Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions BUILD.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,50 @@ 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 <https://megabuild-dev.github.io/picnest/appcast.xml>.

### 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 <file>`. 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 <version>.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. 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.

## 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.
Expand All @@ -101,13 +145,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.
Expand Down
34 changes: 34 additions & 0 deletions Picnest.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -37,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 */; };
Expand All @@ -58,6 +61,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
03B644AB4BC12B0942135B63 /* Picnest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Picnest.entitlements; sourceTree = "<group>"; };
05B56D8F8749FFD9F550296D /* SuggestViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestViewModelTests.swift; sourceTree = "<group>"; };
05F58180671D305DA5D5D04A /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
0629FE80B349DDD9DC93B874 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
Expand All @@ -71,6 +75,7 @@
2CBD09A25D73165A758EBE26 /* SuggestViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestViewModel.swift; sourceTree = "<group>"; };
2E5E4C53FC5AF6C8686C67A5 /* SuggestSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestSheet.swift; sourceTree = "<group>"; };
35166FEF0689ABA60577363E /* PhotoTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoTile.swift; sourceTree = "<group>"; };
3A4F291B7B0D7F2F894B589E /* Picnest.Debug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Picnest.Debug.entitlements; sourceTree = "<group>"; };
501266281830058D97AF399D /* DuplicatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuplicatesView.swift; sourceTree = "<group>"; };
57D74193AE5CCDEA74C74B46 /* Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Support.swift; sourceTree = "<group>"; };
5F311E0938B11ACD22E634D4 /* AppDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateTests.swift; sourceTree = "<group>"; };
Expand All @@ -89,6 +94,7 @@
A23A226DCC3C8650C439A15E /* ReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewView.swift; sourceTree = "<group>"; };
A5F10389CA705A630CD7876B /* ReviewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStyle.swift; sourceTree = "<group>"; };
A7C2B7BBF113F2DC262B2B68 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
BA197D7AC0310B6062CC03C6 /* AppUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdater.swift; sourceTree = "<group>"; };
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 = "<group>"; };
C5040C0826039EBEC8EDE68A /* ReviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewViewModel.swift; sourceTree = "<group>"; };
Expand All @@ -98,6 +104,7 @@
D9FF9F3F20EDFE1CD34F5EAA /* PhotoGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoGridView.swift; sourceTree = "<group>"; };
DE5C538024D2F93601A43E4D /* PicnestApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PicnestApp.swift; sourceTree = "<group>"; };
EC9F57247BA22021E463784C /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
EE8771A6B9F7DCA4B852F0DB /* UpdaterCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterCommands.swift; sourceTree = "<group>"; };
FCD14299CCEEDDCB5B11ACF7 /* Picnest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Picnest.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

Expand All @@ -107,6 +114,7 @@
buildActionMask = 2147483647;
files = (
C0C910198431EECC707F81BE /* PicnestCore in Frameworks */,
3326E5A71671FEF75E42A951 /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -163,6 +171,8 @@
children = (
D594434602636BD2EEEFF69E /* Assets.xcassets */,
1A5B92C09D222C1D48568576 /* Info.plist */,
3A4F291B7B0D7F2F894B589E /* Picnest.Debug.entitlements */,
03B644AB4BC12B0942135B63 /* Picnest.entitlements */,
900ACE24C78A981403F67AD7 /* App */,
A48ADFC30BF399456D9B75AE /* Review */,
0DF81434DB1AA03046151114 /* Scan */,
Expand All @@ -187,8 +197,10 @@
0629FE80B349DDD9DC93B874 /* AppDelegate.swift */,
08D2B7C9420C231204BC494F /* AppEnvironment.swift */,
EC9F57247BA22021E463784C /* AppModel.swift */,
BA197D7AC0310B6062CC03C6 /* AppUpdater.swift */,
DE5C538024D2F93601A43E4D /* PicnestApp.swift */,
A7C2B7BBF113F2DC262B2B68 /* RootView.swift */,
EE8771A6B9F7DCA4B852F0DB /* UpdaterCommands.swift */,
);
path = App;
sourceTree = "<group>";
Expand Down Expand Up @@ -252,6 +264,7 @@
name = Picnest;
packageProductDependencies = (
30B03628CE17F8732AD5C557 /* PicnestCore */,
8CE520B57AC25EFFC86499E5 /* Sparkle */,
);
productName = Picnest;
productReference = FCD14299CCEEDDCB5B11ACF7 /* Picnest.app */;
Expand Down Expand Up @@ -304,6 +317,7 @@
mainGroup = 3807D8D8DFC5F208A36BFFB6;
minimizedProjectReferenceProxies = 1;
packageReferences = (
AE01D1BFB8A12997D58C6A58 /* XCRemoteSwiftPackageReference "Sparkle" */,
A6C687020CB97FEDFE2BCA1B /* XCLocalSwiftPackageReference "PicnestCore" */,
);
preferredProjectObjectVersion = 77;
Expand Down Expand Up @@ -394,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 */,
Expand All @@ -415,6 +430,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 */,
);
Expand Down Expand Up @@ -578,6 +594,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Picnest/Picnest.Debug.entitlements;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = NO;
Expand Down Expand Up @@ -605,6 +622,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;
Expand Down Expand Up @@ -683,11 +701,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 */;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 19 additions & 6 deletions Picnest/App/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import AppKit

/// 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."
Expand All @@ -10,16 +14,25 @@ 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 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())
self.init(
environment: isTestHost ? nil : AppEnvironment(),
makeUpdater: isTestHost ? nil : { SparkleAppUpdater() })
}

init(environment: AppEnvironment?) {
init(environment: AppEnvironment?, makeUpdater: (() -> any AppUpdater)? = nil) {
self.environment = environment
app = environment.map { AppModel(environment: $0) }
// Checks the appcast on a schedule and installs a new version at quit.
updater = makeUpdater?()
super.init()
}

Expand Down
38 changes: 38 additions & 0 deletions Picnest/App/AppUpdater.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
}
Loading
Loading