From ce4ae5973ace3632983e61daba7a167d840977f7 Mon Sep 17 00:00:00 2001 From: Tinh Nguyen Date: Sat, 24 Oct 2026 16:18:57 +0700 Subject: [PATCH 1/4] feat(update): install new versions in place with Sparkle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picnest checks for a new version 1 time a day, asks before it installs, and replaces the app in /Applications. An update skips the Open Anyway prompt that a fresh DMG needs, because Sparkle does not quarantine its download. The sessions in Application Support do not change. - project.yml: add the Sparkle 2 package and the disable-library-validation entitlement, set the SU* Info.plist keys, and regenerate Picnest.xcodeproj. - Picnest: start the updater in AppDelegate, except in the test host, and add Picnest > Check for Updates… to the menu. - scripts: add make-appcast.sh. It signs each archive, makes the delta updates, and writes docs/appcast.xml for GitHub Pages. - Tests: the test host starts no updater. The existing quit guard already stops an install while a sort runs. - Docs: README, BUILD.md, and the design spec section 8 and risks 8 and 11. --- BUILD.md | 51 ++++++++++++++++- Picnest.xcodeproj/project.pbxproj | 28 ++++++++++ Picnest/App/AppDelegate.swift | 29 ++++++++-- Picnest/App/PicnestApp.swift | 6 ++ Picnest/App/UpdaterCommands.swift | 42 ++++++++++++++ Picnest/Info.plist | 14 +++++ Picnest/Picnest.entitlements | 12 ++++ PicnestTests/AppDelegateTests.swift | 9 +++ README.md | 10 +++- docs/specs/2026-10-21-macos-app-design.md | 9 ++- project.yml | 24 +++++++- scripts/make-appcast.sh | 68 +++++++++++++++++++++++ 12 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 Picnest/App/UpdaterCommands.swift create mode 100644 Picnest/Picnest.entitlements create mode 100755 scripts/make-appcast.sh diff --git a/BUILD.md b/BUILD.md index 51ac44e..6550a6f 100644 --- a/BUILD.md +++ b/BUILD.md @@ -91,6 +91,46 @@ gh release create v1.0 "build/Picnest 1.0.dmg" --title "Picnest 1.0" --notes "Fi GitHub accepts files up to 2 GB. The DMG with the S2 model is 180 MB. +## Publish an update for the app + +An installed app reads an appcast file to find a new version. Sparkle, a Swift package, does the check, the +download, and the install. The appcast is at . + +### Do these steps 1 time + +1. Get the Sparkle tools. Run `xcodebuild -resolvePackageDependencies -project Picnest.xcodeproj -scheme Picnest`. + The tools are then in the `Sparkle/bin` folder of the package artifacts in Xcode's build folder. +2. Make the signing key: run `generate_keys` from that folder. It saves the private key in the login Keychain. +3. Copy the public key that it prints to `SUPublicEDKey` in `project.yml`. Run `xcodegen generate`. +4. Save the private key to a file outside this Mac: `generate_keys -x `. Keep the file safe. The project + has no Developer ID, so Sparkle cannot replace a lost key. If the key is lost, every user must install the + app again by hand. +5. Turn on GitHub Pages: **Settings > Pages > Source: Deploy from a branch**, branch `main`, folder `/docs`. + +### Do these steps for each release + +1. Write the release notes in `build/updates/Picnest .md`. +2. Set `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `project.yml`, then run `xcodegen generate`. + `CURRENT_PROJECT_VERSION` must be higher than the last release. Sparkle compares it, not `MARKETING_VERSION`. +3. Make the DMG: `scripts/make-dmg.sh`. +4. Make the appcast: `scripts/make-appcast.sh`. It signs each archive and writes `docs/appcast.xml`. +5. Upload the files in `build/updates/` to the release with the tag `updates`: + +```bash +# The first time only. The tag keeps it out of "Latest", so the README link still works. +gh release create updates --title "Update archives" --notes "Sparkle archives. Do not delete." --prerelease +gh release upload updates build/updates/* --clobber +``` + + Do not delete the old DMGs. Sparkle builds the delta updates from them, so a user downloads a few MB and + not the whole 180 MB. +6. Commit `docs/appcast.xml`. Publish the feed last: the feed points at the new files, so the files must be + there first. +7. Make the release for the user with the DMG and the notes. See [Publish a release on GitHub](#publish-a-release-on-github). + +Keep the signing key off a Mac that can publish a release. The key is the only proof that an update comes +from this project, because macOS cannot check an ad-hoc signature. + ## If macOS blocks the app - If you built the app and macOS does not open it, build it again on this Mac. Then copy it to `/Applications` again. @@ -101,13 +141,20 @@ GitHub accepts files up to 2 GB. The DMG with the S2 model is 180 MB. ## Update the app +An installed app updates itself with Sparkle. The user opens **Picnest > Check for Updates…**, or the app +checks 1 time a day and asks. Sparkle replaces the app in `/Applications` and starts it again. The app must +run from a folder the user can write, for example `/Applications`. Sparkle cannot replace an app that runs +from the DMG. + +Sessions stay in `~/Library/Application Support/dev.megabuild.picnest/`. An update does not change them. + +To work on the code instead: + 1. Get the new project files. 2. Run `scripts/fetch-models.sh s2` again. It downloads only missing files. 3. In Xcode, click **Run**. 4. Copy the new `Picnest.app` to `/Applications`. Replace the old app. -Your sessions stay in `~/Library/Application Support/dev.megabuild.picnest/`. An update does not change them. - ## Run the tests The app tests build the app, and the app build needs the model files. Download them first with `scripts/fetch-models.sh s2`. The core tests do not need the model files. diff --git a/Picnest.xcodeproj/project.pbxproj b/Picnest.xcodeproj/project.pbxproj index 1feef39..bbe05c5 100644 --- a/Picnest.xcodeproj/project.pbxproj +++ b/Picnest.xcodeproj/project.pbxproj @@ -12,10 +12,12 @@ 219B8CB8814CA69A739B35A1 /* DuplicatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BFE534E6787F10148ACE5D /* DuplicatesViewModel.swift */; }; 21ABC7AB273C92070EBEBE84 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989FD2A75D20A49C2832027B /* ScanView.swift */; }; 2AA7F661CDC1E5AF2268CA06 /* DuplicatesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F98EFFC146A79BCEADE0D28 /* DuplicatesViewModelTests.swift */; }; + 3326E5A71671FEF75E42A951 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 8CE520B57AC25EFFC86499E5 /* Sparkle */; }; 3701C843F134677B347B048F /* SelectionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EBC5504915550647169DBD7 /* SelectionBar.swift */; }; 40DF2F882721EFAE00B7D586 /* ReviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5040C0826039EBEC8EDE68A /* ReviewViewModel.swift */; }; 4389658CBA389E3956766A27 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC9F57247BA22021E463784C /* AppModel.swift */; }; 45D05A0645B36215E4861488 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F58180671D305DA5D5D04A /* SidebarView.swift */; }; + 46ACE4979DEE77644A8B5CF6 /* UpdaterCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE8771A6B9F7DCA4B852F0DB /* UpdaterCommands.swift */; }; 556D4B5EA4DD80A6FE2C8D07 /* ReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A23A226DCC3C8650C439A15E /* ReviewView.swift */; }; 5E5EBEB4613086D0FD6C9433 /* ReviewViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F671E009DA3C98C5CBA286A /* ReviewViewModelTests.swift */; }; 5EC0A2D59907C15FB0D2604A /* ScanViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61409C47FE261863147401E /* ScanViewModelTests.swift */; }; @@ -58,6 +60,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 03B644AB4BC12B0942135B63 /* Picnest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Picnest.entitlements; sourceTree = ""; }; 05B56D8F8749FFD9F550296D /* SuggestViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestViewModelTests.swift; sourceTree = ""; }; 05F58180671D305DA5D5D04A /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; 0629FE80B349DDD9DC93B874 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -98,6 +101,7 @@ D9FF9F3F20EDFE1CD34F5EAA /* PhotoGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoGridView.swift; sourceTree = ""; }; DE5C538024D2F93601A43E4D /* PicnestApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PicnestApp.swift; sourceTree = ""; }; EC9F57247BA22021E463784C /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + EE8771A6B9F7DCA4B852F0DB /* UpdaterCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterCommands.swift; sourceTree = ""; }; FCD14299CCEEDDCB5B11ACF7 /* Picnest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Picnest.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -107,6 +111,7 @@ buildActionMask = 2147483647; files = ( C0C910198431EECC707F81BE /* PicnestCore in Frameworks */, + 3326E5A71671FEF75E42A951 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -163,6 +168,7 @@ children = ( D594434602636BD2EEEFF69E /* Assets.xcassets */, 1A5B92C09D222C1D48568576 /* Info.plist */, + 03B644AB4BC12B0942135B63 /* Picnest.entitlements */, 900ACE24C78A981403F67AD7 /* App */, A48ADFC30BF399456D9B75AE /* Review */, 0DF81434DB1AA03046151114 /* Scan */, @@ -189,6 +195,7 @@ EC9F57247BA22021E463784C /* AppModel.swift */, DE5C538024D2F93601A43E4D /* PicnestApp.swift */, A7C2B7BBF113F2DC262B2B68 /* RootView.swift */, + EE8771A6B9F7DCA4B852F0DB /* UpdaterCommands.swift */, ); path = App; sourceTree = ""; @@ -252,6 +259,7 @@ name = Picnest; packageProductDependencies = ( 30B03628CE17F8732AD5C557 /* PicnestCore */, + 8CE520B57AC25EFFC86499E5 /* Sparkle */, ); productName = Picnest; productReference = FCD14299CCEEDDCB5B11ACF7 /* Picnest.app */; @@ -304,6 +312,7 @@ mainGroup = 3807D8D8DFC5F208A36BFFB6; minimizedProjectReferenceProxies = 1; packageReferences = ( + AE01D1BFB8A12997D58C6A58 /* XCRemoteSwiftPackageReference "Sparkle" */, A6C687020CB97FEDFE2BCA1B /* XCLocalSwiftPackageReference "PicnestCore" */, ); preferredProjectObjectVersion = 77; @@ -415,6 +424,7 @@ 689A81B87787FDF696696773 /* SortViewModel.swift in Sources */, 73B91E90E6553F5DEAB35D9A /* SuggestSheet.swift in Sources */, 16DC62252371603DC3D9DD17 /* SuggestViewModel.swift in Sources */, + 46ACE4979DEE77644A8B5CF6 /* UpdaterCommands.swift in Sources */, 6F90566DA29FC4725D1FDB85 /* WelcomeView.swift in Sources */, 66083AC05E13E723E2A46072 /* WelcomeViewModel.swift in Sources */, ); @@ -578,6 +588,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Picnest/Picnest.entitlements; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = NO; @@ -605,6 +616,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Picnest/Picnest.entitlements; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = NO; @@ -683,11 +695,27 @@ }; /* End XCLocalSwiftPackageReference section */ +/* Begin XCRemoteSwiftPackageReference section */ + AE01D1BFB8A12997D58C6A58 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.10.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 30B03628CE17F8732AD5C557 /* PicnestCore */ = { isa = XCSwiftPackageProductDependency; productName = PicnestCore; }; + 8CE520B57AC25EFFC86499E5 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = AE01D1BFB8A12997D58C6A58 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = D2F932FB5DFBAF44A7FCC158 /* Project object */; diff --git a/Picnest/App/AppDelegate.swift b/Picnest/App/AppDelegate.swift index 0aafd63..72b0bb0 100644 --- a/Picnest/App/AppDelegate.swift +++ b/Picnest/App/AppDelegate.swift @@ -1,7 +1,12 @@ import AppKit +import Sparkle -/// Owns the 1 app environment and the 1 `AppModel`, and stops a quit while a sort or an undo changes files -/// (final review M4). A quit in the middle of a copy leaves a part file that the journal does not know. +/// Owns the 1 app environment, the 1 `AppModel`, and the 1 Sparkle updater, and stops a quit while a +/// sort or an undo changes files (final review M4). A quit in the middle of a copy leaves a part file +/// that the journal does not know. +/// +/// Sparkle installs a new version at quit, so the same guard already stops an install while a sort runs: +/// the app refuses to quit, and Sparkle keeps the update for the next quit. @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { static let sortRunningMessage = "A sort is running. Stop it first, then quit." @@ -10,17 +15,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let environment: AppEnvironment? let app: AppModel? - /// The app: the bundled model, Application Support, and standard defaults. The test host must not read - /// the real Application Support or standard defaults, so it gets no environment. + /// The updater, or nil in the test host, which must not read the network or the login Keychain. + private(set) var updaterController: SPUStandardUpdaterController? + + /// The updater behind Picnest > Check for Updates… (spec 8). Nil in the test host. + var updater: SPUUpdater? { updaterController?.updater } + + /// The app: the bundled model, Application Support, standard defaults, and the updater. + /// The test host must not read the real Application Support or standard defaults, or use the + /// network, so it gets neither. override convenience init() { let isTestHost = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil - self.init(environment: isTestHost ? nil : AppEnvironment()) + self.init(environment: isTestHost ? nil : AppEnvironment(), startsUpdater: !isTestHost) } - init(environment: AppEnvironment?) { + init(environment: AppEnvironment?, startsUpdater: Bool = false) { self.environment = environment app = environment.map { AppModel(environment: $0) } super.init() + // Checks the appcast on a schedule and installs a new version at quit. + if startsUpdater { + updaterController = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + } } /// True while a sort or an undo changes files. diff --git a/Picnest/App/PicnestApp.swift b/Picnest/App/PicnestApp.swift index d604f65..408b62a 100644 --- a/Picnest/App/PicnestApp.swift +++ b/Picnest/App/PicnestApp.swift @@ -23,6 +23,12 @@ struct PicnestApp: App { .commands { // No File > New Window. CommandGroup(replacing: .newItem) {} + // Picnest > Check for Updates… (spec 8). Nothing in the test host, which has no updater. + CommandGroup(after: .appInfo) { + if let updater = delegate.updater { + CheckForUpdatesView(updater: updater) + } + } ReviewCommands() } } diff --git a/Picnest/App/UpdaterCommands.swift b/Picnest/App/UpdaterCommands.swift new file mode 100644 index 0000000..c6193e3 --- /dev/null +++ b/Picnest/App/UpdaterCommands.swift @@ -0,0 +1,42 @@ +import Foundation +import Observation +import Sparkle +import SwiftUI + +/// Mirrors `SPUUpdater.canCheckForUpdates` for the menu item (spec 8). Sparkle turns checks off while +/// a check runs, and the item must follow. +@MainActor +@Observable +final class UpdaterViewModel { + private(set) var canCheckForUpdates: Bool + + /// The KVO subscription. Held so it stays alive, and released with this object. + @ObservationIgnored private var observation: NSKeyValueObservation? + + init(updater: SPUUpdater) { + canCheckForUpdates = updater.canCheckForUpdates + observation = updater.observe(\.canCheckForUpdates, options: [.new]) { [weak self] updater, _ in + let canCheck = updater.canCheckForUpdates + // Sparkle changes this on the main thread. The hop keeps the code correct if that changes. + Task { @MainActor in self?.canCheckForUpdates = canCheck } + } + } +} + +/// Picnest > **Check for Updates…** (spec 8). Sparkle shows its own window, downloads the new version, +/// and replaces the app in `/Applications`. +struct CheckForUpdatesView: View { + @State private var model: UpdaterViewModel + + private let updater: SPUUpdater + + init(updater: SPUUpdater) { + self.updater = updater + _model = State(initialValue: UpdaterViewModel(updater: updater)) + } + + var body: some View { + Button("Check for Updates…") { updater.checkForUpdates() } + .disabled(!model.canCheckForUpdates) + } +} diff --git a/Picnest/Info.plist b/Picnest/Info.plist index 2bb7367..b497910 100644 --- a/Picnest/Info.plist +++ b/Picnest/Info.plist @@ -20,6 +20,20 @@ $(CURRENT_PROJECT_VERSION) PicnestModelStem $(PICNEST_MODEL_STEM) + SUAllowsAutomaticUpdates + + SUAutomaticallyUpdate + + SUEnableAutomaticChecks + + SUFeedURL + https://megabuild-dev.github.io/picnest/appcast.xml + SUPublicEDKey + GoGJ8EBZg4rw2V4p4cFtSbdq7GKhMWuIOIHILLZSK/s= + SUScheduledCheckInterval + 86400 + SUVerifyUpdateBeforeExtraction + UTExportedTypeDeclarations diff --git a/Picnest/Picnest.entitlements b/Picnest/Picnest.entitlements new file mode 100644 index 0000000..5bd7c81 --- /dev/null +++ b/Picnest/Picnest.entitlements @@ -0,0 +1,12 @@ + + + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/PicnestTests/AppDelegateTests.swift b/PicnestTests/AppDelegateTests.swift index d0822b7..07c7686 100644 --- a/PicnestTests/AppDelegateTests.swift +++ b/PicnestTests/AppDelegateTests.swift @@ -21,4 +21,13 @@ struct AppDelegateTests { #expect(delegate.environment === environment) #expect(delegate.app != nil) } + + /// Spec 8: the updater must not start in the test host, which must not read the network or the Keychain. + /// `quitIsCancelledWhileASortRuns` covers the other half: the quit guard also stops an install at quit. + @Test func theTestHostStartsNoUpdater() { + let delegate = AppDelegate() + #expect(delegate.environment == nil) + #expect(delegate.updaterController == nil) + #expect(delegate.updater == nil) + } } diff --git a/README.md b/README.md index 904a458..2c3df57 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Point it at a folder of photos with names like `z7913505267117_a3f.jpg`. The app - **Copy or move.** 1 batch sorts all photos. Date sub-folders and new file names (date, category, number) are optional. - **Undo.** Undo Last Sort puts the folder back the way it was. - **Duplicates.** Find exact copies and similar photos, keep 1, and move the others to the Trash. -- **Private.** No account, no upload, no network after the install. +- **Private.** No account, no upload. The app touches the network only to check for a new version. ## Install @@ -30,6 +30,14 @@ The app does not have an Apple notarization. The first time, macOS shows "Apple You do this 1 time only. If you prefer, build the app from the source code yourself. See [Build from source](#build-from-source). +## Update + +Picnest checks the network 1 time a day for a new version, and asks you before it installs 1. You can also +check by hand: open **Picnest > Check for Updates…**. + +You do not download a new DMG or move the app again. Picnest replaces the app in `/Applications` and starts +it again. Your sessions are not changed. + ## Use 1. Drop a folder of photos on the window. diff --git a/docs/specs/2026-10-21-macos-app-design.md b/docs/specs/2026-10-21-macos-app-design.md index a19dda4..6cd80ff 100644 --- a/docs/specs/2026-10-21-macos-app-design.md +++ b/docs/specs/2026-10-21-macos-app-design.md @@ -438,12 +438,16 @@ Move mode deletes the source file with `FileManager.removeItem`, not to the Tras ## 8. Permissions and distribution - Do not use App Sandbox. The app is not for the App Store. Without the sandbox, the app can make the default destination next to the source folder. (Builder decision, made during the spec review.) -- Turn on the hardened runtime. +- Turn on the hardened runtime. Xcode turns it off again for an ad-hoc signature ("Disabling hardened runtime with ad-hoc codesigning"), so it is not active in a release build today. The app keeps the `com.apple.security.cs.disable-library-validation` entitlement for a build where the runtime is active, because no Developer ID signs `Sparkle.framework`. - macOS asks the user one time for access to Desktop, Documents, or Downloads. The app must continue correctly after the user approves. - Save a bookmark for each recent folder, so the app can find the folder after a rename or a move. - The project is open source under the MIT license (user decision, 2026-10-23). There is no App Store release. - The project signs with "Sign to Run Locally". A builder does not need a paid Apple Developer account. - `scripts/make-dmg.sh` makes a DMG for the GitHub release page. Without a Developer ID, the DMG has an ad hoc signature, and users click Open Anyway one time. With a Developer ID and a notary profile, the script signs, notarizes, and staples the DMG. +- The app updates itself with the Swift package Sparkle 2 (user decision, 2026-10-24). The user opens **Picnest > Check for Updates…**, or the app checks 1 time a day and asks before the install. Sparkle replaces the app in `/Applications` and starts it again. An update keeps the sessions in Application Support. +- `scripts/make-appcast.sh` signs each update archive and writes `docs/appcast.xml`, the feed that Sparkle reads. GitHub Pages serves the feed, and the GitHub release with the tag `updates` holds the archives. The first install still needs Open Anyway; an update does not, because Sparkle does not quarantine its download. +- Each archive carries an EdDSA signature. The public key is `SUPublicEDKey` in the Info.plist, and the private key is in the login Keychain. Save the private key outside the Mac: with no Developer ID, Sparkle cannot replace a lost key, and each user must then install the app again by hand. +- The app touches the network only for the update check. The README states this, and the "Private" feature line must stay true. - Write a short guide: install Xcode, open the project, select the Mac as the run target, and click Run. The guide also tells how to copy the built app to `/Applications`, and how to make and publish a DMG. - If a free-team signature stops the app from opening, build it again on that Mac. @@ -549,9 +553,10 @@ The implementation plan breaks these phases into tasks. Each phase ends with a w | 5 | Swift rewrite takes longer than planned. | Late app. | Small phases. Phase 0 and phase 1 have no interface. The CLI spec is the reference for all file rules. | | 6 | Wrong dates for files without EXIF. | Photos in the wrong date folder. | Date folders are off by default. Tiles show "no date" for modification-time dates. | | 7 | The model makes the app large. | Download size of 100 MB to 600 MB. | Accept it for version 1. The phase 0 model choice includes size. | -| 8 | No notarization (no paid account). | The app cannot be sent as a file. A free-team signature can stop working. | Build on each Mac with Xcode. Section 8 guide. | +| 8 | No notarization (no paid account). | The first install needs Open Anyway. A free-team signature can stop working. | Build on each Mac with Xcode. Section 8 guide. An update replaces the app in place, and Sparkle does not quarantine its download, so only the first install prompts. | | 9 | Similar-photo groups join photos that the user sees as different. | The user could trash a photo by mistake. | Only a user action trashes photos. Keep is pre-marked, the user checks each group, Clean Up All asks for confirmation, and undo and the Trash can put photos back. | | 10 | Hashing a large folder takes time. | Slow Duplicates view. | Hash only files whose size is not unique. Cache the hashes. | +| 11 | The EdDSA update key is lost, and the project has no Developer ID. | Sparkle cannot replace the key. Every user must install the app again by hand. | Save the private key outside the Mac on the day the project makes it. Section 8 and BUILD.md. | ## 13. New terms diff --git a/project.yml b/project.yml index 13f37f5..9f932f7 100644 --- a/project.yml +++ b/project.yml @@ -20,6 +20,11 @@ settings: packages: PicnestCore: path: PicnestCore + # In-app updates. The public key below is the EdDSA key that signs each update archive + # (`scripts/make-appcast.sh`). Its private key is in the login Keychain, not in git. + Sparkle: + url: https://github.com/sparkle-project/Sparkle + from: 2.10.0 targets: Picnest: @@ -32,6 +37,8 @@ targets: dependencies: - package: PicnestCore product: PicnestCore + - package: Sparkle + product: Sparkle # XcodeGen writes this partial Info.plist. Xcode merges it with the generated keys. info: path: Picnest/Info.plist @@ -40,6 +47,16 @@ targets: # The version comes from MARKETING_VERSION and CURRENT_PROJECT_VERSION below, not from 2 copies. CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION) + # In-app updates (Sparkle). The app checks for a new version, the user approves the install, + # and Sparkle replaces the app in /Applications. The feed is the appcast on GitHub Pages. + # The public key matches the EdDSA private key in the login Keychain. + SUFeedURL: https://megabuild-dev.github.io/picnest/appcast.xml + SUPublicEDKey: "GoGJ8EBZg4rw2V4p4cFtSbdq7GKhMWuIOIHILLZSK/s=" + SUEnableAutomaticChecks: true + SUScheduledCheckInterval: 86400 + SUAllowsAutomaticUpdates: true + SUAutomaticallyUpdate: false + SUVerifyUpdateBeforeExtraction: true # The private type of a photo drag from the grid to a sidebar category (MediaIDs). UTExportedTypeDeclarations: - UTTypeIdentifier: dev.megabuild.picnest.media-ids @@ -57,9 +74,14 @@ targets: INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Tinh Nguyen. MIT License." ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor - # Hardened runtime on, App Sandbox off (spec 8). No entitlements file. + # Hardened runtime on, App Sandbox off (spec 8). Xcode turns the hardened runtime off again + # for an ad-hoc signature, and it says so: "Disabling hardened runtime with ad-hoc codesigning". ENABLE_HARDENED_RUNTIME: YES ENABLE_APP_SANDBOX: NO + # Library validation is part of the hardened runtime: it loads only a framework that the same + # Developer ID signed. Sparkle.framework has no Developer ID here (spec 8), so this entitlement + # keeps the app able to load it whenever the hardened runtime is active. + CODE_SIGN_ENTITLEMENTS: Picnest/Picnest.entitlements # The model scripts read and write folders, which the script sandbox does not allow. ENABLE_USER_SCRIPT_SANDBOXING: NO # The 1 place that sets the model size (a MobileCLIPSize.modelStem). The scripts below copy diff --git a/scripts/make-appcast.sh b/scripts/make-appcast.sh new file mode 100755 index 0000000..27045d7 --- /dev/null +++ b/scripts/make-appcast.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Build docs/appcast.xml, the update feed that Sparkle reads, from the update archives in build/updates/. +# +# Usage: scripts/make-appcast.sh +# +# Put every new DMG in build/updates/. This script copies the newest one from build/ for you. +# Keep the old DMGs in that folder: Sparkle builds the delta files from them, so an update stays small. +# After this script runs, upload every file in build/updates/ to the GitHub release that +# DOWNLOAD_URL_PREFIX points at. See BUILD.md. +# +# The script signs each item with the EdDSA key in the login Keychain, so it may ask for the Keychain password. +# +# Settings (environment variables): +# DOWNLOAD_URL_PREFIX Where the archives are hosted. Default: the "updates" release of this repo. +# SPARKLE_BIN The folder with generate_appcast. Default: the package folder in Xcode's build folder. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +UPDATES_DIR="$ROOT_DIR/build/updates" +APPCAST="$ROOT_DIR/docs/appcast.xml" +PROJECT_URL="https://github.com/megabuild-dev/picnest" +DOWNLOAD_URL_PREFIX="${DOWNLOAD_URL_PREFIX:-https://github.com/megabuild-dev/picnest/releases/download/updates/}" +SPARKLE_BIN="${SPARKLE_BIN:-}" + +if [ -z "$SPARKLE_BIN" ]; then + # Xcode writes the package artifacts next to its build folder. + BUILD_DIR="$(xcodebuild -project "$ROOT_DIR/Picnest.xcodeproj" -scheme Picnest -showBuildSettings 2>/dev/null \ + | awk -F ' = ' '/ BUILD_DIR = /{print $2; exit}')" + SPARKLE_BIN="$BUILD_DIR/../../SourcePackages/artifacts/sparkle/Sparkle/bin" +fi + +GENERATE="$SPARKLE_BIN/generate_appcast" +if [ ! -x "$GENERATE" ]; then + echo "error: No generate_appcast at $GENERATE." >&2 + echo "Run this first, or set SPARKLE_BIN to the bin folder of a Sparkle release:" >&2 + echo " xcodebuild -resolvePackageDependencies -project Picnest.xcodeproj -scheme Picnest" >&2 + exit 1 +fi + +mkdir -p "$UPDATES_DIR" + +# Keep the published feed, so a fresh clone with an empty build folder still lists the old versions. +if [ ! -f "$UPDATES_DIR/appcast.xml" ] && [ -f "$APPCAST" ]; then + echo "seed appcast.xml from docs/" + cp "$APPCAST" "$UPDATES_DIR/appcast.xml" +fi + +# The DMG that scripts/make-dmg.sh wrote in build/, if the archive folder does not hold it yet. +for dmg in "$ROOT_DIR"/build/*.dmg; do + [ -e "$dmg" ] || continue + name="$(basename "$dmg")" + if [ ! -e "$UPDATES_DIR/$name" ]; then + echo "add $name" + cp "$dmg" "$UPDATES_DIR/$name" + fi +done + +# A .md file beside an archive becomes the release notes of that item. +echo "sign the archives and generate the appcast" +"$GENERATE" --download-url-prefix "$DOWNLOAD_URL_PREFIX" --link "$PROJECT_URL" "$UPDATES_DIR" + +echo "copy appcast.xml to docs/" +cp "$UPDATES_DIR/appcast.xml" "$APPCAST" + +echo "Appcast: $APPCAST" +echo "Upload every file below to $DOWNLOAD_URL_PREFIX" +ls -1 "$UPDATES_DIR" From b47ebb7ac2ab3126ee2bc5f18f5542f18ca758b4 Mon Sep 17 00:00:00 2001 From: Tinh Nguyen Date: Wed, 28 Oct 2026 17:41:56 +0700 Subject: [PATCH 2/4] fix(update): address PR review on Sparkle integration - Keep library validation on in Release; only Debug disables it. - Read canCheckForUpdates from the KVO change, not off the main actor. - Put the updater behind an AppUpdater protocol and test the positive path with a fake. - Commit Package.resolved to pin Sparkle 2.10.0. - Set SUAllowsAutomaticUpdates to false so every install needs approval. --- Picnest.xcodeproj/project.pbxproj | 8 ++- .../xcshareddata/swiftpm/Package.resolved | 15 ++++ Picnest/App/AppDelegate.swift | 22 +++--- Picnest/App/AppUpdater.swift | 38 ++++++++++ Picnest/App/UpdaterCommands.swift | 31 +++++---- Picnest/Info.plist | 2 +- Picnest/Picnest.Debug.entitlements | 11 +++ Picnest/Picnest.entitlements | 8 +-- PicnestTests/AppDelegateTests.swift | 69 ++++++++++++++++++- docs/specs/2026-10-21-macos-app-design.md | 2 +- project.yml | 10 +-- 11 files changed, 174 insertions(+), 42 deletions(-) create mode 100644 Picnest.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Picnest/App/AppUpdater.swift create mode 100644 Picnest/Picnest.Debug.entitlements diff --git a/Picnest.xcodeproj/project.pbxproj b/Picnest.xcodeproj/project.pbxproj index bbe05c5..f10997b 100644 --- a/Picnest.xcodeproj/project.pbxproj +++ b/Picnest.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ C0D6276346C37C0D994D1657 /* ReviewCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C28C2A5F8FA8B521A245CE4A /* ReviewCommands.swift */; }; C85B93CBA4C456D7B1094A35 /* AppModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8720DD050E7DEC881184656B /* AppModelTests.swift */; }; C9A3BD8C73107AE12AB64A4C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0629FE80B349DDD9DC93B874 /* AppDelegate.swift */; }; + CD3F3888933C31BF5904B13F /* AppUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA197D7AC0310B6062CC03C6 /* AppUpdater.swift */; }; D107C155337D4DE5FBBF567E /* SortViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0977699F69AB7452B6F56650 /* SortViewModelTests.swift */; }; D54CFD0B3713BDC63BE47DEE /* SortSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A0384D25A40836DB152C65 /* SortSheet.swift */; }; D5E02ACC4D238BF7ACF5EEBE /* SortMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70654CE25C0D4A3AEB26F66C /* SortMessages.swift */; }; @@ -74,6 +75,7 @@ 2CBD09A25D73165A758EBE26 /* SuggestViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestViewModel.swift; sourceTree = ""; }; 2E5E4C53FC5AF6C8686C67A5 /* SuggestSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestSheet.swift; sourceTree = ""; }; 35166FEF0689ABA60577363E /* PhotoTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoTile.swift; sourceTree = ""; }; + 3A4F291B7B0D7F2F894B589E /* Picnest.Debug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Picnest.Debug.entitlements; sourceTree = ""; }; 501266281830058D97AF399D /* DuplicatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuplicatesView.swift; sourceTree = ""; }; 57D74193AE5CCDEA74C74B46 /* Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Support.swift; sourceTree = ""; }; 5F311E0938B11ACD22E634D4 /* AppDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateTests.swift; sourceTree = ""; }; @@ -92,6 +94,7 @@ A23A226DCC3C8650C439A15E /* ReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewView.swift; sourceTree = ""; }; A5F10389CA705A630CD7876B /* ReviewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStyle.swift; sourceTree = ""; }; A7C2B7BBF113F2DC262B2B68 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + BA197D7AC0310B6062CC03C6 /* AppUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdater.swift; sourceTree = ""; }; BC73950B912E76D04662E063 /* PicnestCore */ = {isa = PBXFileReference; lastKnownFileType = folder; name = PicnestCore; path = PicnestCore; sourceTree = SOURCE_ROOT; }; C28C2A5F8FA8B521A245CE4A /* ReviewCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCommands.swift; sourceTree = ""; }; C5040C0826039EBEC8EDE68A /* ReviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewViewModel.swift; sourceTree = ""; }; @@ -168,6 +171,7 @@ children = ( D594434602636BD2EEEFF69E /* Assets.xcassets */, 1A5B92C09D222C1D48568576 /* Info.plist */, + 3A4F291B7B0D7F2F894B589E /* Picnest.Debug.entitlements */, 03B644AB4BC12B0942135B63 /* Picnest.entitlements */, 900ACE24C78A981403F67AD7 /* App */, A48ADFC30BF399456D9B75AE /* Review */, @@ -193,6 +197,7 @@ 0629FE80B349DDD9DC93B874 /* AppDelegate.swift */, 08D2B7C9420C231204BC494F /* AppEnvironment.swift */, EC9F57247BA22021E463784C /* AppModel.swift */, + BA197D7AC0310B6062CC03C6 /* AppUpdater.swift */, DE5C538024D2F93601A43E4D /* PicnestApp.swift */, A7C2B7BBF113F2DC262B2B68 /* RootView.swift */, EE8771A6B9F7DCA4B852F0DB /* UpdaterCommands.swift */, @@ -403,6 +408,7 @@ C9A3BD8C73107AE12AB64A4C /* AppDelegate.swift in Sources */, 78EC7D52544D30719D020C2A /* AppEnvironment.swift in Sources */, 4389658CBA389E3956766A27 /* AppModel.swift in Sources */, + CD3F3888933C31BF5904B13F /* AppUpdater.swift in Sources */, 8CF4B586E69522C94C2E264D /* DuplicatesView.swift in Sources */, 219B8CB8814CA69A739B35A1 /* DuplicatesViewModel.swift in Sources */, F5DD9E7C033C8EBF266AB26D /* NotSureView.swift in Sources */, @@ -588,7 +594,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Picnest/Picnest.entitlements; + CODE_SIGN_ENTITLEMENTS = Picnest/Picnest.Debug.entitlements; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = NO; diff --git a/Picnest.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Picnest.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..0258c37 --- /dev/null +++ b/Picnest.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "a186440a96034581d39cab1a270503640d75367a709bc60dac2b644e9bd4d522", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "eef1a539a373c1f1a320624b1130fc5de7b2e100", + "version" : "2.10.0" + } + } + ], + "version" : 3 +} diff --git a/Picnest/App/AppDelegate.swift b/Picnest/App/AppDelegate.swift index 72b0bb0..01bc957 100644 --- a/Picnest/App/AppDelegate.swift +++ b/Picnest/App/AppDelegate.swift @@ -1,5 +1,4 @@ import AppKit -import Sparkle /// Owns the 1 app environment, the 1 `AppModel`, and the 1 Sparkle updater, and stops a quit while a /// sort or an undo changes files (final review M4). A quit in the middle of a copy leaves a part file @@ -15,29 +14,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let environment: AppEnvironment? let app: AppModel? - /// The updater, or nil in the test host, which must not read the network or the login Keychain. - private(set) var updaterController: SPUStandardUpdaterController? - - /// The updater behind Picnest > Check for Updates… (spec 8). Nil in the test host. - var updater: SPUUpdater? { updaterController?.updater } + /// The updater behind Picnest > Check for Updates… (spec 8). Nil in the test host, which must not read + /// the network or the login Keychain. + let updater: (any AppUpdater)? /// The app: the bundled model, Application Support, standard defaults, and the updater. /// The test host must not read the real Application Support or standard defaults, or use the /// network, so it gets neither. override convenience init() { let isTestHost = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil - self.init(environment: isTestHost ? nil : AppEnvironment(), startsUpdater: !isTestHost) + self.init( + environment: isTestHost ? nil : AppEnvironment(), + makeUpdater: isTestHost ? nil : { SparkleAppUpdater() }) } - init(environment: AppEnvironment?, startsUpdater: Bool = false) { + init(environment: AppEnvironment?, makeUpdater: (() -> any AppUpdater)? = nil) { self.environment = environment app = environment.map { AppModel(environment: $0) } - super.init() // Checks the appcast on a schedule and installs a new version at quit. - if startsUpdater { - updaterController = SPUStandardUpdaterController( - startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) - } + updater = makeUpdater?() + super.init() } /// True while a sort or an undo changes files. diff --git a/Picnest/App/AppUpdater.swift b/Picnest/App/AppUpdater.swift new file mode 100644 index 0000000..b50eea4 --- /dev/null +++ b/Picnest/App/AppUpdater.swift @@ -0,0 +1,38 @@ +import Foundation +import Sparkle + +/// What the app needs from an updater (spec 8). The tests replace it with a fake, so they use no network +/// and no Keychain. +@MainActor +protocol AppUpdater: AnyObject { + var canCheckForUpdates: Bool { get } + + /// Starts a check that the user asked for. The updater shows its own window. + func checkForUpdates() + + /// Calls `onChange` on the main actor each time `canCheckForUpdates` changes. The subscription lasts + /// as long as the returned token. + func observeCanCheckForUpdates(_ onChange: @escaping @MainActor (Bool) -> Void) -> AnyObject +} + +/// The Sparkle updater. It starts at once: it checks the appcast on a schedule and installs a new version +/// at quit. +@MainActor +final class SparkleAppUpdater: AppUpdater { + private let controller = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + + var canCheckForUpdates: Bool { controller.updater.canCheckForUpdates } + + func checkForUpdates() { + controller.updater.checkForUpdates() + } + + func observeCanCheckForUpdates(_ onChange: @escaping @MainActor (Bool) -> Void) -> AnyObject { + controller.updater.observe(\.canCheckForUpdates, options: [.new]) { _, change in + // The change carries the new value, so no code reads the main-actor property off the main actor. + let canCheck = change.newValue ?? false + Task { @MainActor in onChange(canCheck) } + } + } +} diff --git a/Picnest/App/UpdaterCommands.swift b/Picnest/App/UpdaterCommands.swift index c6193e3..fc900d3 100644 --- a/Picnest/App/UpdaterCommands.swift +++ b/Picnest/App/UpdaterCommands.swift @@ -1,26 +1,30 @@ -import Foundation import Observation -import Sparkle import SwiftUI -/// Mirrors `SPUUpdater.canCheckForUpdates` for the menu item (spec 8). Sparkle turns checks off while +/// Mirrors `AppUpdater.canCheckForUpdates` for the menu item (spec 8). Sparkle turns checks off while /// a check runs, and the item must follow. @MainActor @Observable final class UpdaterViewModel { private(set) var canCheckForUpdates: Bool - /// The KVO subscription. Held so it stays alive, and released with this object. - @ObservationIgnored private var observation: NSKeyValueObservation? + @ObservationIgnored private let updater: any AppUpdater - init(updater: SPUUpdater) { + /// The subscription. Held so it stays alive, and released with this object. + @ObservationIgnored private var observation: AnyObject? + + init(updater: any AppUpdater) { + self.updater = updater canCheckForUpdates = updater.canCheckForUpdates - observation = updater.observe(\.canCheckForUpdates, options: [.new]) { [weak self] updater, _ in - let canCheck = updater.canCheckForUpdates - // Sparkle changes this on the main thread. The hop keeps the code correct if that changes. - Task { @MainActor in self?.canCheckForUpdates = canCheck } + observation = updater.observeCanCheckForUpdates { [weak self] canCheck in + self?.canCheckForUpdates = canCheck } } + + /// The user chose **Check for Updates…**. + func checkForUpdates() { + updater.checkForUpdates() + } } /// Picnest > **Check for Updates…** (spec 8). Sparkle shows its own window, downloads the new version, @@ -28,15 +32,12 @@ final class UpdaterViewModel { struct CheckForUpdatesView: View { @State private var model: UpdaterViewModel - private let updater: SPUUpdater - - init(updater: SPUUpdater) { - self.updater = updater + init(updater: any AppUpdater) { _model = State(initialValue: UpdaterViewModel(updater: updater)) } var body: some View { - Button("Check for Updates…") { updater.checkForUpdates() } + Button("Check for Updates…") { model.checkForUpdates() } .disabled(!model.canCheckForUpdates) } } diff --git a/Picnest/Info.plist b/Picnest/Info.plist index b497910..3e34fc7 100644 --- a/Picnest/Info.plist +++ b/Picnest/Info.plist @@ -21,7 +21,7 @@ PicnestModelStem $(PICNEST_MODEL_STEM) SUAllowsAutomaticUpdates - + SUAutomaticallyUpdate SUEnableAutomaticChecks diff --git a/Picnest/Picnest.Debug.entitlements b/Picnest/Picnest.Debug.entitlements new file mode 100644 index 0000000..3e2f053 --- /dev/null +++ b/Picnest/Picnest.Debug.entitlements @@ -0,0 +1,11 @@ + + + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/Picnest/Picnest.entitlements b/Picnest/Picnest.entitlements index 5bd7c81..24e275f 100644 --- a/Picnest/Picnest.entitlements +++ b/Picnest/Picnest.entitlements @@ -2,11 +2,7 @@ - - com.apple.security.cs.disable-library-validation - + diff --git a/PicnestTests/AppDelegateTests.swift b/PicnestTests/AppDelegateTests.swift index 07c7686..ac046ef 100644 --- a/PicnestTests/AppDelegateTests.swift +++ b/PicnestTests/AppDelegateTests.swift @@ -27,7 +27,74 @@ struct AppDelegateTests { @Test func theTestHostStartsNoUpdater() { let delegate = AppDelegate() #expect(delegate.environment == nil) - #expect(delegate.updaterController == nil) #expect(delegate.updater == nil) } + + @Test func delegateMakesTheUpdaterOnce() { + let fake = FakeUpdater() + var made = 0 + let delegate = AppDelegate(environment: nil, makeUpdater: { + made += 1 + return fake + }) + #expect(made == 1) + #expect(delegate.updater === fake) + } + + @Test func menuItemFollowsCanCheckForUpdates() { + let fake = FakeUpdater(canCheckForUpdates: true) + let model = UpdaterViewModel(updater: fake) + #expect(model.canCheckForUpdates) + fake.setCanCheck(false) + #expect(model.canCheckForUpdates == false) + fake.setCanCheck(true) + #expect(model.canCheckForUpdates) + } + + @Test func menuItemStartsACheck() { + let fake = FakeUpdater() + let model = UpdaterViewModel(updater: fake) + model.checkForUpdates() + #expect(fake.checkCount == 1) + } + + @Test func viewModelReleasesTheSubscription() { + let fake = FakeUpdater() + var model: UpdaterViewModel? = UpdaterViewModel(updater: fake) + #expect(fake.hasObserver) + model = nil + #expect(fake.hasObserver == false) + _ = model + } +} + +/// An updater that uses no network and no Keychain. +@MainActor +final class FakeUpdater: AppUpdater { + private(set) var canCheckForUpdates: Bool + private(set) var checkCount = 0 + private var handler: (@MainActor (Bool) -> Void)? + private weak var token: Token? + + var hasObserver: Bool { token != nil } + + init(canCheckForUpdates: Bool = true) { + self.canCheckForUpdates = canCheckForUpdates + } + + func checkForUpdates() { checkCount += 1 } + + func observeCanCheckForUpdates(_ onChange: @escaping @MainActor (Bool) -> Void) -> AnyObject { + handler = onChange + let token = Token() + self.token = token + return token + } + + func setCanCheck(_ value: Bool) { + canCheckForUpdates = value + handler?(value) + } + + final class Token {} } diff --git a/docs/specs/2026-10-21-macos-app-design.md b/docs/specs/2026-10-21-macos-app-design.md index 6cd80ff..5502718 100644 --- a/docs/specs/2026-10-21-macos-app-design.md +++ b/docs/specs/2026-10-21-macos-app-design.md @@ -438,7 +438,7 @@ Move mode deletes the source file with `FileManager.removeItem`, not to the Tras ## 8. Permissions and distribution - Do not use App Sandbox. The app is not for the App Store. Without the sandbox, the app can make the default destination next to the source folder. (Builder decision, made during the spec review.) -- Turn on the hardened runtime. Xcode turns it off again for an ad-hoc signature ("Disabling hardened runtime with ad-hoc codesigning"), so it is not active in a release build today. The app keeps the `com.apple.security.cs.disable-library-validation` entitlement for a build where the runtime is active, because no Developer ID signs `Sparkle.framework`. +- Turn on the hardened runtime. Xcode turns it off again for an ad-hoc signature ("Disabling hardened runtime with ad-hoc codesigning"), so it is not active in a release build today. Only the Debug build has the `com.apple.security.cs.disable-library-validation` entitlement (`Picnest.Debug.entitlements`), so an ad-hoc build can load `Sparkle.framework`. The Release build keeps library validation on. A Developer ID build signs `Sparkle.framework` with the identity of the app. - macOS asks the user one time for access to Desktop, Documents, or Downloads. The app must continue correctly after the user approves. - Save a bookmark for each recent folder, so the app can find the folder after a rename or a move. - The project is open source under the MIT license (user decision, 2026-10-23). There is no App Store release. diff --git a/project.yml b/project.yml index 9f932f7..56df21d 100644 --- a/project.yml +++ b/project.yml @@ -54,7 +54,7 @@ targets: SUPublicEDKey: "GoGJ8EBZg4rw2V4p4cFtSbdq7GKhMWuIOIHILLZSK/s=" SUEnableAutomaticChecks: true SUScheduledCheckInterval: 86400 - SUAllowsAutomaticUpdates: true + SUAllowsAutomaticUpdates: false SUAutomaticallyUpdate: false SUVerifyUpdateBeforeExtraction: true # The private type of a photo drag from the grid to a sidebar category (MediaIDs). @@ -78,15 +78,17 @@ targets: # for an ad-hoc signature, and it says so: "Disabling hardened runtime with ad-hoc codesigning". ENABLE_HARDENED_RUNTIME: YES ENABLE_APP_SANDBOX: NO - # Library validation is part of the hardened runtime: it loads only a framework that the same - # Developer ID signed. Sparkle.framework has no Developer ID here (spec 8), so this entitlement - # keeps the app able to load it whenever the hardened runtime is active. + # Library validation stays on in Release: a Developer ID build signs Sparkle.framework with the + # app's identity. Only the ad-hoc Debug build turns it off, to load Sparkle (spec 8). CODE_SIGN_ENTITLEMENTS: Picnest/Picnest.entitlements # The model scripts read and write folders, which the script sandbox does not allow. ENABLE_USER_SCRIPT_SANDBOXING: NO # The 1 place that sets the model size (a MobileCLIPSize.modelStem). The scripts below copy # its 2 packages, and AppEnvironment reads it from the Info.plist key PicnestModelStem. PICNEST_MODEL_STEM: mobileclip_s2 + configs: + Debug: + CODE_SIGN_ENTITLEMENTS: Picnest/Picnest.Debug.entitlements preBuildScripts: - name: Check models basedOnDependencyAnalysis: false From 9bda61ee41e40943b5cc3ca98d380a1b71981002 Mon Sep 17 00:00:00 2001 From: Tinh Nguyen Date: Wed, 28 Oct 2026 17:46:18 +0700 Subject: [PATCH 3/4] feat(update): add smoke check for the published appcast scripts/check-appcast.sh fails when the appcast or an enclosure URL returns an error. BUILD.md runs it after the feed is published and adds the end-to-end update test. --- BUILD.md | 6 ++++- scripts/check-appcast.sh | 53 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100755 scripts/check-appcast.sh diff --git a/BUILD.md b/BUILD.md index 6550a6f..392f561 100644 --- a/BUILD.md +++ b/BUILD.md @@ -126,7 +126,11 @@ gh release upload updates build/updates/* --clobber not the whole 180 MB. 6. Commit `docs/appcast.xml`. Publish the feed last: the feed points at the new files, so the files must be there first. -7. Make the release for the user with the DMG and the notes. See [Publish a release on GitHub](#publish-a-release-on-github). +7. After Pages deploys the feed (about 1 minute), run `scripts/check-appcast.sh`. It fails when the appcast or + any archive or delta URL in it returns an error. Do not tell users about the release until it passes. +8. Test 1 update from an older build: install the previous release in `/Applications`, open **Picnest > Check + for Updates…**, and make sure the new version installs and starts with the sessions intact. +9. Make the release for the user with the DMG and the notes. See [Publish a release on GitHub](#publish-a-release-on-github). Keep the signing key off a Mac that can publish a release. The key is the only proof that an update comes from this project, because macOS cannot check an ad-hoc signature. diff --git a/scripts/check-appcast.sh b/scripts/check-appcast.sh new file mode 100755 index 0000000..0225aab --- /dev/null +++ b/scripts/check-appcast.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Fail when the published appcast, or a file that it points at, is not available. +# +# Usage: scripts/check-appcast.sh [appcast-url] +# +# Run this after you upload the archives and publish docs/appcast.xml (see BUILD.md). It downloads the feed, +# reads every enclosure URL (the full archives and the delta files), and sends 1 HEAD request to each. +# The exit code is 0 only when the feed and all enclosures answer with a 2xx status after redirects. +# +# Settings: +# APPCAST_URL The feed to check. Default: the feed in SUFeedURL of project.yml. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +URL="${1:-${APPCAST_URL:-}}" +if [ -z "$URL" ]; then + URL="$(awk '/SUFeedURL:/{print $2; exit}' "$ROOT_DIR/project.yml")" +fi +[ -n "$URL" ] || { echo "error: No appcast URL." >&2; exit 2; } + +feed="$(mktemp)" +trap 'rm -f "$feed"' EXIT + +echo "feed $URL" +if ! curl -fsSL --retry 2 -o "$feed" "$URL"; then + echo "FAIL the appcast is not available" >&2 + exit 1 +fi + +# Every enclosure has a url="..." attribute. +enclosures="$(grep -o ']*' "$feed" | sed -n 's/.*[[:space:]]url="\([^"]*\)".*/\1/p')" +if [ -z "$enclosures" ]; then + echo "FAIL the appcast has no enclosure" >&2 + exit 1 +fi + +failed=0 +while IFS= read -r enclosure; do + if curl -fsSIL --retry 2 -o /dev/null "$enclosure"; then + echo "ok $enclosure" + else + echo "FAIL $enclosure" >&2 + failed=$((failed + 1)) + fi +done <<< "$enclosures" + +if [ "$failed" -gt 0 ]; then + echo "$failed enclosure URL(s) are not available." >&2 + exit 1 +fi +echo "The appcast and all enclosures are available." From 37e8d62ff9d2516aafed10852002ac76d84a32c4 Mon Sep 17 00:00:00 2001 From: Tinh Nguyen Date: Wed, 28 Oct 2026 18:38:34 +0700 Subject: [PATCH 4/4] test(library): wait for the running sort instead of timing it startNewSortWaitsUntilTheSortServiceIsIdle slept 50 ms and asserted a wait of at least 150 ms, which failed on a slow CI runner (52 ms). Poll until the blocking sort runs, then assert the sort is idle when startNewSort returns. --- .../Tests/PicnestCoreTests/LibraryServiceTests.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/PicnestCore/Tests/PicnestCoreTests/LibraryServiceTests.swift b/PicnestCore/Tests/PicnestCoreTests/LibraryServiceTests.swift index c9d3d76..efb3537 100644 --- a/PicnestCore/Tests/PicnestCoreTests/LibraryServiceTests.swift +++ b/PicnestCore/Tests/PicnestCoreTests/LibraryServiceTests.swift @@ -309,10 +309,12 @@ final class LibraryServiceTests { let blocker = sortService.run(plan, mode: .copy, journal: library.journal(for: other), stop: { Thread.sleep(forTimeInterval: 0.3); return true }) let drain = Task { for try await _ in blocker {} } - try await Task.sleep(for: .milliseconds(50)) - let start = ContinuousClock.now + // Wait until the blocker holds the service. A fixed sleep is not enough on a slow CI runner. + for _ in 0..<200 where !library.isSortRunning { try await Task.sleep(for: .milliseconds(10)) } + #expect(library.isSortRunning) try await library.startNewSort(source) - #expect(ContinuousClock.now - start >= .milliseconds(150)) + // startNewSort returns only after the other sort ends. + #expect(!library.isSortRunning) #expect(try sessions.load(source: source) == nil) _ = await drain.result }