From 8349826093f1213f7d683804c453cbd2514ea0b0 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 13 Sep 2026 17:55:29 -0400 Subject: [PATCH 1/3] Add update checks and in-place auto-update from GitHub Releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No Sparkle — zero third-party dependencies — so the mechanism is written out in full and kept small. - `AppVersion`: the `major.minor.patch` in Info.plist and in a `vX.Y.Z` tag. Nil for a `swift run` build, which has no Info.plist. - `ReleaseFeed`: `releases/latest` from the GitHub API, decoded to a `Release` with the first `.dmg` asset. A release without one is still reported and offered as a page. - `UpdateInstaller`: download, `hdiutil attach` read-only, verify the new bundle with a `SecRequirement` built from the running app (Developer ID chain, same Team ID, same bundle identifier, strict + nested), copy next to the current app, two renames, delete the old bundle, detach, then hand `open` to a shell that waits for this PID and terminate. An unsigned or unbundled running copy cannot install and is sent to the release page. - `UpdateCoordinator`: a quiet daily check at launch (skipping a version the user skipped, swallowing network errors) and a talkative manual one from LUTzy ▸ Check for Updates…. Feed and installer are injected for tests. - `UpdateSheet`: one sheet for checking / up to date / available with notes / downloading / installing / failed. Settings gains an Updates section. - `UpdateTests`: 15 tests over versions, feed decoding, both check manners, skip, the interval, install failure, and the on-disk swap. The requirement string was checked by hand against the published v0.1.0 DMG: it passes the real app and fails a wrong team. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ViSYWttguWwFQm1PEksQ7K --- README.md | 5 +- Sources/LUTzyKit/Models/AppPreference.swift | 6 + Sources/LUTzyKit/Models/AppVersion.swift | 46 ++++ Sources/LUTzyKit/Models/ReleaseFeed.swift | 91 +++++++ Sources/LUTzyKit/Models/UpdateInstaller.swift | 251 ++++++++++++++++++ .../LUTzyKit/ViewModels/AppViewModel.swift | 9 +- .../ViewModels/UpdateCoordinator.swift | 177 ++++++++++++ Sources/LUTzyKit/Views/ContentView.swift | 6 + Sources/LUTzyKit/Views/MenuCommands.swift | 8 + Sources/LUTzyKit/Views/SettingsView.swift | 18 ++ Sources/LUTzyKit/Views/UpdateSheet.swift | 92 +++++++ Tests/LUTzyKitTests/UpdateTests.swift | 247 +++++++++++++++++ 12 files changed, 954 insertions(+), 2 deletions(-) create mode 100644 Sources/LUTzyKit/Models/AppVersion.swift create mode 100644 Sources/LUTzyKit/Models/ReleaseFeed.swift create mode 100644 Sources/LUTzyKit/Models/UpdateInstaller.swift create mode 100644 Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift create mode 100644 Sources/LUTzyKit/Views/UpdateSheet.swift create mode 100644 Tests/LUTzyKitTests/UpdateTests.swift diff --git a/README.md b/README.md index a84ec2ed..38b1c46f 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ A 3D LUT is a table with a color at every lattice point. The photo's colors are - **Adjust** — nine sliders after develop, before the LUT: exposure, brightness, contrast, saturation, highlights, shadows, temperature, tint, vibrance - **Filmstrip** when a source folder is open. ← → or [ ] step through; the current look stays on. ⌘R rescans. - **Window** — Liquid Glass toolbar, customizable (View ▸ Customize Toolbar…). The file and LUT names sit in the title. ⌘, sets launch defaults: side-by-side, source browser, export format. +- **Updates** — once a day the packaged app looks at this repo's [releases](https://github.com/tsvb/lutzy/releases) and offers anything newer. LUTzy ▸ Check for Updates… asks now. Install downloads the DMG, verifies the app inside is signed by the same Developer ID team as the running copy, swaps it in place, and relaunches. Off switch and skip-this-version in Settings. - **Export** — 16-bit TIFF, JPEG (quality 0.95), or PNG, always full resolution, named `{photo}_{LUT}.ext` (spaces in the LUT name become underscores). ⌘⇧E Export All writes the whole look — develop, adjustments, LUT, intensity — and counts failures instead of aborting. ## Derive LUT from JPG @@ -69,7 +70,7 @@ The RAW is developed with the same default `CIRAWFilter` settings the rest of th | ⌘D | derive | | ⌘S | export | | ⌘⇧E | export all | -| ⌘, | settings — launch defaults and the sidebar's collapsed folders | +| ⌘, | settings — launch defaults, update checks, and the sidebar's collapsed folders | Letter keys go through SwiftUI's `.onKeyPress` on the split view; the preview canvas is focusable and holds focus by default so the handler always has a focused descendant. ⌘ shortcuts go through the menu bar. @@ -116,6 +117,8 @@ scripts/release-dmg.sh 0.1.1 Does what an Xcode archive would: universal release build, a hand-written `LUTzy.app` with an `Info.plist` and the icon, signed with Developer ID and the hardened runtime (no sandbox yet), notarized and stapled, then packed into a DMG that is notarized and stapled again. Output lands in `build/release/`. Needs `create-dmg` (`brew install create-dmg`) and a notarytool keychain profile. +Publish the DMG as a GitHub release tagged `vX.Y.Z`, the same triple passed to the script. The in-app updater reads `releases/latest` and picks the first `.dmg` asset, so a release without one is offered as a page to open rather than an install. + The icon is drawn by `scripts/render-icon.swift`: the RGB cube a `.cube` file indexes, seen from its green corner, rendered into every slot of the appiconset. Run it again rather than editing the PNGs. ## License diff --git a/Sources/LUTzyKit/Models/AppPreference.swift b/Sources/LUTzyKit/Models/AppPreference.swift index 966b02e5..263fd802 100644 --- a/Sources/LUTzyKit/Models/AppPreference.swift +++ b/Sources/LUTzyKit/Models/AppPreference.swift @@ -19,6 +19,12 @@ enum AppPreference { static let showSourceBrowserOnRestore = "lutzy.showSourceBrowserOnRestore" /// `ExportFormat.rawValue` to start with. Default JPEG. static let defaultExportFormat = "lutzy.defaultExportFormat" + /// Whether the launch-time release check runs. Default `true`. Read live by `UpdateCoordinator`. + static let automaticUpdateChecks = "lutzy.automaticUpdateChecks" + /// `Date` of the last completed release check, automatic or manual. + static let lastUpdateCheck = "lutzy.lastUpdateCheck" + /// A version string the user chose to skip; the automatic check stays quiet about it. + static let skippedUpdateVersion = "lutzy.skippedUpdateVersion" } extension UserDefaults { diff --git a/Sources/LUTzyKit/Models/AppVersion.swift b/Sources/LUTzyKit/Models/AppVersion.swift new file mode 100644 index 00000000..b05d0b2d --- /dev/null +++ b/Sources/LUTzyKit/Models/AppVersion.swift @@ -0,0 +1,46 @@ +import Foundation + +/// A `major.minor.patch` version, as found in `CFBundleShortVersionString` and in a release tag. +/// +/// Accepts a leading `v` because GitHub tags carry one (`v0.1.0`) and Info.plist does not (`0.1.0`). +/// Anything after the third component, or a pre-release suffix, is rejected rather than guessed at: +/// the release script only ever produces plain triples, so a tag that does not parse is a tag the +/// updater should ignore, not one it should half-understand. +struct AppVersion: Sendable, Equatable, Comparable, CustomStringConvertible { + let major: Int + let minor: Int + let patch: Int + + init(_ major: Int, _ minor: Int, _ patch: Int) { + self.major = major + self.minor = minor + self.patch = patch + } + + init?(_ string: String) { + var text = Substring(string.trimmingCharacters(in: .whitespaces)) + if text.first == "v" || text.first == "V" { text = text.dropFirst() } + let parts = text.split(separator: ".", omittingEmptySubsequences: false) + guard (1...3).contains(parts.count) else { return nil } + var numbers: [Int] = [] + for part in parts { + guard !part.isEmpty, part.allSatisfy(\.isNumber), let n = Int(part) else { return nil } + numbers.append(n) + } + while numbers.count < 3 { numbers.append(0) } + self.init(numbers[0], numbers[1], numbers[2]) + } + + static func < (lhs: AppVersion, rhs: AppVersion) -> Bool { + (lhs.major, lhs.minor, lhs.patch) < (rhs.major, rhs.minor, rhs.patch) + } + + var description: String { "\(major).\(minor).\(patch)" } + + /// The version of the running app, or `nil` for a bare SwiftPM executable, which has no + /// Info.plist. Updates are a property of the packaged app; `swift run` has nothing to update. + static var current: AppVersion? { + guard let string = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return nil } + return AppVersion(string) + } +} diff --git a/Sources/LUTzyKit/Models/ReleaseFeed.swift b/Sources/LUTzyKit/Models/ReleaseFeed.swift new file mode 100644 index 00000000..78a75916 --- /dev/null +++ b/Sources/LUTzyKit/Models/ReleaseFeed.swift @@ -0,0 +1,91 @@ +import Foundation + +/// One published release, reduced to what the updater needs. +struct Release: Sendable, Equatable { + let version: AppVersion + let title: String + /// The release notes, as GitHub-flavoured Markdown. + let notes: String + /// The release page, for a build that cannot install itself. + let pageURL: URL + /// The disk image to install from, if the release carries one. + let diskImageURL: URL? + let diskImageSize: Int? +} + +/// Where releases come from: the GitHub Releases API for this repository. +/// +/// `releases/latest` is the newest non-draft, non-prerelease release, which is exactly the set +/// `scripts/release-dmg.sh` output is published to. The DMG asset is found by extension, so the +/// asset name can change without a code change; a release with no DMG is still reported, and the +/// sheet offers the release page instead of an install. +enum ReleaseFeed { + + static let repository = "tsvb/lutzy" + + static func latestURL(repository: String = repository) -> URL { + URL(string: "https://api.github.com/repos/\(repository)/releases/latest")! + } + + enum FeedError: LocalizedError, Equatable { + case badStatus(Int) + case unusableTag(String) + + var errorDescription: String? { + switch self { + case .badStatus(let code): return "GitHub answered with status \(code)." + case .unusableTag(let tag): return "The latest release is tagged \"\(tag)\", which is not a version." + } + } + } + + /// Fetch and decode the latest release. Unauthenticated: sixty requests an hour is plenty for + /// one check a day plus a few manual ones. + static func fetchLatest( + repository: String = repository, + session: URLSession = .shared + ) async throws -> Release { + var request = URLRequest(url: latestURL(repository: repository)) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("LUTzy/\(AppVersion.current?.description ?? "dev")", forHTTPHeaderField: "User-Agent") + request.timeoutInterval = 15 + let (data, response) = try await session.data(for: request) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw FeedError.badStatus(http.statusCode) + } + return try parse(data) + } + + // MARK: - Decoding + + private struct Payload: Decodable { + struct Asset: Decodable { + let name: String + let browser_download_url: URL + let size: Int? + } + let tag_name: String + let name: String? + let body: String? + let html_url: URL + let assets: [Asset]? + } + + /// Decode a `releases/latest` body. Internal so the decoder can be tested on a fixture without + /// a network. + static func parse(_ data: Data) throws -> Release { + let payload = try JSONDecoder().decode(Payload.self, from: data) + guard let version = AppVersion(payload.tag_name) else { + throw FeedError.unusableTag(payload.tag_name) + } + let dmg = payload.assets?.first { $0.name.lowercased().hasSuffix(".dmg") } + return Release( + version: version, + title: payload.name?.isEmpty == false ? payload.name! : "LUTzy \(version)", + notes: payload.body ?? "", + pageURL: payload.html_url, + diskImageURL: dmg?.browser_download_url, + diskImageSize: dmg?.size + ) + } +} diff --git a/Sources/LUTzyKit/Models/UpdateInstaller.swift b/Sources/LUTzyKit/Models/UpdateInstaller.swift new file mode 100644 index 00000000..a4f08aad --- /dev/null +++ b/Sources/LUTzyKit/Models/UpdateInstaller.swift @@ -0,0 +1,251 @@ +import Foundation +import AppKit +import Security + +/// Replaces the running app with the one inside a downloaded disk image, then relaunches. +/// +/// There is no Sparkle here — zero third-party dependencies — so this is the whole mechanism, +/// and it is deliberately small: +/// +/// 1. Download the DMG to a private temp directory. +/// 2. Mount it read-only with `hdiutil`, at a mount point of our choosing. +/// 3. **Verify the new app's code signature** against a requirement built from the running app: +/// Apple-anchored Developer ID, the same Team ID, the same bundle identifier, strict and nested. +/// A download that fails this is thrown away, whatever the feed said. This is the step that +/// makes "fetch a binary from the internet and run it" acceptable; an unsigned running app — +/// `swift run` — has no Team ID to check against and cannot install at all. +/// 4. Copy the app next to the current one (same volume, so the swap is two renames), swap, and +/// delete the old bundle. The running process keeps its unlinked files and is unaffected. +/// 5. Detach, then hand `open` to a shell that waits for this process to exit, and terminate. +/// +/// Everything here is `nonisolated` and blocking-free from the caller's point of view: the +/// coordinator awaits it from the main actor while the sheet shows progress. +enum UpdateInstaller { + + enum InstallError: LocalizedError { + case notAnAppBundle + case runningAppUnsigned + case noDiskImage + case mountFailed(String) + case noAppInImage + case signatureRejected(String) + case destinationNotWritable(URL) + case swapFailed(String) + + var errorDescription: String? { + switch self { + case .notAnAppBundle: + return "This build is not an app bundle, so it cannot replace itself. Download the release instead." + case .runningAppUnsigned: + return "This copy of LUTzy is not signed, so a downloaded update cannot be verified against it." + case .noDiskImage: + return "The release has no disk image to install from." + case .mountFailed(let detail): + return "The disk image could not be opened. \(detail)" + case .noAppInImage: + return "The disk image does not contain an app." + case .signatureRejected(let detail): + return "The downloaded app failed signature verification and was discarded. \(detail)" + case .destinationNotWritable(let url): + return "LUTzy cannot write to \(url.path). Move the app somewhere you own, or install the update by hand." + case .swapFailed(let detail): + return "The update could not be put in place. \(detail)" + } + } + } + + /// The identity the running app was signed with, or `nil` when it is unsigned or not a bundle. + struct SigningIdentity: Sendable, Equatable { + let teamID: String + let bundleID: String + } + + static func signingIdentity(of bundleURL: URL) -> SigningIdentity? { + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(bundleURL as CFURL, [], &staticCode) == errSecSuccess, + let code = staticCode else { return nil } + var info: CFDictionary? + let flags = SecCSFlags(rawValue: kSecCSSigningInformation) + guard SecCodeCopySigningInformation(code, flags, &info) == errSecSuccess, + let dict = info as? [String: Any], + let team = dict[kSecCodeInfoTeamIdentifier as String] as? String, + let bundle = dict[kSecCodeInfoIdentifier as String] as? String + else { return nil } + return SigningIdentity(teamID: team, bundleID: bundle) + } + + /// The running app, if it is a real bundle: `swift run` yields a bare executable whose + /// `bundleURL` is a directory of build products, not an `.app`. + static var runningAppBundle: URL? { + let url = Bundle.main.bundleURL + return url.pathExtension == "app" ? url : nil + } + + /// True when this process could replace itself: a signed `.app`. + static var canInstallInPlace: Bool { + guard let app = runningAppBundle else { return false } + return signingIdentity(of: app) != nil + } + + // MARK: - Steps + + static func download(_ url: URL, session: URLSession = .shared) async throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("LUTzy Update \(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + var request = URLRequest(url: url) + request.timeoutInterval = 120 + let (temp, response) = try await session.download(for: request) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw ReleaseFeed.FeedError.badStatus(http.statusCode) + } + let dmg = dir.appendingPathComponent(url.lastPathComponent.isEmpty ? "update.dmg" : url.lastPathComponent) + try FileManager.default.moveItem(at: temp, to: dmg) + return dmg + } + + /// Mount read-only at a fresh mount point and return it. + static func mount(_ dmg: URL) async throws -> URL { + let point = dmg.deletingLastPathComponent().appendingPathComponent("mount", isDirectory: true) + let result = try await run("/usr/bin/hdiutil", [ + "attach", dmg.path, "-nobrowse", "-readonly", "-noautoopen", "-noverify", + "-mountpoint", point.path, + ]) + guard result.status == 0 else { throw InstallError.mountFailed(result.output) } + return point + } + + static func detach(_ mountPoint: URL) async { + _ = try? await run("/usr/bin/hdiutil", ["detach", mountPoint.path, "-quiet"]) + } + + static func findApp(in mountPoint: URL) throws -> URL { + let entries = try FileManager.default.contentsOfDirectory(at: mountPoint, includingPropertiesForKeys: nil) + guard let app = entries.first(where: { $0.pathExtension == "app" }) else { + throw InstallError.noAppInImage + } + return app + } + + /// Check the new bundle against the running app's identity. The requirement language is + /// Apple's own (`man csreq`): a Developer ID chain, this team, this bundle identifier. + static func verify(_ appURL: URL, against identity: SigningIdentity) throws { + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode) == errSecSuccess, + let code = staticCode else { + throw InstallError.signatureRejected("The app could not be read.") + } + let text = "anchor apple generic" + + " and certificate 1[field.1.2.840.113635.100.6.2.6]" // Developer ID CA + + " and certificate leaf[field.1.2.840.113635.100.6.1.13]" // Developer ID Application + + " and certificate leaf[subject.OU] = \"\(identity.teamID)\"" + + " and identifier \"\(identity.bundleID)\"" + var requirement: SecRequirement? + guard SecRequirementCreateWithString(text as CFString, [], &requirement) == errSecSuccess, + let req = requirement else { + throw InstallError.signatureRejected("The verification requirement could not be built.") + } + let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode | kSecCSStrictValidate) + var errors: Unmanaged? + let status = SecStaticCodeCheckValidityWithErrors(code, flags, req, &errors) + if status != errSecSuccess { + let detail = errors?.takeRetainedValue().localizedDescription ?? "OSStatus \(status)" + throw InstallError.signatureRejected(detail) + } + } + + /// Put `newApp` where `currentApp` is. Copy first (cross-volume), then two renames. + static func swap(newApp: URL, into currentApp: URL) throws { + let fm = FileManager.default + let parent = currentApp.deletingLastPathComponent() + guard fm.isWritableFile(atPath: parent.path) else { + throw InstallError.destinationNotWritable(parent) + } + let token = UUID().uuidString + let staged = parent.appendingPathComponent(".LUTzy-update-\(token).app") + let retired = parent.appendingPathComponent(".LUTzy-old-\(token).app") + do { + try fm.copyItem(at: newApp, to: staged) + } catch { + throw InstallError.swapFailed(error.localizedDescription) + } + do { + try fm.moveItem(at: currentApp, to: retired) + } catch { + try? fm.removeItem(at: staged) + throw InstallError.swapFailed(error.localizedDescription) + } + do { + try fm.moveItem(at: staged, to: currentApp) + } catch { + try? fm.moveItem(at: retired, to: currentApp) + try? fm.removeItem(at: staged) + throw InstallError.swapFailed(error.localizedDescription) + } + try? fm.removeItem(at: retired) + } + + /// Relaunch once this process has gone. `open` is deferred to a shell that polls the PID, so + /// there is never a moment with two copies running and Launch Services always starts the new + /// bundle rather than reusing this one. + @MainActor + static func relaunch(_ appURL: URL) { + let script = "while /bin/kill -0 \"$1\" 2>/dev/null; do /bin/sleep 0.2; done; /usr/bin/open \"$0\"" + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", script, appURL.path, String(ProcessInfo.processInfo.processIdentifier)] + try? process.run() + NSApp.terminate(nil) + } + + // MARK: - The whole thing + + /// Download, verify and swap. Returns the path of the installed app; the caller relaunches. + static func install(_ release: Release) async throws -> URL { + guard let currentApp = runningAppBundle else { throw InstallError.notAnAppBundle } + guard let identity = signingIdentity(of: currentApp) else { throw InstallError.runningAppUnsigned } + guard let dmgURL = release.diskImageURL else { throw InstallError.noDiskImage } + + let dmg = try await download(dmgURL) + defer { try? FileManager.default.removeItem(at: dmg.deletingLastPathComponent()) } + let mountPoint = try await mount(dmg) + do { + let newApp = try findApp(in: mountPoint) + try verify(newApp, against: identity) + try swap(newApp: newApp, into: currentApp) + } catch { + await detach(mountPoint) + throw error + } + await detach(mountPoint) + return currentApp + } + + // MARK: - Process helper + + private struct RunResult: Sendable { + let status: Int32 + let output: String + } + + private static func run(_ executable: String, _ arguments: [String]) async throws -> RunResult { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + process.terminationHandler = { p in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + continuation.resume(returning: RunResult(status: p.terminationStatus, output: text)) + } + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + } +} diff --git a/Sources/LUTzyKit/ViewModels/AppViewModel.swift b/Sources/LUTzyKit/ViewModels/AppViewModel.swift index db8906b7..1d7dc396 100644 --- a/Sources/LUTzyKit/ViewModels/AppViewModel.swift +++ b/Sources/LUTzyKit/ViewModels/AppViewModel.swift @@ -263,6 +263,8 @@ final class AppViewModel { let export: ExportCoordinator /// The "Derive LUT from JPG" flow and its scratch-until-saved result. let derive = DeriveCoordinator() + /// Release checks and the in-place update. Its automatic check is kicked off from `init`. + let updates: UpdateCoordinator // Convenience passthroughs so views and the menu don't have to know which // collaborator owns a given piece of state. @@ -284,9 +286,10 @@ final class AppViewModel { // MARK: - Init - init(engine: any RenderEngining = RenderEngine.shared) { + init(engine: any RenderEngining = RenderEngine.shared, updates: UpdateCoordinator = UpdateCoordinator()) { self.engine = engine self.export = ExportCoordinator(engine: engine) + self.updates = updates // Launch defaults from Settings (⌘,). Read once: see `AppPreference`. self.isSideBySide = UserDefaults.standard.bool(forKey: AppPreference.defaultSideBySide, default: true) @@ -305,6 +308,10 @@ final class AppViewModel { forKey: AppPreference.showSourceBrowserOnRestore, default: true) openFirstImageWhenScanned() } + + // Quiet unless there is something newer; a no-op in a `swift run` build or a test, which + // have no version to compare. See `UpdateCoordinator`. + updates.checkAutomaticallyIfDue() } /// Point the coordinators' status/error output at this view model, which diff --git a/Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift b/Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift new file mode 100644 index 00000000..8d089137 --- /dev/null +++ b/Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift @@ -0,0 +1,177 @@ +import Foundation +import AppKit +import Observation + +/// Owns the update flow: the daily background check, the manual one, the sheet, and the install. +/// +/// Two checks with different manners. The **automatic** check runs at launch when a day has passed, +/// says nothing unless there is something newer than both the running version and a version the +/// user has chosen to skip, and swallows failures — a laptop without Wi-Fi does not need an alert +/// about GitHub. The **manual** check (LUTzy ▸ Check for Updates…) reports whatever it finds: newer, +/// up to date, skipped-but-newer, or the error. +/// +/// The feed is injected as a closure so a test can drive every phase without a network; the +/// installer is injected the same way so a test never swaps a bundle. `AppVersion.current` is +/// injected too, because a test process has no `CFBundleShortVersionString`. +@Observable +@MainActor +final class UpdateCoordinator { + + enum Phase: Equatable { + case checking + case upToDate + case available(Release) + case downloading(Release) + case installing(Release) + case failed(String) + } + + typealias Fetch = @Sendable () async throws -> Release + typealias Install = @Sendable (Release) async throws -> URL + + private(set) var phase: Phase? + var isSheetPresented = false + + /// Whether the sheet can offer "Install and Relaunch" or only the release page. + let canInstallInPlace: Bool + let currentVersion: AppVersion? + + @ObservationIgnored private let fetch: Fetch + @ObservationIgnored private let install: Install + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private var task: Task? + + static let automaticInterval: TimeInterval = 24 * 60 * 60 + + init( + currentVersion: AppVersion? = AppVersion.current, + canInstallInPlace: Bool = UpdateInstaller.canInstallInPlace, + defaults: UserDefaults = .standard, + fetch: @escaping Fetch = { try await ReleaseFeed.fetchLatest() }, + install: @escaping Install = { try await UpdateInstaller.install($0) } + ) { + self.currentVersion = currentVersion + self.canInstallInPlace = canInstallInPlace + self.defaults = defaults + self.fetch = fetch + self.install = install + } + + // MARK: - Preferences + + var checksAutomatically: Bool { + get { defaults.bool(forKey: AppPreference.automaticUpdateChecks, default: true) } + set { defaults.set(newValue, forKey: AppPreference.automaticUpdateChecks) } + } + + var lastCheck: Date? { + get { defaults.object(forKey: AppPreference.lastUpdateCheck) as? Date } + set { defaults.set(newValue, forKey: AppPreference.lastUpdateCheck) } + } + + var skippedVersion: AppVersion? { + get { defaults.string(forKey: AppPreference.skippedUpdateVersion).flatMap(AppVersion.init) } + set { defaults.set(newValue?.description, forKey: AppPreference.skippedUpdateVersion) } + } + + // MARK: - Checking + + /// True when the automatic check should run now: the preference is on, this is a packaged build + /// with a version to compare, and the last check is older than a day (or never happened). + var isAutomaticCheckDue: Bool { + guard checksAutomatically, currentVersion != nil else { return false } + guard let last = lastCheck else { return true } + return Date().timeIntervalSince(last) >= Self.automaticInterval + } + + /// The launch-time check. A no-op unless due; never shows anything but an available update. + func checkAutomaticallyIfDue() { + guard isAutomaticCheckDue else { return } + check(userInitiated: false) + } + + /// LUTzy ▸ Check for Updates…. Always reports. + func checkNow() { + check(userInitiated: true) + } + + private func check(userInitiated: Bool) { + if case .downloading = phase { return } + if case .installing = phase { return } + task?.cancel() + if userInitiated { + phase = .checking + isSheetPresented = true + } + let fetch = self.fetch + task = Task { + let result: Result + do { result = .success(try await fetch()) } catch { result = .failure(error) } + guard !Task.isCancelled else { return } + lastCheck = Date() + switch result { + case .failure(let error): + if userInitiated { phase = .failed(error.localizedDescription) } + else { phase = nil } + case .success(let release): + if isNewer(release) { + if userInitiated || release.version != skippedVersion { + phase = .available(release) + isSheetPresented = true + } else { + phase = nil + } + } else { + phase = userInitiated ? .upToDate : nil + } + } + } + } + + /// Newer than the running version. A build with no version — `swift run` — treats every release + /// as newer so a manual check still shows what is out there. + func isNewer(_ release: Release) -> Bool { + guard let current = currentVersion else { return true } + return release.version > current + } + + // MARK: - Acting on it + + func skip(_ release: Release) { + skippedVersion = release.version + dismiss() + } + + func remindLater() { + dismiss() + } + + func openReleasePage(_ release: Release) { + NSWorkspace.shared.open(release.pageURL) + dismiss() + } + + /// Download, verify, swap, relaunch. The sheet stays up and shows each stage; on failure the + /// message replaces it and nothing has changed on disk. + func installAndRelaunch(_ release: Release) { + guard canInstallInPlace else { return openReleasePage(release) } + phase = .downloading(release) + let install = self.install + task = Task { + do { + let installed = try await install(release) + guard !Task.isCancelled else { return } + phase = .installing(release) + UpdateInstaller.relaunch(installed) + } catch { + guard !Task.isCancelled else { return } + phase = .failed(error.localizedDescription) + } + } + } + + func dismiss() { + isSheetPresented = false + phase = nil + } +} diff --git a/Sources/LUTzyKit/Views/ContentView.swift b/Sources/LUTzyKit/Views/ContentView.swift index beb09cf4..9acb3185 100644 --- a/Sources/LUTzyKit/Views/ContentView.swift +++ b/Sources/LUTzyKit/Views/ContentView.swift @@ -39,6 +39,9 @@ public struct ContentView: View { .sheet(isPresented: Bindable(viewModel.derive).isSheetPresented) { RecipeExtractorSheet(coordinator: viewModel.derive) } + .sheet(isPresented: Bindable(viewModel.updates).isSheetPresented) { + UpdateSheet(coordinator: viewModel.updates) + } .modifier(MenuCommandReceivers(viewModel: viewModel)) .alert( "Something went wrong", @@ -89,6 +92,9 @@ public struct ContentView: View { .onChange(of: viewModel.derive.isSheetPresented) { _, presented in if !presented { isCanvasFocused = true } } + .onChange(of: viewModel.updates.isSheetPresented) { _, presented in + if !presented { isCanvasFocused = true } + } .onKeyPress(keys: KeyCommandMap.keys, phases: KeyCommandMap.phases) { press in guard !isSearchFocused, !KeyCommandMap.textInputHasFocus(), diff --git a/Sources/LUTzyKit/Views/MenuCommands.swift b/Sources/LUTzyKit/Views/MenuCommands.swift index 6f4aa68d..51d4b1fb 100644 --- a/Sources/LUTzyKit/Views/MenuCommands.swift +++ b/Sources/LUTzyKit/Views/MenuCommands.swift @@ -17,6 +17,10 @@ public struct LUTzyCommands: Commands { public init() {} public var body: some Commands { + CommandGroup(after: .appInfo) { + Button("Check for Updates…") { post(.checkForUpdates) } + } + CommandGroup(replacing: .newItem) { Button("Open Image...") { post(.openImage) } .keyboardShortcut("o") @@ -87,6 +91,9 @@ struct MenuCommandReceivers: ViewModifier { .onReceive(NotificationCenter.default.publisher(for: .deriveRecipe)) { _ in viewModel.presentRecipeExtractor() } + .onReceive(NotificationCenter.default.publisher(for: .checkForUpdates)) { _ in + viewModel.updates.checkNow() + } } } @@ -101,4 +108,5 @@ extension Notification.Name { static let openSourceFolder = Notification.Name("LUTzy.openSourceFolder") static let refreshSourceFolder = Notification.Name("LUTzy.refreshSourceFolder") static let deriveRecipe = Notification.Name("LUTzy.deriveRecipe") + static let checkForUpdates = Notification.Name("LUTzy.checkForUpdates") } diff --git a/Sources/LUTzyKit/Views/SettingsView.swift b/Sources/LUTzyKit/Views/SettingsView.swift index 59292e25..04b99740 100644 --- a/Sources/LUTzyKit/Views/SettingsView.swift +++ b/Sources/LUTzyKit/Views/SettingsView.swift @@ -7,6 +7,7 @@ struct SettingsView: View { @AppStorage(AppPreference.showSourceBrowserOnRestore) private var showSourceBrowserOnRestore = true @AppStorage(AppPreference.defaultExportFormat) private var defaultExportFormat = ExportFormat.jpeg.rawValue @AppStorage(AppPreference.collapsedLUTCategories) private var collapsedLUTCategories = "" + @AppStorage(AppPreference.automaticUpdateChecks) private var automaticUpdateChecks = true var body: some View { Form { @@ -41,6 +42,23 @@ struct SettingsView: View { .disabled(collapsedLUTCategories.isEmpty) } } + + Section { + Toggle("Check for updates automatically", isOn: $automaticUpdateChecks) + LabeledContent("Version") { + HStack(spacing: 8) { + Text(AppVersion.current?.description ?? "development build") + .foregroundStyle(.secondary) + Button("Check Now") { + NotificationCenter.default.post(name: .checkForUpdates, object: nil) + } + } + } + } header: { + Text("Updates") + } footer: { + Text("Once a day, LUTzy looks at its GitHub releases. An update is installed only after its signature is verified against this copy.") + } } .formStyle(.grouped) .frame(width: 460) diff --git a/Sources/LUTzyKit/Views/UpdateSheet.swift b/Sources/LUTzyKit/Views/UpdateSheet.swift new file mode 100644 index 00000000..552c2157 --- /dev/null +++ b/Sources/LUTzyKit/Views/UpdateSheet.swift @@ -0,0 +1,92 @@ +import SwiftUI + +/// The one sheet for every update phase: checking, up to date, available, in progress, failed. +struct UpdateSheet: View { + let coordinator: UpdateCoordinator + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + switch coordinator.phase { + case .none, .checking: + header("Checking for updates…", detail: nil) + ProgressView().controlSize(.small) + buttons { Button("Cancel") { coordinator.dismiss() }.keyboardShortcut(.cancelAction) } + + case .upToDate: + header("LUTzy is up to date", detail: versionLine) + buttons { Button("OK") { coordinator.dismiss() }.keyboardShortcut(.defaultAction) } + + case .available(let release): + header("\(release.title) is available", detail: versionLine) + notes(release.notes) + buttons { + Button("Skip This Version") { coordinator.skip(release) } + Spacer() + Button("Later") { coordinator.remindLater() }.keyboardShortcut(.cancelAction) + if coordinator.canInstallInPlace && release.diskImageURL != nil { + Button("Install and Relaunch") { coordinator.installAndRelaunch(release) } + .keyboardShortcut(.defaultAction) + } else { + Button("Open Release Page") { coordinator.openReleasePage(release) } + .keyboardShortcut(.defaultAction) + } + } + + case .downloading(let release): + header("Downloading \(release.title)…", detail: sizeLine(release)) + ProgressView().controlSize(.small) + + case .installing(let release): + header("Installing \(release.title)…", detail: "LUTzy will relaunch in a moment.") + ProgressView().controlSize(.small) + + case .failed(let message): + header("The update didn't go through", detail: message) + buttons { Button("OK") { coordinator.dismiss() }.keyboardShortcut(.defaultAction) } + } + } + .padding(20) + .frame(width: 460) + } + + private var versionLine: String? { + coordinator.currentVersion.map { "You have \($0)." } + } + + private func sizeLine(_ release: Release) -> String? { + release.diskImageSize.map { ByteCountFormatter.string(fromByteCount: Int64($0), countStyle: .file) } + } + + private func header(_ title: String, detail: String?) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + if let detail { + Text(detail).font(.callout).foregroundStyle(.secondary) + } + } + } + + /// Release notes are GitHub Markdown. Inline syntax renders; block syntax (headings, lists) + /// keeps its line breaks and reads as plain text, which is fine for a changelog. + private func notes(_ markdown: String) -> some View { + ScrollView { + Text(attributed(markdown)) + .font(.callout) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 220) + .padding(10) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) + } + + private func attributed(_ markdown: String) -> AttributedString { + let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + return (try? AttributedString(markdown: markdown, options: options)) ?? AttributedString(markdown) + } + + private func buttons(@ViewBuilder _ content: () -> Content) -> some View { + HStack { content() } + .frame(maxWidth: .infinity, alignment: .trailing) + } +} diff --git a/Tests/LUTzyKitTests/UpdateTests.swift b/Tests/LUTzyKitTests/UpdateTests.swift new file mode 100644 index 00000000..3139677e --- /dev/null +++ b/Tests/LUTzyKitTests/UpdateTests.swift @@ -0,0 +1,247 @@ +import XCTest +@testable import LUTzyKit + +/// The updater without a network or a bundle: version arithmetic, feed decoding, the coordinator's +/// two manners (quiet automatic check, talkative manual one), and the on-disk swap. The signature +/// check and the real download are not staged here — they need a Developer ID-signed bundle, which +/// `swift test` is not. +@MainActor +final class UpdateTests: TempDirectoryTestCase { + + // MARK: - AppVersion + + func testVersionParsesWithAndWithoutTheTagPrefix() { + XCTAssertEqual(AppVersion("v0.1.0"), AppVersion(0, 1, 0)) + XCTAssertEqual(AppVersion("0.10.2"), AppVersion(0, 10, 2)) + XCTAssertEqual(AppVersion("1.2"), AppVersion(1, 2, 0), "a missing component reads as zero") + XCTAssertNil(AppVersion("v0.1.0-beta.1"), "a pre-release suffix is not guessed at") + XCTAssertNil(AppVersion("1.2.3.4")) + XCTAssertNil(AppVersion("latest")) + XCTAssertNil(AppVersion("")) + } + + func testVersionOrderIsNumericNotLexical() { + XCTAssertLessThan(AppVersion(0, 9, 0), AppVersion(0, 10, 0)) + XCTAssertLessThan(AppVersion(0, 1, 9), AppVersion(0, 2, 0)) + XCTAssertLessThan(AppVersion(1, 0, 0), AppVersion(1, 0, 1)) + XCTAssertEqual(AppVersion(1, 0, 0).description, "1.0.0") + } + + // MARK: - ReleaseFeed + + private let feedJSON = """ + { + "tag_name": "v0.2.0", + "name": "LUTzy 0.2.0", + "body": "## Highlights\\n\\n- Drops from **Photos**", + "html_url": "https://github.com/tsvb/lutzy/releases/tag/v0.2.0", + "assets": [ + {"name": "LUTzy-0.2.0.dmg", "browser_download_url": "https://github.com/tsvb/lutzy/releases/download/v0.2.0/LUTzy-0.2.0.dmg", "size": 1779807, "content_type": "application/x-apple-diskimage"}, + {"name": "checksums.txt", "browser_download_url": "https://example.com/checksums.txt", "size": 12} + ] + } + """ + + func testFeedDecodesTheReleaseAndFindsTheDiskImage() throws { + let release = try ReleaseFeed.parse(Data(feedJSON.utf8)) + XCTAssertEqual(release.version, AppVersion(0, 2, 0)) + XCTAssertEqual(release.title, "LUTzy 0.2.0") + XCTAssertEqual(release.diskImageURL?.lastPathComponent, "LUTzy-0.2.0.dmg") + XCTAssertEqual(release.diskImageSize, 1_779_807) + XCTAssertTrue(release.notes.contains("Photos")) + } + + func testFeedWithoutADiskImageStillReports() throws { + let json = #"{"tag_name": "v0.3.0", "html_url": "https://example.com/r", "assets": []}"# + let release = try ReleaseFeed.parse(Data(json.utf8)) + XCTAssertNil(release.diskImageURL) + XCTAssertEqual(release.title, "LUTzy 0.3.0", "a missing name falls back to the version") + } + + func testFeedRejectsATagThatIsNotAVersion() { + let json = #"{"tag_name": "nightly", "html_url": "https://example.com/r"}"# + XCTAssertThrowsError(try ReleaseFeed.parse(Data(json.utf8))) { error in + XCTAssertEqual(error as? ReleaseFeed.FeedError, .unusableTag("nightly")) + } + } + + // MARK: - UpdateCoordinator + + nonisolated private static func makeRelease(_ version: String, dmg: Bool = true) -> Release { + Release( + version: AppVersion(version)!, title: "LUTzy \(version)", notes: "", + pageURL: URL(string: "https://example.com/r")!, + diskImageURL: dmg ? URL(string: "https://example.com/r.dmg") : nil, diskImageSize: nil + ) + } + + private func makeDefaults() -> UserDefaults { + let suite = "UpdateTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } + + private func waitUntil( + _ description: String, timeout: TimeInterval = 5, _ condition: @MainActor () -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + if Date() > deadline { return XCTFail("timed out waiting for \(description)") } + try await Task.sleep(for: .milliseconds(10)) + } + } + + private func makeCoordinator( + current: String? = "0.1.0", + defaults: UserDefaults? = nil, + fetch: @escaping UpdateCoordinator.Fetch, + install: @escaping UpdateCoordinator.Install = { _ in throw CocoaError(.featureUnsupported) } + ) -> UpdateCoordinator { + UpdateCoordinator( + currentVersion: current.flatMap(AppVersion.init), canInstallInPlace: true, + defaults: defaults ?? makeDefaults(), fetch: fetch, install: install + ) + } + + func testManualCheckShowsANewerRelease() async throws { + let release = Self.makeRelease("0.2.0") + let coordinator = makeCoordinator(fetch: { release }) + coordinator.checkNow() + XCTAssertEqual(coordinator.phase, .checking) + XCTAssertTrue(coordinator.isSheetPresented, "the sheet opens at once so the check is visible") + try await waitUntil("the release") { coordinator.phase == .available(release) } + XCTAssertNotNil(coordinator.lastCheck) + } + + func testManualCheckReportsUpToDate() async throws { + let coordinator = makeCoordinator(fetch: { Self.makeRelease("0.1.0") }) + coordinator.checkNow() + try await waitUntil("up to date") { coordinator.phase == .upToDate } + } + + func testManualCheckReportsAFailure() async throws { + let coordinator = makeCoordinator(fetch: { throw ReleaseFeed.FeedError.badStatus(503) }) + coordinator.checkNow() + try await waitUntil("the failure") { + if case .failed(let message) = coordinator.phase { return message.contains("503") } + return false + } + } + + func testAutomaticCheckIsQuietUnlessSomethingIsNewer() async throws { + let coordinator = makeCoordinator(fetch: { Self.makeRelease("0.1.0") }) + XCTAssertTrue(coordinator.isAutomaticCheckDue, "never checked, so due") + coordinator.checkAutomaticallyIfDue() + XCTAssertFalse(coordinator.isSheetPresented) + try await waitUntil("the check to land") { coordinator.lastCheck != nil } + XCTAssertNil(coordinator.phase) + XCTAssertFalse(coordinator.isSheetPresented) + XCTAssertFalse(coordinator.isAutomaticCheckDue, "just checked, so not due again") + } + + func testAutomaticCheckShowsANewerReleaseAndSwallowsErrors() async throws { + let release = Self.makeRelease("0.2.0") + let found = makeCoordinator(fetch: { release }) + found.checkAutomaticallyIfDue() + try await waitUntil("the release") { found.phase == .available(release) } + XCTAssertTrue(found.isSheetPresented) + + let failing = makeCoordinator(fetch: { throw URLError(.notConnectedToInternet) }) + failing.checkAutomaticallyIfDue() + try await waitUntil("the check to land") { failing.lastCheck != nil } + XCTAssertNil(failing.phase, "an offline launch is not an alert") + XCTAssertFalse(failing.isSheetPresented) + } + + func testASkippedVersionStaysQuietAutomaticallyButNotManually() async throws { + let release = Self.makeRelease("0.2.0") + let defaults = makeDefaults() + let coordinator = makeCoordinator(defaults: defaults, fetch: { release }) + coordinator.skip(release) + XCTAssertEqual(coordinator.skippedVersion, release.version) + + coordinator.checkAutomaticallyIfDue() + try await waitUntil("the check to land") { coordinator.lastCheck != nil } + XCTAssertNil(coordinator.phase) + + coordinator.checkNow() + try await waitUntil("the release") { coordinator.phase == .available(release) } + } + + func testAutomaticCheckRespectsThePreferenceAndTheInterval() { + let defaults = makeDefaults() + let coordinator = makeCoordinator(defaults: defaults, fetch: { Self.makeRelease("0.2.0") }) + coordinator.checksAutomatically = false + XCTAssertFalse(coordinator.isAutomaticCheckDue) + coordinator.checksAutomatically = true + coordinator.lastCheck = Date().addingTimeInterval(-60 * 60) + XCTAssertFalse(coordinator.isAutomaticCheckDue, "an hour ago is too recent") + coordinator.lastCheck = Date().addingTimeInterval(-UpdateCoordinator.automaticInterval - 1) + XCTAssertTrue(coordinator.isAutomaticCheckDue) + } + + func testAnUnversionedBuildNeverChecksAutomatically() { + let coordinator = makeCoordinator(current: nil, fetch: { Self.makeRelease("0.2.0") }) + XCTAssertFalse(coordinator.isAutomaticCheckDue, "`swift run` has nothing to update") + XCTAssertTrue(coordinator.isNewer(Self.makeRelease("0.0.1")), "but a manual check still shows what exists") + } + + func testAFailedInstallLeavesTheMessageOnTheSheet() async throws { + let release = Self.makeRelease("0.2.0") + let coordinator = makeCoordinator( + fetch: { release }, + install: { _ in throw UpdateInstaller.InstallError.signatureRejected("wrong team") } + ) + coordinator.installAndRelaunch(release) + XCTAssertEqual(coordinator.phase, .downloading(release)) + try await waitUntil("the failure") { + if case .failed(let message) = coordinator.phase { return message.contains("wrong team") } + return false + } + } + + // MARK: - UpdateInstaller.swap + + private func makeBundle(named name: String, marker: String) throws -> URL { + let app = tempDirectory.appendingPathComponent(name) + try FileManager.default.createDirectory( + at: app.appendingPathComponent("Contents/MacOS"), withIntermediateDirectories: true) + try marker.write(to: app.appendingPathComponent("Contents/marker"), atomically: true, encoding: .utf8) + return app + } + + func testSwapPutsTheNewBundleInPlaceAndLeavesNoDebris() throws { + let current = try makeBundle(named: "LUTzy.app", marker: "old") + let image = tempDirectory.appendingPathComponent("image", isDirectory: true) + try FileManager.default.createDirectory(at: image, withIntermediateDirectories: true) + let incoming = image.appendingPathComponent("LUTzy.app") + try FileManager.default.createDirectory( + at: incoming.appendingPathComponent("Contents/MacOS"), withIntermediateDirectories: true) + try "new".write(to: incoming.appendingPathComponent("Contents/marker"), atomically: true, encoding: .utf8) + + try UpdateInstaller.swap(newApp: incoming, into: current) + + XCTAssertEqual(try String(contentsOf: current.appendingPathComponent("Contents/marker"), encoding: .utf8), "new") + XCTAssertTrue(FileManager.default.fileExists(atPath: incoming.path), "the mounted copy is untouched") + let leftovers = try FileManager.default.contentsOfDirectory(atPath: tempDirectory.path) + .filter { $0.hasPrefix(".LUTzy-") } + XCTAssertTrue(leftovers.isEmpty, "no staged or retired bundle should remain: \(leftovers)") + } + + func testSwapRefusesAReadOnlyDestination() throws { + let folder = tempDirectory.appendingPathComponent("locked", isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let current = folder.appendingPathComponent("LUTzy.app") + try FileManager.default.createDirectory(at: current, withIntermediateDirectories: true) + let incoming = try makeBundle(named: "Incoming.app", marker: "new") + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: folder.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: folder.path) } + + XCTAssertThrowsError(try UpdateInstaller.swap(newApp: incoming, into: current)) { error in + guard case UpdateInstaller.InstallError.destinationNotWritable = error else { + return XCTFail("unexpected error \(error)") + } + } + } +} From 11b01ff6e8c599b852ab5a8c5ddde71f59765f5f Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 13 Sep 2026 18:34:50 -0400 Subject: [PATCH 2/3] Dismiss the sheet and exit outright if terminate is ignored after an update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first end-to-end run swapped the bundle and started the relaunch helper, but the old process stayed up behind its "Installing…" sheet: `NSApp.terminate` sent while a SwiftUI sheet was presented did not end the app. The coordinator now brings the sheet down first, and `relaunch` follows `terminate` with a hard `exit(0)` a second later. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ViSYWttguWwFQm1PEksQ7K --- Sources/LUTzyKit/Models/UpdateInstaller.swift | 8 ++++++++ Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift | 3 +++ 2 files changed, 11 insertions(+) diff --git a/Sources/LUTzyKit/Models/UpdateInstaller.swift b/Sources/LUTzyKit/Models/UpdateInstaller.swift index a4f08aad..4d60664b 100644 --- a/Sources/LUTzyKit/Models/UpdateInstaller.swift +++ b/Sources/LUTzyKit/Models/UpdateInstaller.swift @@ -188,6 +188,13 @@ enum UpdateInstaller { /// Relaunch once this process has gone. `open` is deferred to a shell that polls the PID, so /// there is never a moment with two copies running and Launch Services always starts the new /// bundle rather than reusing this one. + /// + /// **Quitting is not left to `NSApp.terminate` alone** — measured, not assumed. In the first + /// end-to-end run the swap landed, the shell was waiting, and the old process stayed up behind + /// its "Installing…" sheet: `terminate(_:)` sent from under a presented SwiftUI sheet did not + /// end the app. So the caller dismisses the sheet first, and if the process is still here a + /// second after `terminate`, it exits outright. There is nothing to save — preferences are + /// already with `cfprefsd` — and the whole point of this call is to stop existing. @MainActor static func relaunch(_ appURL: URL) { let script = "while /bin/kill -0 \"$1\" 2>/dev/null; do /bin/sleep 0.2; done; /usr/bin/open \"$0\"" @@ -196,6 +203,7 @@ enum UpdateInstaller { process.arguments = ["-c", script, appURL.path, String(ProcessInfo.processInfo.processIdentifier)] try? process.run() NSApp.terminate(nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { exit(0) } } // MARK: - The whole thing diff --git a/Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift b/Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift index 8d089137..7945e642 100644 --- a/Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift +++ b/Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift @@ -162,6 +162,9 @@ final class UpdateCoordinator { let installed = try await install(release) guard !Task.isCancelled else { return } phase = .installing(release) + // Let the sheet come down before asking the app to quit: see `relaunch`. + isSheetPresented = false + try? await Task.sleep(for: .milliseconds(300)) UpdateInstaller.relaunch(installed) } catch { guard !Task.isCancelled else { return } From 692713c8e7f337771fc4192444ca07fa302cc520 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 13 Sep 2026 18:47:09 -0400 Subject: [PATCH 3/3] Log every updater step, and let the release script skip notarization for local tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `log stream --level info --predicate 'subsystem == "com.timvbs.LUTzy"'` now shows a check's outcome and each install stage — download, mount, verify, swap, relaunch — which is the only way to see what the updater did on a machine you are not sitting at. It is also how the end-to-end run was read: accessibility could not see the SwiftUI sheet. `LUTZY_SKIP_NOTARIZE=1 scripts/release-dmg.sh ` builds and signs without the two notarization rounds, for a local update test; never for something shipped. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ViSYWttguWwFQm1PEksQ7K --- Sources/LUTzyKit/Models/UpdateInstaller.swift | 10 +++++- .../ViewModels/UpdateCoordinator.swift | 13 ++++++++ scripts/release-dmg.sh | 33 ++++++++++++------- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/Sources/LUTzyKit/Models/UpdateInstaller.swift b/Sources/LUTzyKit/Models/UpdateInstaller.swift index 4d60664b..ebe44066 100644 --- a/Sources/LUTzyKit/Models/UpdateInstaller.swift +++ b/Sources/LUTzyKit/Models/UpdateInstaller.swift @@ -202,8 +202,12 @@ enum UpdateInstaller { process.executableURL = URL(fileURLWithPath: "/bin/sh") process.arguments = ["-c", script, appURL.path, String(ProcessInfo.processInfo.processIdentifier)] try? process.run() + UpdateCoordinator.log.info("relaunch helper started; terminating") NSApp.terminate(nil) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { exit(0) } + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + UpdateCoordinator.log.info("terminate was ignored; exiting") + exit(0) + } } // MARK: - The whole thing @@ -215,12 +219,16 @@ enum UpdateInstaller { guard let dmgURL = release.diskImageURL else { throw InstallError.noDiskImage } let dmg = try await download(dmgURL) + UpdateCoordinator.log.info("downloaded \(dmg.lastPathComponent)") defer { try? FileManager.default.removeItem(at: dmg.deletingLastPathComponent()) } let mountPoint = try await mount(dmg) + UpdateCoordinator.log.info("mounted at \(mountPoint.path)") do { let newApp = try findApp(in: mountPoint) try verify(newApp, against: identity) + UpdateCoordinator.log.info("signature verified for team \(identity.teamID)") try swap(newApp: newApp, into: currentApp) + UpdateCoordinator.log.info("swapped into \(currentApp.path)") } catch { await detach(mountPoint) throw error diff --git a/Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift b/Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift index 7945e642..dab5e954 100644 --- a/Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift +++ b/Sources/LUTzyKit/ViewModels/UpdateCoordinator.swift @@ -1,6 +1,7 @@ import Foundation import AppKit import Observation +import os /// Owns the update flow: the daily background check, the manual one, the sheet, and the install. /// @@ -43,6 +44,10 @@ final class UpdateCoordinator { static let automaticInterval: TimeInterval = 24 * 60 * 60 + /// `log stream --predicate 'subsystem == "com.timvbs.LUTzy"'` shows every step of a check and + /// an install. This is the only way to see what the updater did on someone else's machine. + nonisolated static let log = Logger(subsystem: "com.timvbs.LUTzy", category: "updates") + init( currentVersion: AppVersion? = AppVersion.current, canInstallInPlace: Bool = UpdateInstaller.canInstallInPlace, @@ -104,6 +109,7 @@ final class UpdateCoordinator { isSheetPresented = true } let fetch = self.fetch + Self.log.info("check started (manual: \(userInitiated), running: \(self.currentVersion?.description ?? "dev"))") task = Task { let result: Result do { result = .success(try await fetch()) } catch { result = .failure(error) } @@ -111,17 +117,21 @@ final class UpdateCoordinator { lastCheck = Date() switch result { case .failure(let error): + Self.log.error("check failed: \(error.localizedDescription)") if userInitiated { phase = .failed(error.localizedDescription) } else { phase = nil } case .success(let release): if isNewer(release) { if userInitiated || release.version != skippedVersion { + Self.log.info("update available: \(release.version.description)") phase = .available(release) isSheetPresented = true } else { + Self.log.info("update \(release.version.description) available but skipped") phase = nil } } else { + Self.log.info("up to date (latest \(release.version.description))") phase = userInitiated ? .upToDate : nil } } @@ -155,12 +165,14 @@ final class UpdateCoordinator { /// message replaces it and nothing has changed on disk. func installAndRelaunch(_ release: Release) { guard canInstallInPlace else { return openReleasePage(release) } + Self.log.info("install started: \(release.version.description)") phase = .downloading(release) let install = self.install task = Task { do { let installed = try await install(release) guard !Task.isCancelled else { return } + Self.log.info("install landed at \(installed.path); relaunching") phase = .installing(release) // Let the sheet come down before asking the app to quit: see `relaunch`. isSheetPresented = false @@ -168,6 +180,7 @@ final class UpdateCoordinator { UpdateInstaller.relaunch(installed) } catch { guard !Task.isCancelled else { return } + Self.log.error("install failed: \(error.localizedDescription)") phase = .failed(error.localizedDescription) } } diff --git a/scripts/release-dmg.sh b/scripts/release-dmg.sh index 1c0fbbe8..15cf2ebe 100755 --- a/scripts/release-dmg.sh +++ b/scripts/release-dmg.sh @@ -16,6 +16,9 @@ # scripts/release-dmg.sh 0.1.1 # # Output lands in build/release/. +# +# LUTZY_SKIP_NOTARIZE=1 builds and signs the app and the DMG but skips both notarization rounds and +# the stapling: for a local test of the update path, never for something you ship. set -euo pipefail VERSION="${1:?Usage: release-dmg.sh , e.g. 0.1.1}" @@ -76,11 +79,15 @@ echo "==> Signing app" codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID_APP" "$APP" codesign --verify --deep --strict --verbose=2 "$APP" -echo "==> Notarizing app (round 1 of 2)" -ditto -c -k --keepParent "$APP" "$OUT/LUTzy.zip" -xcrun notarytool submit "$OUT/LUTzy.zip" --keychain-profile "$NOTARY_PROFILE" --wait -xcrun stapler staple "$APP" -rm "$OUT/LUTzy.zip" +if [ "${LUTZY_SKIP_NOTARIZE:-0}" = "1" ]; then + echo "==> Skipping notarization (LUTZY_SKIP_NOTARIZE=1)" +else + echo "==> Notarizing app (round 1 of 2)" + ditto -c -k --keepParent "$APP" "$OUT/LUTzy.zip" + xcrun notarytool submit "$OUT/LUTzy.zip" --keychain-profile "$NOTARY_PROFILE" --wait + xcrun stapler staple "$APP" + rm "$OUT/LUTzy.zip" +fi echo "==> Building DMG" create-dmg --volname "LUTzy $VERSION" --window-size 540 360 --icon-size 128 \ @@ -88,11 +95,15 @@ create-dmg --volname "LUTzy $VERSION" --window-size 540 360 --icon-size 128 \ "$DMG" "$APP" codesign --force --timestamp --sign "$DEVELOPER_ID_APP" "$DMG" -echo "==> Notarizing DMG (round 2 of 2)" -xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait -xcrun stapler staple "$DMG" +if [ "${LUTZY_SKIP_NOTARIZE:-0}" = "1" ]; then + echo "==> Skipping DMG notarization and the Gatekeeper check (LUTZY_SKIP_NOTARIZE=1)" +else + echo "==> Notarizing DMG (round 2 of 2)" + xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait + xcrun stapler staple "$DMG" -echo "==> Gatekeeper" -spctl -a -vv -t install "$APP" -spctl --assess --type open --context context:primary-signature -vv "$DMG" + echo "==> Gatekeeper" + spctl -a -vv -t install "$APP" + spctl --assess --type open --context context:primary-signature -vv "$DMG" +fi echo "Done: $DMG"