Compare commits

..
Author SHA1 Message Date
Claude Fable 5 a32cd03581 review: refuse to publish a bundle without a team identifier; document the allowed-teams override
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:06:57 +04:00
Claude Fable 5 8a12ed0a54 release: NOTARIZED is 0/1, compare numerically
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:03:48 +04:00
Claude Fable 5 bfdc6fde9d release: spctl gate only hard-fails when the build was notarized
Un-notarized fallback builds (Apple Development identity) are rejected by
spctl by design; the script must still publish them with a warning, otherwise
the fallback path can never release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:03:40 +04:00
kua-agentandClaude Fable 5.1 9884c844d4 release: publish-update.sh — resolve a real signing identity, notarize, record it in the appcast
SIGN_IDENTITY now comes from REDLINE_SIGN_IDENTITY, else the first
"Developer ID Application" identity in the keychain (REDLINE_KEYCHAIN adds
--keychain everywhere it's searched/used), else the existing Apple
Development identity with a loud WARNING that Gatekeeper will block first
install elsewhere. build-app.sh still has to sign first with its own
hardcoded Apple Development identity (that pair is what keeps the Screen
Recording grant alive) — this script now re-signs the resulting bundle with
the resolved identity, --options runtime --timestamp, before zipping.

Notarization is optional: set REDLINE_NOTARY_PROFILE (a notarytool
keychain profile) or all three of REDLINE_NOTARY_KEY_ID/_ISSUER/_KEY_PATH,
and after the zip is built the script submits it, waits, and on Accepted
staples Redline.app, rebuilds the zip and DMG from the stapled app (a new
build_dmg() that ditto-copies whatever is already at .build/Redline.app
rather than re-invoking make-dmg.sh, which would rebuild from source and
strip both the resolved signature and the staple), staples the DMG, and
requires `spctl -a -vv -t exec` to say "accepted" or the script aborts.
Absent notary config: prints NOT NOTARIZED and continues exactly as before.

appcast.json gains "notarized" and "teamIdentifier" (from codesign -dv);
confirmed UpdateChecker's Appcast Decodable already ignores unknown JSON
keys (verified with a standalone decode), so no app-side change was needed
for old appcasts to keep working. Ends with a summary block: identity used,
notarized yes/no, spctl verdict, team, sha256. --test dry-run behaviour
(upload to .../test/, skip the git commit) is unchanged.

Known gap, out of scope here: build-app.sh's own pre-sign step still hard-
requires its hardcoded Apple Development identity in the keychain even when
a Developer ID identity is what will actually ship — untouched per the task
boundary (this file only).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:00:41 +04:00
kua-agentandClaude Fable 5.1 365220ade8 test: extend UPDATE-SELFTEST — reject-unsigned, staged-signed, atomic-install, revert
Same fake-99.0.0-bundle setup as before, but now drives it through the
hardened UpdateChecker end to end, in-process:

(a) reject-unsigned — the fake bundle, copied then plist-edited without
    re-signing (editing Info.plist after copy invalidates the inherited
    signature on its own — nothing stripped by hand), must be rejected by
    checkNow(): updateAvailable stays nil and statusMessage is the exact
    "Update is not signed by MMD" text.
(b) staged-signed — codesign --force --deep --sign the same bundle
    (SHOTDECK_SELFTEST_SIGN_IDENTITY or the default Apple Development
    identity), re-zip, re-serve the same appcast path; must now stage.
(c) atomic-install — installStaged into a throwaway <tmp>/Applications
    (never real /Applications) pre-populated with a copy of the actually
    running app; asserts the target lands on 99.0.0, Redline.app.previous
    holds the original version, and no replacement-directory cruft is left
    beside them.
(d) revert — revertToPrevious swaps the rollback copy back in; asserts the
    target is back to the original version and .previous now holds 99.0.0.

Caught a real bug while wiring (d): replaceItemAt(target, withItemAt:
previousURL, backupItemName: "Redline.app.previous") self-clobbers, because
the backup name and the withItemAt source resolve to the same path — the
backup write lands before the swap ever reads it, so target ends up
unchanged. Fixed in UpdateChecker by staging previousURL through a throwaway
ditto copy first (same pattern installStaged already used).

Every existing phase (PICKER-SELFTEST, REGION-PERSIST, SEND-TRUTH, and the
final "UPDATE-SELFTEST PASS version=99.0.0") is unchanged and still prints.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:00:28 +04:00
kua-agentandClaude Fable 5.1 064e410e30 feat: surface check/revert/version in the menu and badge the icon
AppModel exposes appVersion, previousVersion, isCheckingForUpdates and
updateStatusMessage (an alias for the existing statusLine plumbing — one
status channel, not a new one), plus checkForUpdates() and
revertToPreviousVersion() wired to the hardened UpdateChecker.

Menu gains, in order: "Update to X" (unchanged, staged-only), "Check for
updates" (labelled "Checking…" and disabled mid-check), "Revert to <version>"
(only when a rollback copy exists), then the existing rows unchanged, then a
non-interactive footer "Redline <version>" with the status line under it —
same caption/secondary styles already used elsewhere in the file, no new
tokens.

Menu-bar icon gets a small badge while an update is staged: uses the SF
Symbol's own ".badge" variant when one exists for the current icon, otherwise
overlays a small dot on the plain symbol. Reads live model state, so the
badge disappears on its own once the offer clears.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:00:17 +04:00
kua-agentandClaude Fable 5.1 a79a569a7d feat: updater hardening — timeouts, signature verification, atomic install+rollback
30s/600s URLSession timeouts on the update session (was 15s/15s, too tight for
a real zip download). Every staged and installed payload is now verified with
the Security framework (SecStaticCodeCheckValidityWithErrors, strict + all
architectures + nested code) against bundle id ai.flowmaster.shotdeck and an
allowed-team set (PWMCBMX5M8, L3N9S54CN3; overridable via REDLINE_ALLOWED_TEAMS
for the self-test only) — an unsigned or wrongly-signed update is discarded
before checkNow ever offers it, and installStaged re-verifies what actually
landed on disk as defense in depth.

installStaged is now atomic: ditto into an itemReplacementDirectory, then
FileManager.replaceItemAt swaps it into place, keeping exactly one
Redline.app.previous rollback copy (older ones are dropped first). Added
revertToPrevious(target:) to swap that copy back in (itself reversible — the
replaced version becomes the new .previous), and previousVersion(target:) to
read its CFBundleShortVersionString. Relaunch is now a detached
"wait for this PID to exit, then open -n" shell handoff instead of a
synchronous open+terminate, so there is never a moment with two instances
running. checkNow(manual:) now says "Redline X is up to date." when the user
asked directly, and exposes isCheckingNow/lastCheckedAt for the UI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:00:10 +04:00
14 changed files with 686 additions and 946 deletions
+28 -21
View File
@@ -32,9 +32,6 @@ public final class AppModel {
public private(set) var isSending: Bool = false public private(set) var isSending: Bool = false
public private(set) var outboxDisplayName: String public private(set) var outboxDisplayName: String
public private(set) var watchFolderDisplayName: String public private(set) var watchFolderDisplayName: String
/// Live transport choice; WP-onedrive reads this to pick the send path and to drive
/// the Settings "Send via" picker and the menu's "Send" label.
public private(set) var transport: SendTransport
/// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect. /// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect.
public private(set) var outboxURL: URL public private(set) var outboxURL: URL
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`. /// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
@@ -46,6 +43,8 @@ public final class AppModel {
var hotkeyDisplayString: String { captureHotkey.displayString } var hotkeyDisplayString: String { captureHotkey.displayString }
/// Staged update offered in the menu. Set only after checksum + payload validation. /// Staged update offered in the menu. Set only after checksum + payload validation.
public private(set) var updateAvailable: (version: String, notes: String)? public private(set) var updateAvailable: (version: String, notes: String)?
/// True for the duration of any appcast check (manual or scheduled).
public private(set) var isCheckingForUpdates: Bool = false
let paths: AppSupportPaths let paths: AppSupportPaths
let spool: SpoolStore let spool: SpoolStore
@@ -84,15 +83,12 @@ public final class AppModel {
) )
self.region = Self.loadPersistedRegion() self.region = Self.loadPersistedRegion()
self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
// Seeded from TransportSettings.effectiveFolders() the one place that combines // Seeded from FolderSettings.resolve() via resolvedAppSupportPaths never .standard().
// the transport choice with FolderSettings/OneDriveLocator. Never call let folders = FolderSettings.resolve()
// FolderSettings.resolve() directly outside that function.
let folders = TransportSettings.effectiveFolders()
self.outboxURL = folders.outbox self.outboxURL = folders.outbox
self.watchFolderURL = folders.watch self.watchFolderURL = folders.watch
self.outboxDisplayName = folders.outbox.lastPathComponent self.outboxDisplayName = folders.outbox.lastPathComponent
self.watchFolderDisplayName = folders.watch.lastPathComponent self.watchFolderDisplayName = folders.watch.lastPathComponent
self.transport = folders.transport
self.captureHotkey = HotkeyPreference.load() self.captureHotkey = HotkeyPreference.load()
self.updateChecker = UpdateChecker() self.updateChecker = UpdateChecker()
self.updateChecker.onChecked = { [weak self] in self.updateChecker.onChecked = { [weak self] in
@@ -102,7 +98,18 @@ public final class AppModel {
self.setStatus(message) self.setStatus(message)
} }
} }
self.updateChecker.onCheckingChanged = { [weak self] checking in
self?.isCheckingForUpdates = checking
} }
}
/// CFBundleShortVersionString of the running app.
public var appVersion: String { UpdateChecker.currentVersion() }
/// Version recorded in the app-managed rollback copy, when one exists.
public var previousVersion: String? { updateChecker.previousVersion() }
/// Most recent status text shared with the general status line by design
/// (Redline has one status channel, not a separate update-only one).
public var updateStatusMessage: String? { statusLine }
// MARK: Seam mutators the only way a WP-4b/4c extension changes state. // MARK: Seam mutators the only way a WP-4b/4c extension changes state.
@@ -124,7 +131,6 @@ public final class AppModel {
watchFolderURL = watch watchFolderURL = watch
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent) setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
} }
func setTransport(_ value: SendTransport) { transport = value }
func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url } func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url }
/// True when a last-composed PDF path is known this run, or the newest /// True when a last-composed PDF path is known this run, or the newest
@@ -192,16 +198,6 @@ public final class AppModel {
// Empty ledger on first run is not an error. // Empty ledger on first run is not an error.
} }
// OneDrive mode: outbox == watch folder, so a freshly written, unmarked PDF must
// never show up as a return; only a document that already carries a mark does.
// Also make sure the resolved OneDrive folder actually exists before the
// watcher starts watching it (bootstrap is the other creation trigger besides
// chooseTransport/chooseOneDriveFolder see TransportSettings.effectiveFolders).
if transport == .oneDrive {
try? FileManager.default.createDirectory(at: outboxURL, withIntermediateDirectories: true)
}
await watcher.setRecordUncommented(transport == .airDrop)
do { do {
try await watcher.start { [weak self] _ in try await watcher.start { [weak self] _ in
Task { @MainActor in Task { @MainActor in
@@ -225,8 +221,6 @@ public final class AppModel {
ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
|| ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
|| ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|| ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil
|| ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] != nil
if !skipSchedule { if !skipSchedule {
updateChecker.startSchedule() updateChecker.startSchedule()
} }
@@ -238,6 +232,19 @@ public final class AppModel {
updateChecker.installStaged() updateChecker.installStaged()
} }
/// User-initiated appcast check ("Check for updates" menu row).
public func checkForUpdates() {
Task { @MainActor in
await updateChecker.checkNow(manual: true)
}
}
/// Reverts `/Applications/Redline.app` to the app-managed rollback copy and relaunches.
/// Does nothing unless a `Redline.app.previous` exists and the user clicked the row.
public func revertToPreviousVersion() {
updateChecker.revertToPrevious()
}
/// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new /// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new
/// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working. /// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working.
func reRegisterHotkey() { func reRegisterHotkey() {
+32 -1
View File
@@ -15,6 +15,8 @@ struct MenuBarView: View {
Divider() Divider()
returnsBlock returnsBlock
} }
Divider()
updateFooter
} }
.padding(10) .padding(10)
.frame(width: 320, alignment: .leading) .frame(width: 320, alignment: .leading)
@@ -83,6 +85,21 @@ struct MenuBarView: View {
} }
} }
Button {
model.checkForUpdates()
} label: {
actionLabel(model.isCheckingForUpdates ? "Checking…" : "Check for updates")
}
.disabled(model.isCheckingForUpdates)
if let previous = model.previousVersion {
Button {
model.revertToPreviousVersion()
} label: {
actionLabel("Revert to \(previous)")
}
}
Button { Button {
let anchor = NSApp.keyWindow?.contentView let anchor = NSApp.keyWindow?.contentView
if let sender = model as? SendCapable { if let sender = model as? SendCapable {
@@ -91,7 +108,7 @@ struct MenuBarView: View {
model.setStatus("Send is not available in this build.") model.setStatus("Send is not available in this build.")
} }
} label: { } label: {
actionLabel(model.transport == .oneDrive ? "Send to OneDrive" : "Send…") actionLabel("Send…")
} }
.disabled(model.session.isEmpty || model.isSending) .disabled(model.session.isEmpty || model.isSending)
@@ -193,4 +210,18 @@ struct MenuBarView: View {
private var newestReturns: [ReturnedDocument] { private var newestReturns: [ReturnedDocument] {
model.allReturns.sorted { $0.detectedAt > $1.detectedAt } model.allReturns.sorted { $0.detectedAt > $1.detectedAt }
} }
private var updateFooter: some View {
VStack(alignment: .leading, spacing: 2) {
Text("Redline \(model.appVersion)")
.font(.caption)
.foregroundStyle(.secondary)
if let message = model.updateStatusMessage {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
} }
+122 -222
View File
@@ -3,7 +3,6 @@ import CoreGraphics
import Darwin import Darwin
import Foundation import Foundation
import ImageIO import ImageIO
import PDFKit
import ShotdeckCore import ShotdeckCore
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`. /// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
@@ -174,7 +173,7 @@ enum PickerSelfTest {
try await executeSendTruth() try await executeSendTruth()
print("SEND-TRUTH PASS") print("SEND-TRUTH PASS")
fflush(stdout) fflush(stdout)
if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() { if !startUpdateSelfTestIfRequested() {
exit(0) exit(0)
} }
} catch { } catch {
@@ -316,9 +315,7 @@ enum PickerSelfTest {
try await runUpdateSelfTest(outputDirectory: output) try await runUpdateSelfTest(outputDirectory: output)
print("UPDATE-SELFTEST PASS version=99.0.0") print("UPDATE-SELFTEST PASS version=99.0.0")
fflush(stdout) fflush(stdout)
if !startOneDriveSelfTestIfRequested() {
exit(0) exit(0)
}
} catch let error as UpdateSelfTestError { } catch let error as UpdateSelfTestError {
updateFail(error.description) updateFail(error.description)
} catch { } catch {
@@ -328,6 +325,9 @@ enum PickerSelfTest {
return true return true
} }
/// (a) rejects an invalidly-signed payload, (b) stages the same payload once
/// properly signed, (c) installs it atomically into a throwaway target with
/// exactly one rollback copy, (d) reverts back. Never touches `/Applications`.
private static func runUpdateSelfTest(outputDirectory: URL) async throws { private static func runUpdateSelfTest(outputDirectory: URL) async throws {
let fm = FileManager.default let fm = FileManager.default
try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true) try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
@@ -335,6 +335,9 @@ enum PickerSelfTest {
guard let sourceApp = ownAppBundleURL() else { guard let sourceApp = ownAppBundleURL() else {
throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))") throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))")
} }
guard let originalVersion = readShortVersion(atAppURL: sourceApp) else {
throw UpdateSelfTestError.detail("own Info.plist has no CFBundleShortVersionString")
}
let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true) let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true)
if fm.fileExists(atPath: payload.path) { if fm.fileExists(atPath: payload.path) {
@@ -352,17 +355,20 @@ enum PickerSelfTest {
plist["CFBundleShortVersionString"] = "99.0.0" plist["CFBundleShortVersionString"] = "99.0.0"
let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try rewritten.write(to: plistURL) try rewritten.write(to: plistURL)
// Editing Info.plist after copying it invalidates the inherited signature
// Info.plist is a sealed special slot in the CodeDirectory so this fake
// bundle is genuinely unsigned-in-effect without us stripping anything.
let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip") let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip")
let appcastURL = outputDirectory.appendingPathComponent("appcast.json")
func writeZipAndAppcast() throws {
if fm.fileExists(atPath: zipURL.path) { if fm.fileExists(atPath: zipURL.path) {
try fm.removeItem(at: zipURL) try fm.removeItem(at: zipURL)
} }
try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path]) try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path])
let zipData = try Data(contentsOf: zipURL) let zipData = try Data(contentsOf: zipURL)
let hex = UpdateChecker.sha256Hex(zipData) let hex = UpdateChecker.sha256Hex(zipData)
let appcastURL = outputDirectory.appendingPathComponent("appcast.json")
let appcast: [String: String] = [ let appcast: [String: String] = [
"version": "99.0.0", "version": "99.0.0",
"zipURL": zipURL.absoluteString, "zipURL": zipURL.absoluteString,
@@ -371,13 +377,15 @@ enum PickerSelfTest {
] ]
let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys]) let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys])
try appcastData.write(to: appcastURL) try appcastData.write(to: appcastURL)
}
try writeZipAndAppcast()
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey) let previousAppcastPref = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey)
defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey) defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey)
defer { defer {
if let previous { if let previousAppcastPref {
defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey) defaults.set(previousAppcastPref, forKey: UpdateChecker.appcastURLDefaultsKey)
} else { } else {
defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey) defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey)
} }
@@ -386,39 +394,128 @@ enum PickerSelfTest {
let (model, isolatedRoot) = try makeIsolatedUpdateModel() let (model, isolatedRoot) = try makeIsolatedUpdateModel()
defer { try? fm.removeItem(at: isolatedRoot) } defer { try? fm.removeItem(at: isolatedRoot) }
// (a) NEGATIVE invalidly-signed payload must never be offered or staged.
await model.updateChecker.checkNow() await model.updateChecker.checkNow()
guard model.updateAvailable == nil else {
throw UpdateSelfTestError.detail(
"reject-unsigned: updateAvailable=\(model.updateAvailable?.version ?? "nil") (expected nil)"
)
}
guard model.updateChecker.statusMessage == "Update is not signed by MMD — not installed." else {
throw UpdateSelfTestError.detail(
"reject-unsigned: statusMessage=\(model.updateChecker.statusMessage ?? "nil")"
)
}
print("UPDATE-SELFTEST reject-unsigned PASS")
fflush(stdout)
// (b) POSITIVE re-sign the same bundle, re-zip, re-serve; must now stage.
let signIdentity = ProcessInfo.processInfo.environment["SHOTDECK_SELFTEST_SIGN_IDENTITY"]
?? "Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
try runCodesign(identity: signIdentity, path: fakeApp.path)
try writeZipAndAppcast()
await model.updateChecker.checkNow()
guard model.updateAvailable?.version == "99.0.0" else { guard model.updateAvailable?.version == "99.0.0" else {
throw UpdateSelfTestError.detail( throw UpdateSelfTestError.detail(
"updateAvailable=\(model.updateAvailable?.version ?? "nil")" "staged-signed: updateAvailable=\(model.updateAvailable?.version ?? "nil")"
) )
} }
guard let staged = model.updateChecker.stagedAppURL else { guard let staged = model.updateChecker.stagedAppURL else {
throw UpdateSelfTestError.detail("staged payload missing") throw UpdateSelfTestError.detail("staged-signed: staged payload missing")
} }
guard staged.lastPathComponent == "Redline.app" else { guard staged.lastPathComponent == "Redline.app" else {
throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)") throw UpdateSelfTestError.detail("staged-signed: staged name \(staged.lastPathComponent)")
} }
let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck") let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck")
guard fm.fileExists(atPath: stagedExe.path) else { guard fm.fileExists(atPath: stagedExe.path) else {
throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing") throw UpdateSelfTestError.detail("staged-signed: staged Contents/MacOS/Shotdeck missing")
} }
print("UPDATE-SELFTEST staged-signed PASS")
fflush(stdout)
let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true) // (c) ATOMIC INSTALL a throwaway target pre-populated with the real
if fm.fileExists(atPath: targetRoot.path) { // running version; never `/Applications`.
try fm.removeItem(at: targetRoot) let tempAppsRoot = outputDirectory.appendingPathComponent("Applications", isDirectory: true)
if fm.fileExists(atPath: tempAppsRoot.path) {
try fm.removeItem(at: tempAppsRoot)
} }
let target = targetRoot.appendingPathComponent("Redline.app") try fm.createDirectory(at: tempAppsRoot, withIntermediateDirectories: true)
model.updateChecker.installStaged(to: target) let tempTarget = tempAppsRoot.appendingPathComponent("Redline.app")
try fm.copyItem(at: sourceApp, to: tempTarget)
let installedPlist = target.appendingPathComponent("Contents/Info.plist") model.updateChecker.installStaged(to: tempTarget)
guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any],
let installedVersion = installed["CFBundleShortVersionString"] as? String guard let installedVersion = readShortVersion(atAppURL: tempTarget) else {
else { throw UpdateSelfTestError.detail("atomic-install: installed Info.plist unreadable")
throw UpdateSelfTestError.detail("installed Info.plist unreadable")
} }
guard installedVersion == "99.0.0" else { guard installedVersion == "99.0.0" else {
throw UpdateSelfTestError.detail("installed version \(installedVersion)") throw UpdateSelfTestError.detail("atomic-install: installed version \(installedVersion)")
}
let previousCopy = tempAppsRoot.appendingPathComponent("Redline.app.previous")
guard let previousVersionAfterInstall = readShortVersion(atAppURL: previousCopy) else {
throw UpdateSelfTestError.detail("atomic-install: Redline.app.previous missing or unreadable")
}
guard previousVersionAfterInstall == originalVersion else {
throw UpdateSelfTestError.detail(
"atomic-install: previous version=\(previousVersionAfterInstall) expected=\(originalVersion)"
)
}
try assertNoLeftoverEntries(in: tempAppsRoot, expecting: ["Redline.app", "Redline.app.previous"])
print("UPDATE-SELFTEST atomic-install PASS")
fflush(stdout)
// (d) REVERT the rollback copy swaps back in; the just-replaced version
// becomes the new rollback copy, so a revert is itself reversible.
model.updateChecker.revertToPrevious(target: tempTarget)
guard let revertedVersion = readShortVersion(atAppURL: tempTarget) else {
throw UpdateSelfTestError.detail("revert: reverted Info.plist unreadable")
}
guard revertedVersion == originalVersion else {
throw UpdateSelfTestError.detail("revert: target version=\(revertedVersion) expected=\(originalVersion)")
}
guard let previousVersionAfterRevert = readShortVersion(atAppURL: previousCopy) else {
throw UpdateSelfTestError.detail("revert: Redline.app.previous missing or unreadable")
}
guard previousVersionAfterRevert == "99.0.0" else {
throw UpdateSelfTestError.detail(
"revert: previous version=\(previousVersionAfterRevert) expected=99.0.0"
)
}
try assertNoLeftoverEntries(in: tempAppsRoot, expecting: ["Redline.app", "Redline.app.previous"])
print("UPDATE-SELFTEST revert PASS")
fflush(stdout)
}
private static func readShortVersion(atAppURL url: URL) -> String? {
let plistURL = url.appendingPathComponent("Contents/Info.plist")
guard let dict = NSDictionary(contentsOf: plistURL) as? [String: Any] else { return nil }
return dict["CFBundleShortVersionString"] as? String
}
private static func runCodesign(identity: String, path: String) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
process.arguments = ["--force", "--deep", "--sign", identity, path]
let err = Pipe()
process.standardError = err
process.standardOutput = Pipe()
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
throw UpdateSelfTestError.detail("codesign failed: \(message)")
}
}
private static func assertNoLeftoverEntries(in directory: URL, expecting expected: Set<String>) throws {
let entries = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
let unexpected = entries.filter { !expected.contains($0) }
guard unexpected.isEmpty else {
throw UpdateSelfTestError.detail(
"unexpected entries in \(directory.path): \(unexpected.joined(separator: ", "))"
)
} }
} }
@@ -476,194 +573,6 @@ enum PickerSelfTest {
exit(1) exit(1)
} }
/// Real sync root this Mac has; the phase proves the transport against the actual
/// OneDrive file provider, never a fake home tree (that is what
/// OneDriveLocatorTests in ShotdeckCoreTests are for).
private static let realOneDriveSyncRoot = URL(
fileURLWithPath: "/Users/benjaminhippler/Library/CloudStorage/OneDrive-MMDGROUP",
isDirectory: true
)
/// Phase 5: proves the OneDrive transport end to end against the real sync root.
/// Triggered by `SHOTDECK_ONEDRIVE_SELFTEST` when chained after PICKER/SEND-TRUTH/
/// UPDATE-SELFTEST the exact pattern `startUpdateSelfTestIfRequested` uses for its
/// own env var. Returns true when the async phase was scheduled (it calls `exit` itself).
@discardableResult
private static func startOneDriveSelfTestIfRequested() -> Bool {
guard ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil else {
return false
}
runOneDriveSelfTestAndExit()
return true
}
/// Entry point for running ONLY this phase, bypassing the on-screen picker chain
/// entirely. The harness has no other per-phase selector, so this is the escape
/// hatch: `REDLINE_SELFTEST_PHASE=onedrive`.
static func runOneDriveOnlyIfRequested() {
guard ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" else {
return
}
// Same hop as runIfRequested(): a plain main-queue turn after NSApp starts, so
// AppKit/PDFKit calls inside the phase are not racing app launch.
DispatchQueue.main.async {
MainActor.assumeIsolated {
runOneDriveSelfTestAndExit()
}
}
}
private static func runOneDriveSelfTestAndExit() {
Task { @MainActor in
do {
let folder = try await executeOneDriveSelfTest()
print("ONEDRIVE-SELFTEST PASS path=\(folder.path)")
fflush(stdout)
exit(0)
} catch let error as OneDriveSelfTestError {
oneDriveFail(error.description)
} catch {
oneDriveFail(String(describing: error))
}
}
}
/// Builds a session, sends it through the OneDrive branch of `send(anchor: nil)`
/// against a NEW folder under the real OneDrive sync root, confirms the watcher does
/// NOT report the freshly-written unmarked PDF as a return, then adds a real PDFKit
/// ink annotation in place (what the iPad does) and confirms the watcher now reports
/// it as commented. Never deletes anything under OneDrive the created folder and
/// PDF are left in place for Ben to inspect / for the real iPad round trip.
private static func executeOneDriveSelfTest() async throws -> URL {
let fm = FileManager.default
guard fm.fileExists(atPath: realOneDriveSyncRoot.path) else {
throw OneDriveSelfTestError.detail(
"real OneDrive sync root not found at \(realOneDriveSyncRoot.path)"
)
}
// UserDefaults.standard is the ONLY defaults instance send()/TransportSettings
// actually read at runtime (there is no defaults-threading through AppModel), so
// "isolated" here means snapshot-and-restore around the real keys the same
// pattern runRegionPersistPhase already uses for CaptureRegion.defaultsKey.
let defaults = UserDefaults.standard
let previousTransport = defaults.string(forKey: TransportSettings.transportDefaultsKey)
let previousFolder = defaults.string(forKey: TransportSettings.oneDriveFolderDefaultsKey)
defer {
if let previousTransport {
defaults.set(previousTransport, forKey: TransportSettings.transportDefaultsKey)
} else {
defaults.removeObject(forKey: TransportSettings.transportDefaultsKey)
}
if let previousFolder {
defaults.set(previousFolder, forKey: TransportSettings.oneDriveFolderDefaultsKey)
} else {
defaults.removeObject(forKey: TransportSettings.oneDriveFolderDefaultsKey)
}
}
let stamp = DubaiTime.fileStamp(Date())
let selftestFolder = realOneDriveSyncRoot
.appendingPathComponent("Redline-selftest-\(stamp)", isDirectory: true)
try fm.createDirectory(at: selftestFolder, withIntermediateDirectories: true)
TransportSettings.setTransport(.oneDrive, defaults: defaults)
TransportSettings.setOneDriveFolder(selftestFolder, defaults: defaults)
// Local spool root only the outbox/watch folder is the real OneDrive folder.
let spoolRoot = fm.temporaryDirectory
.appendingPathComponent("shotdeck-onedrive-selftest-\(UUID().uuidString)", isDirectory: true)
defer { try? fm.removeItem(at: spoolRoot) }
let paths = try AppSupportPaths(root: spoolRoot, outbox: selftestFolder, watchFolder: selftestFolder)
let ledger = try ReturnLedger(paths: paths)
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
await watcher.setRecordUncommented(false) // OneDrive mode: today's default is AirDrop's `true`.
let model = AppModel(
paths: paths,
spool: try SpoolStore(paths: paths),
composer: PDFComposer(),
capturer: ScreenCapturer(),
hotkeys: HotkeyCenter(),
picker: RegionPickerController(),
ledger: ledger,
watcher: watcher
)
model.setFolderURLs(outbox: selftestFolder, watch: selftestFolder)
model.setTransport(.oneDrive)
let png = try makeTinyPNGData()
_ = try await model.spool.append(
pngData: png, pixelWidth: 64, pixelHeight: 48, scale: 1, capturedAt: Date()
)
model.replaceSession(try await model.spool.currentSession())
guard !model.session.isEmpty else {
throw OneDriveSelfTestError.detail("seeded session was empty")
}
await model.send(anchor: nil)
guard let status = model.statusLine, status.hasPrefix("Saved to OneDrive") else {
throw OneDriveSelfTestError.detail(
"status did not start with 'Saved to OneDrive': \(model.statusLine ?? "nil")"
)
}
guard model.session.isEmpty else {
throw OneDriveSelfTestError.detail("session was not archived after the OneDrive send")
}
let written = (try? fm.contentsOfDirectory(at: selftestFolder, includingPropertiesForKeys: nil)) ?? []
guard let pdfURL = written.first(where: { $0.pathExtension.lowercased() == "pdf" }) else {
throw OneDriveSelfTestError.detail("no PDF found in \(selftestFolder.path)")
}
// Unmarked so far: the watcher must not treat it as a return.
let beforeMarkup = try await watcher.scanNow()
guard !beforeMarkup.contains(where: { $0.fileURL == pdfURL }) else {
throw OneDriveSelfTestError.detail("unmarked PDF was reported as returned by scanNow")
}
let commentedBefore = try await ledger.commented()
guard !commentedBefore.contains(where: { $0.fileURL == pdfURL }) else {
throw OneDriveSelfTestError.detail("unmarked PDF was recorded as commented in the ledger")
}
// What the iPad does: mark it up in place with a real ink annotation, then save.
guard let document = PDFDocument(url: pdfURL), let page = document.page(at: 0) else {
throw OneDriveSelfTestError.detail("could not reopen \(pdfURL.path) to annotate it")
}
let ink = PDFAnnotation(
bounds: CGRect(x: 20, y: 20, width: 60, height: 60),
forType: .ink,
withProperties: nil
)
let stroke = NSBezierPath()
stroke.move(to: NSPoint(x: 20, y: 20))
stroke.line(to: NSPoint(x: 80, y: 80))
ink.add(stroke)
page.addAnnotation(ink)
guard document.write(to: pdfURL) else {
throw OneDriveSelfTestError.detail("could not save the annotated PDF back to \(pdfURL.path)")
}
let afterMarkup = try await watcher.scanNow()
guard let recorded = afterMarkup.first(where: { $0.fileURL == pdfURL }), recorded.isCommented else {
throw OneDriveSelfTestError.detail("annotated PDF was not reported as commented by scanNow")
}
let commentedAfter = try await ledger.commented()
guard commentedAfter.contains(where: { $0.fileURL == pdfURL }) else {
throw OneDriveSelfTestError.detail("annotated PDF was not recorded in the ledger as commented")
}
return selftestFolder
}
private static func oneDriveFail(_ detail: String) -> Never {
print("ONEDRIVE-SELFTEST FAIL \(detail)")
fflush(stdout)
exit(1)
}
private static func interpolate(_ step: Int) -> NSPoint { private static func interpolate(_ step: Int) -> NSPoint {
let t = CGFloat(step) / CGFloat(dragSteps) let t = CGFloat(step) / CGFloat(dragSteps)
return NSPoint( return NSPoint(
@@ -729,12 +638,3 @@ private enum UpdateSelfTestError: Error, CustomStringConvertible {
} }
} }
} }
private enum OneDriveSelfTestError: Error, CustomStringConvertible {
case detail(String)
var description: String {
switch self {
case .detail(let s): return s
}
}
}
-44
View File
@@ -16,31 +16,6 @@ extension AppModel: SendCapable {
guard !session.isEmpty, !isSending else { return } guard !session.isEmpty, !isSending else { return }
setSending(true) setSending(true)
let transport = TransportSettings.transport()
// OneDrive mode: verify the real destination exists RIGHT NOW, before composing
// anything. `outboxURL` is kept in sync with the resolved OneDrive folder by
// bootstrap/chooseTransport/chooseOneDriveFolder, but this is re-resolved fresh
// here (never trusted stale) so a folder that vanished since then (OneDrive
// signed out, external volume unmounted, folder deleted) is caught instead of
// silently writing into whatever `outboxURL` happens to hold.
if transport == .oneDrive {
guard let folder = OneDriveLocator.resolveOneDriveFolder(),
Self.directoryExists(at: folder)
else {
let path = OneDriveLocator.resolveOneDriveFolder()?.path
?? TransportSettings.storedOneDriveFolderPath()
?? "no OneDrive folder found"
setStatus(ShotdeckError.oneDriveFolderUnavailable(path: path).errorDescription)
setSending(false)
return
}
if outboxURL != folder || watchFolderURL != folder {
setFolderURLs(outbox: folder, watch: folder)
try? await watcher.updateWatchFolder(folder)
}
}
let pending: ComposedSend let pending: ComposedSend
do { do {
pending = try await composePDFForSend() pending = try await composePDFForSend()
@@ -52,18 +27,6 @@ extension AppModel: SendCapable {
return return
} }
switch transport {
case .oneDrive:
// No AirDrop, no anchor needed the PDF is already in the watched
// OneDrive folder. Archive immediately; the iPad marks it up in place.
await handleDidShareItems(fileName: pending.fileName, pageCount: pending.pageCount)
let pageWord = pending.pageCount == 1 ? "page" : "pages"
setStatus(
"Saved to OneDrive — \(pending.pageCount) \(pageWord). Open it in Files on your iPad."
)
setSending(false)
case .airDrop:
guard let anchor else { guard let anchor else {
handleDidFailToShareItems(fileName: pending.fileName) handleDidFailToShareItems(fileName: pending.fileName)
setSending(false) setSending(false)
@@ -89,13 +52,6 @@ extension AppModel: SendCapable {
setSending(false) setSending(false)
} }
} }
}
private static func directoryExists(at url: URL) -> Bool {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
return exists && isDirectory.boolValue
}
/// Writes the PDF to the outbox and records its path. Does not archive the session /// Writes the PDF to the outbox and records its path. Does not archive the session
/// and does not present AirDrop that happens only after the share completes. /// and does not present AirDrop that happens only after the share completes.
-108
View File
@@ -33,25 +33,6 @@ struct SettingsView: View {
.frame(minHeight: 22) .frame(minHeight: 22)
} }
GridRow {
Text("Send via")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.gridCellColumns(2)
.padding(.top, 6)
}
GridRow {
Picker("Send via", selection: transportBinding) {
ForEach(SendTransport.allCases, id: \.self) { transport in
Text(transport.displayName).tag(transport)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.gridCellColumns(2)
}
GridRow { GridRow {
Text("Folders") Text("Folders")
.font(.headline) .font(.headline)
@@ -60,7 +41,6 @@ struct SettingsView: View {
.padding(.top, 6) .padding(.top, 6)
} }
if model.transport == .airDrop {
GridRow(alignment: .center) { GridRow(alignment: .center) {
fieldLabel("Watch folder") fieldLabel("Watch folder")
folderValue(path: model.watchFolderURL.path) { folderValue(path: model.watchFolderURL.path) {
@@ -74,36 +54,6 @@ struct SettingsView: View {
model.chooseOutboxFolder() model.chooseOutboxFolder()
} }
} }
} else {
GridRow(alignment: .center) {
fieldLabel("OneDrive folder")
if let folder = resolvedOneDriveFolder {
folderValue(path: folder.path) {
model.chooseOneDriveFolder()
}
} else {
HStack(spacing: 8) {
Text("No OneDrive folder found — sign in to OneDrive or choose a folder.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
Button("Choose…") { model.chooseOneDriveFolder() }
}
.frame(minHeight: 22)
}
}
GridRow {
Text(
"The PDF is saved here and this same folder is watched for the marked-up copy. On the iPad open it from Files > OneDrive."
)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.gridCellColumns(2)
}
}
GridRow { GridRow {
Button("Reveal spool folder") { model.openSpoolFolder() } Button("Reveal spool folder") { model.openSpoolFolder() }
@@ -118,17 +68,6 @@ struct SettingsView: View {
.onDisappear { disarmHotkeyRecorder() } .onDisappear { disarmHotkeyRecorder() }
} }
private var transportBinding: Binding<SendTransport> {
Binding(get: { model.transport }, set: { model.chooseTransport($0) })
}
/// Ground truth from OneDriveLocator, not `model.outboxURL` the model may be
/// showing an AirDrop-folder fallback when no real OneDrive folder resolves, and
/// the Settings row must say so plainly rather than repeat that fallback path.
private var resolvedOneDriveFolder: URL? {
OneDriveLocator.resolveOneDriveFolder()
}
private func armHotkeyRecorder() { private func armHotkeyRecorder() {
guard !isRecordingHotkey else { return } guard !isRecordingHotkey else { return }
isRecordingHotkey = true isRecordingHotkey = true
@@ -251,53 +190,6 @@ extension AppModel: SettingsWindowPresenting {
} }
} }
/// Settings "Send via" picker action. Persists the choice, recomputes the effective
/// outbox/watch folder for the new transport, creates the OneDrive folder if it
/// doesn't exist yet, and re-points the running watcher (folder + recordUncommented)
/// at the new state. Switching back to AirDrop restores its own stored overrides
/// untouched, since AirDrop and OneDrive folder settings are stored under separate keys.
func chooseTransport(_ value: SendTransport) {
guard value != transport else { return }
TransportSettings.setTransport(value)
setTransport(value)
let folders = TransportSettings.effectiveFolders()
if value == .oneDrive {
try? FileManager.default.createDirectory(
at: folders.outbox, withIntermediateDirectories: true
)
}
setFolderURLs(outbox: folders.outbox, watch: folders.watch)
Task {
await watcher.setRecordUncommented(value == .airDrop)
do {
try await watcher.updateWatchFolder(folders.watch)
} catch {
setStatus(
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
)
}
}
}
func chooseOneDriveFolder() {
let start = OneDriveLocator.resolveOneDriveFolder() ?? FileManager.default.homeDirectoryForCurrentUser
guard let url = chooseDirectory(startingAt: start) else { return }
TransportSettings.setOneDriveFolder(url)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
guard transport == .oneDrive else { return }
setFolderURLs(outbox: url, watch: url)
Task {
do {
try await watcher.updateWatchFolder(url)
setStatus("OneDrive folder set to \(url.lastPathComponent).")
} catch {
setStatus(
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
)
}
}
}
private func chooseDirectory(startingAt directory: URL) -> URL? { private func chooseDirectory(startingAt directory: URL) -> URL? {
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.canChooseDirectories = true panel.canChooseDirectories = true
+224 -21
View File
@@ -1,6 +1,7 @@
import AppKit import AppKit
import CryptoKit import CryptoKit
import Foundation import Foundation
import Security
/// Built-in updater. Checks an appcast, stages a verified payload, and installs /// Built-in updater. Checks an appcast, stages a verified payload, and installs
/// only when the user clicks the menu row never automatically. /// only when the user clicks the menu row never automatically.
@@ -10,22 +11,31 @@ final class UpdateChecker {
static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")! static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")!
static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app") static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app")
/// Required bundle identifier for any staged or installed payload.
static let expectedBundleIdentifier = "ai.flowmaster.shotdeck"
/// Developer team identifiers MMD ships Redline under. Overridable only for the self-test.
static let allowedTeamIdentifiers: Set<String> = ["PWMCBMX5M8", "L3N9S54CN3"]
private(set) var availableUpdate: (version: String, notes: String)? private(set) var availableUpdate: (version: String, notes: String)?
private(set) var stagedAppURL: URL? private(set) var stagedAppURL: URL?
private(set) var statusMessage: String? private(set) var statusMessage: String?
private(set) var lastCheckedAt: Date?
private(set) var isCheckingNow: Bool = false
var onChecked: (() -> Void)? var onChecked: (() -> Void)?
/// Fired whenever `isCheckingNow` flips, so a UI can show "Checking" for the
/// whole duration of a check rather than only after it lands.
var onCheckingChanged: ((Bool) -> Void)?
private let urlSession: URLSession private let urlSession: URLSession
private var repeatingTimer: Timer? private var repeatingTimer: Timer?
private var firstCheckTask: Task<Void, Never>? private var firstCheckTask: Task<Void, Never>?
private var isChecking = false
private var stagingDirectory: URL? private var stagingDirectory: URL?
init() { init() {
let config = URLSessionConfiguration.ephemeral let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 15 config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 15 config.timeoutIntervalForResource = 600
config.httpCookieAcceptPolicy = .never config.httpCookieAcceptPolicy = .never
config.httpShouldSetCookies = false config.httpShouldSetCookies = false
config.httpCookieStorage = nil config.httpCookieStorage = nil
@@ -51,10 +61,18 @@ final class UpdateChecker {
repeatingTimer = timer repeatingTimer = timer
} }
func checkNow() async { /// Checks the appcast and stages a newer, signature-verified payload.
guard !isChecking else { return } /// `manual` only affects the status message shown when already up to date
isChecking = true /// a user-initiated check says so; the silent background check stays quiet.
defer { isChecking = false } func checkNow(manual: Bool = false) async {
guard !isCheckingNow else { return }
isCheckingNow = true
onCheckingChanged?(true)
defer {
isCheckingNow = false
onCheckingChanged?(false)
}
lastCheckedAt = Date()
let appcast: Appcast let appcast: Appcast
do { do {
@@ -67,7 +85,7 @@ final class UpdateChecker {
guard Self.isNewer(appcast.version, than: Self.currentVersion()) else { guard Self.isNewer(appcast.version, than: Self.currentVersion()) else {
clearOffer() clearOffer()
statusMessage = nil statusMessage = manual ? "Redline \(Self.currentVersion()) is up to date." : nil
onChecked?() onChecked?()
return return
} }
@@ -80,6 +98,10 @@ final class UpdateChecker {
discardStaging() discardStaging()
availableUpdate = nil availableUpdate = nil
statusMessage = "Update file failed the checksum — not installed." statusMessage = "Update file failed the checksum — not installed."
} catch UpdateCheckError.signatureInvalid {
discardStaging()
availableUpdate = nil
statusMessage = "Update is not signed by MMD — not installed."
} catch { } catch {
discardStaging() discardStaging()
availableUpdate = nil availableUpdate = nil
@@ -88,9 +110,9 @@ final class UpdateChecker {
onChecked?() onChecked?()
} }
/// Copies the staged app onto `target` with ditto (in place; never deletes the old app). /// Installs the staged app onto `target` atomically, keeping exactly one rollback
/// Relaunches unless `SHOTDECK_UPDATE_SELFTEST` is set, so the in-process self-test /// copy (`Redline.app.previous`), then hands off to a relaunch and quits.
/// can assert the installed Info.plist without killing the process. /// Never deletes the old app before the new one is verified in place.
func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) { func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) {
guard let staged = stagedAppURL else { guard let staged = stagedAppURL else {
statusMessage = "No update is staged." statusMessage = "No update is staged."
@@ -98,29 +120,118 @@ final class UpdateChecker {
return return
} }
let targetDir = target.deletingLastPathComponent()
let previousURL = targetDir.appendingPathComponent("Redline.app.previous")
do { do {
try FileManager.default.createDirectory( try FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true)
at: target.deletingLastPathComponent(),
withIntermediateDirectories: true let replacementDir = try FileManager.default.url(
for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: target,
create: true
) )
try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, target.path]) defer { try? FileManager.default.removeItem(at: replacementDir) }
let newCopy = replacementDir.appendingPathComponent(target.lastPathComponent)
try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, newCopy.path])
// Exactly one rollback copy is kept drop any older one before this install.
if FileManager.default.fileExists(atPath: previousURL.path) {
try FileManager.default.removeItem(at: previousURL)
}
if FileManager.default.fileExists(atPath: target.path) {
_ = try FileManager.default.replaceItemAt(
target,
withItemAt: newCopy,
backupItemName: previousURL.lastPathComponent,
options: [.withoutDeletingBackupItem]
)
} else {
try FileManager.default.moveItem(at: newCopy, to: target)
}
} catch { } catch {
statusMessage = "The update could not be installed." statusMessage = "The update could not be installed."
onChecked?() onChecked?()
return return
} }
let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil // Defense in depth: re-verify what actually landed on disk, not just the staged copy.
if isSelfTest { return }
do { do {
try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path]) try Self.verifySignature(of: target)
} catch { } catch {
statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications." statusMessage = "The update was installed but failed verification."
onChecked?() onChecked?()
return return
} }
NSApp.terminate(nil)
discardStaging()
availableUpdate = nil
relaunch(target: target)
}
/// Swaps `Redline.app.previous` back into place, verifying its signature first.
/// The just-replaced (newer) app becomes the new `.previous` a revert is
/// itself reversible.
func revertToPrevious(target: URL = UpdateChecker.defaultInstallTarget) {
let targetDir = target.deletingLastPathComponent()
let previousURL = targetDir.appendingPathComponent("Redline.app.previous")
guard FileManager.default.fileExists(atPath: previousURL.path) else {
statusMessage = "No previous version to revert to."
onChecked?()
return
}
do {
try Self.verifySignature(of: previousURL)
} catch {
statusMessage = "The previous version failed verification and was not restored."
onChecked?()
return
}
do {
// `previousURL` cannot be handed to replaceItemAt directly: its own path
// IS the requested backup name, so the backup step would clobber it
// before the swap ever reads it. Stage a throwaway copy first, exactly
// like installStaged does for the forward direction.
let replacementDir = try FileManager.default.url(
for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: target,
create: true
)
defer { try? FileManager.default.removeItem(at: replacementDir) }
let newCopy = replacementDir.appendingPathComponent(target.lastPathComponent)
try Self.runProcess(executable: "/usr/bin/ditto", arguments: [previousURL.path, newCopy.path])
try FileManager.default.removeItem(at: previousURL)
_ = try FileManager.default.replaceItemAt(
target,
withItemAt: newCopy,
backupItemName: previousURL.lastPathComponent,
options: [.withoutDeletingBackupItem]
)
} catch {
statusMessage = "Could not revert to the previous version."
onChecked?()
return
}
relaunch(target: target)
}
/// The version recorded in `Redline.app.previous`'s Info.plist, or nil when no
/// rollback copy exists.
func previousVersion(target: URL = UpdateChecker.defaultInstallTarget) -> String? {
let previousURL = target.deletingLastPathComponent().appendingPathComponent("Redline.app.previous")
let plistURL = previousURL.appendingPathComponent("Contents/Info.plist")
guard let plist = NSDictionary(contentsOf: plistURL) as? [String: Any] else { return nil }
return plist["CFBundleShortVersionString"] as? String
} }
static func resolvedAppcastURL() -> URL { static func resolvedAppcastURL() -> URL {
@@ -156,6 +267,67 @@ final class UpdateChecker {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
} }
/// Validates the code signature of the app at `appURL`: strictly, across all
/// architectures and nested code, then checks its bundle identifier and team
/// identifier against `expectedBundleIdentifier` / the allowed-teams set.
/// `REDLINE_ALLOWED_TEAMS` (comma separated) overrides the allowed set for
/// the self-test only, so it can accept a locally re-signed fake bundle.
static func verifySignature(of appURL: URL) throws {
var staticCode: SecStaticCode?
let createStatus = SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode)
guard createStatus == errSecSuccess, let code = staticCode else {
throw UpdateCheckError.signatureInvalid(
"could not read a code signature (status \(createStatus))"
)
}
let validityFlags = SecCSFlags(
rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures | kSecCSCheckNestedCode
)
var validityError: Unmanaged<CFError>?
let validityStatus = SecStaticCodeCheckValidityWithErrors(code, validityFlags, nil, &validityError)
guard validityStatus == errSecSuccess else {
let detail = (validityError?.takeRetainedValue()).map { String(describing: $0) } ?? "status \(validityStatus)"
throw UpdateCheckError.signatureInvalid("signature is not valid: \(detail)")
}
var signingInfo: CFDictionary?
let infoStatus = SecCodeCopySigningInformation(
code,
SecCSFlags(rawValue: kSecCSSigningInformation),
&signingInfo
)
guard infoStatus == errSecSuccess, let info = signingInfo as? [String: Any] else {
throw UpdateCheckError.signatureInvalid("could not read signing information (status \(infoStatus))")
}
let identifier = info[kSecCodeInfoIdentifier as String] as? String
guard identifier == expectedBundleIdentifier else {
throw UpdateCheckError.signatureInvalid(
"unexpected bundle identifier: \(identifier ?? "nil")"
)
}
let teamIdentifier = info[kSecCodeInfoTeamIdentifier as String] as? String
guard let teamIdentifier, resolvedAllowedTeamIdentifiers().contains(teamIdentifier) else {
throw UpdateCheckError.signatureInvalid(
"unexpected team identifier: \(teamIdentifier ?? "nil")"
)
}
}
private static func resolvedAllowedTeamIdentifiers() -> Set<String> {
if let env = ProcessInfo.processInfo.environment["REDLINE_ALLOWED_TEAMS"], !env.isEmpty {
let parts = env.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
if !parts.isEmpty {
return Set(parts)
}
}
return allowedTeamIdentifiers
}
// MARK: - Private // MARK: - Private
private struct Appcast: Decodable { private struct Appcast: Decodable {
@@ -170,6 +342,7 @@ final class UpdateChecker {
case invalidPayload case invalidPayload
case httpStatus(Int) case httpStatus(Int)
case processFailed(String) case processFailed(String)
case signatureInvalid(String)
} }
private func fetchAppcast() async throws -> Appcast { private func fetchAppcast() async throws -> Appcast {
@@ -219,6 +392,7 @@ final class UpdateChecker {
guard FileManager.default.fileExists(atPath: executable.path) else { guard FileManager.default.fileExists(atPath: executable.path) else {
throw UpdateCheckError.invalidPayload throw UpdateCheckError.invalidPayload
} }
try Self.verifySignature(of: appURL)
stagedAppURL = appURL stagedAppURL = appURL
} }
@@ -235,6 +409,35 @@ final class UpdateChecker {
stagedAppURL = nil stagedAppURL = nil
} }
/// Spawns a detached watcher that waits for this process to exit, then reopens
/// `target`, and quits. Never called during the self-test, so the in-process
/// assertions after `installStaged`/`revertToPrevious` can still run.
private func relaunch(target: URL) {
guard ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] == nil else { return }
let ownPID = ProcessInfo.processInfo.processIdentifier
let script = "while kill -0 \(ownPID) 2>/dev/null; do sleep 0.2; done; " +
"/usr/bin/open -n \(Self.shellQuoted(target.path))"
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", script]
process.standardInput = FileHandle.nullDevice
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
try process.run()
} catch {
statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications."
onChecked?()
return
}
NSApp.terminate(nil)
}
private static func shellQuoted(_ path: String) -> String {
"'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private static func findRedlineApp(in directory: URL) -> URL? { private static func findRedlineApp(in directory: URL) -> URL? {
let fm = FileManager.default let fm = FileManager.default
let direct = directory.appendingPathComponent("Redline.app") let direct = directory.appendingPathComponent("Redline.app")
+25 -4
View File
@@ -7,9 +7,6 @@ import ShotdeckCore
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil { if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() } MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
} }
if ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" {
MainActor.assumeIsolated { PickerSelfTest.runOneDriveOnlyIfRequested() }
}
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil { if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() } MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
} }
@@ -24,8 +21,9 @@ struct ShotdeckApp: App {
.environment(appDelegate.model) .environment(appDelegate.model)
} label: { } label: {
let state = appDelegate.model.iconState let state = appDelegate.model.iconState
let hasUpdate = appDelegate.model.updateAvailable != nil
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: state.symbolName) menuBarIcon(for: state, hasUpdate: hasUpdate)
if let count = state.countText { if let count = state.countText {
Text(count).font(.system(size: 11, weight: .semibold)) Text(count).font(.system(size: 11, weight: .semibold))
} }
@@ -36,6 +34,29 @@ struct ShotdeckApp: App {
} }
} }
/// The menu-bar symbol for `state`, badged while an update is staged. Uses the
/// SF Symbol's own `.badge` variant when one exists; falls back to a small
/// overlaid dot on the plain symbol otherwise. The badge disappears on its own
/// once `updateAvailable` clears, since this reads live model state.
@ViewBuilder
private func menuBarIcon(for state: MenuIconState, hasUpdate: Bool) -> some View {
if hasUpdate {
let badgeName = "\(state.symbolName).badge"
if NSImage(systemSymbolName: badgeName, accessibilityDescription: nil) != nil {
Image(systemName: badgeName)
} else {
ZStack(alignment: .topTrailing) {
Image(systemName: state.symbolName)
Circle()
.frame(width: 6, height: 6)
.offset(x: 3, y: -3)
}
}
} else {
Image(systemName: state.symbolName)
}
}
@MainActor @MainActor
final class AppDelegate: NSObject, NSApplicationDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
let model: AppModel let model: AppModel
@@ -10,7 +10,6 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
case pdfCompositionFailed(reason: String) case pdfCompositionFailed(reason: String)
case airDropUnavailable case airDropUnavailable
case noCommentedReturns case noCommentedReturns
case oneDriveFolderUnavailable(path: String)
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
@@ -32,8 +31,6 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
return "AirDrop is not available right now." return "AirDrop is not available right now."
case .noCommentedReturns: case .noCommentedReturns:
return "None of the returned PDFs have comments on them." return "None of the returned PDFs have comments on them."
case .oneDriveFolderUnavailable(let path):
return "Your OneDrive folder is not available: \(path). Check that OneDrive is signed in, or choose another folder in Settings."
} }
} }
} }
@@ -10,12 +10,6 @@ public actor ReturnWatcher {
private var bridge: FSEventBridge? private var bridge: FSEventBridge?
private var pendingScanTask: Task<Void, Never>? private var pendingScanTask: Task<Void, Never>?
private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents") private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents")
/// When false, a document with zero human marks is neither recorded into the ledger
/// nor included in scanNow's/onChange's results needed for OneDrive mode, where the
/// outbox and watch folder are the same folder and a freshly written, unmarked PDF
/// must not be treated as a return. Defaults to true (today's AirDrop behaviour).
/// A document that IS commented is always recorded, regardless of this flag.
public var recordUncommented: Bool = true
/// Watch folder is `paths.watchFolder`, which production constructs from /// Watch folder is `paths.watchFolder`, which production constructs from
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings; /// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
@@ -35,12 +29,6 @@ public actor ReturnWatcher {
onChange(found) onChange(found)
} }
/// Sets `recordUncommented`. A `func` (not a plain property set) only because
/// callers outside this actor must `await` it like any other actor mutation.
public func setRecordUncommented(_ value: Bool) {
recordUncommented = value
}
/// Idempotent. Stops and releases the FSEventStream if one is running; safe to call /// Idempotent. Stops and releases the FSEventStream if one is running; safe to call
/// when never started or already stopped. Cancels any pending debounced scan. /// when never started or already stopped. Cancels any pending debounced scan.
public func stop() { public func stop() {
@@ -89,7 +77,6 @@ public actor ReturnWatcher {
guard let document = PDFDocument(url: url), guard let document = PDFDocument(url: url),
AnnotationInspector.isShotdeckDocument(document) else { continue } AnnotationInspector.isShotdeckDocument(document) else { continue }
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue } guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
if !recordUncommented, !inspected.isCommented { continue }
try await ledger.record(inspected) try await ledger.record(inspected)
results.append(inspected) results.append(inspected)
} }
@@ -1,145 +0,0 @@
import Foundation
/// The two ways a composed PDF can reach the iPad and come back marked up.
public enum SendTransport: String, Codable, Sendable, CaseIterable {
case airDrop
case oneDrive
public var displayName: String {
switch self {
case .airDrop: return "AirDrop"
case .oneDrive: return "OneDrive folder"
}
}
}
/// User-configurable transport choice plus the OneDrive folder override, backed by
/// UserDefaults the same way `FolderSettings` is. See `FolderSettings` for why a plain
/// path (not a security-scoped bookmark) is correct for this unsandboxed app.
public enum TransportSettings {
public static let transportDefaultsKey = "ai.flowmaster.shotdeck.transport"
public static let oneDriveFolderDefaultsKey = "ai.flowmaster.shotdeck.oneDriveFolder"
/// Defaults to `.airDrop` when unset or when the stored value cannot be parsed.
public static func transport(defaults: UserDefaults = .standard) -> SendTransport {
guard let raw = defaults.string(forKey: transportDefaultsKey),
let value = SendTransport(rawValue: raw)
else { return .airDrop }
return value
}
public static func setTransport(_ value: SendTransport, defaults: UserDefaults = .standard) {
defaults.set(value.rawValue, forKey: transportDefaultsKey)
}
/// Raw stored path (or nil if never set / cleared). Does NOT validate that the
/// directory still exists.
public static func storedOneDriveFolderPath(defaults: UserDefaults = .standard) -> String? {
defaults.string(forKey: oneDriveFolderDefaultsKey)
}
public static func setOneDriveFolder(_ url: URL, defaults: UserDefaults = .standard) {
defaults.set(url.path, forKey: oneDriveFolderDefaultsKey)
}
public static func resetOneDriveFolder(defaults: UserDefaults = .standard) {
defaults.removeObject(forKey: oneDriveFolderDefaultsKey)
}
/// The outbox/watch folders Redline should actually use right now, for the current
/// transport. AirDrop mode delegates to `FolderSettings.resolve()` unchanged.
/// OneDrive mode uses the SAME folder for both outbox and watch see
/// `OneDriveLocator.resolveOneDriveFolder`. When no OneDrive folder can be resolved
/// at all (no sync root, no override), this falls back to the AirDrop folders so the
/// app always has somewhere to write; `send(anchor:)` performs its own live
/// existence check before ever composing into a OneDrive send, so that fallback is
/// never mistaken for a valid OneDrive destination.
public static func effectiveFolders(
defaults: UserDefaults = .standard,
fileManager: FileManager = .default
) -> (outbox: URL, watch: URL, transport: SendTransport) {
let transport = transport(defaults: defaults)
switch transport {
case .airDrop:
let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager)
return (folders.outbox, folders.watch, transport)
case .oneDrive:
if let folder = OneDriveLocator.resolveOneDriveFolder(
defaults: defaults,
home: fileManager.homeDirectoryForCurrentUser,
fileManager: fileManager
) {
return (folder, folder, transport)
}
let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager)
return (folders.outbox, folders.watch, transport)
}
}
}
/// Pure path logic for locating a OneDrive sync root under
/// `~/Library/CloudStorage` and the Redline folder inside it. No side effects never
/// creates a directory. Fully unit-testable with a fake home tree.
public enum OneDriveLocator {
/// Every directory directly under `<home>/Library/CloudStorage` whose name starts
/// with "OneDrive-", sorted so a name containing "MMD" (case-insensitive) sorts
/// first, then alphabetically. Empty when CloudStorage does not exist.
public static func syncRoots(
home: URL = FileManager.default.homeDirectoryForCurrentUser,
fileManager: FileManager = .default
) -> [URL] {
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: cloudStorage.path, isDirectory: &isDirectory),
isDirectory.boolValue
else { return [] }
let items = (try? fileManager.contentsOfDirectory(
at: cloudStorage,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)) ?? []
let roots = items.filter { url in
guard url.lastPathComponent.hasPrefix("OneDrive-") else { return false }
var itemIsDirectory: ObjCBool = false
let exists = fileManager.fileExists(atPath: url.path, isDirectory: &itemIsDirectory)
return exists && itemIsDirectory.boolValue
}
return roots.sorted { a, b in
let aName = a.lastPathComponent
let bName = b.lastPathComponent
let aIsMMD = aName.localizedCaseInsensitiveContains("MMD")
let bIsMMD = bName.localizedCaseInsensitiveContains("MMD")
if aIsMMD != bIsMMD { return aIsMMD }
return aName.localizedStandardCompare(bName) == .orderedAscending
}
}
/// First sync root's "Redline" subfolder, or nil when there is no sync root at all.
public static func defaultRedlineFolder(
home: URL = FileManager.default.homeDirectoryForCurrentUser,
fileManager: FileManager = .default
) -> URL? {
guard let first = syncRoots(home: home, fileManager: fileManager).first else { return nil }
return first.appendingPathComponent("Redline", isDirectory: true)
}
/// The stored override when it is set AND still exists as a directory; otherwise
/// `defaultRedlineFolder`. Never creates anything.
public static func resolveOneDriveFolder(
defaults: UserDefaults = .standard,
home: URL = FileManager.default.homeDirectoryForCurrentUser,
fileManager: FileManager = .default
) -> URL? {
if let storedPath = TransportSettings.storedOneDriveFolderPath(defaults: defaults) {
var isDirectory: ObjCBool = false
let exists = fileManager.fileExists(atPath: storedPath, isDirectory: &isDirectory)
if exists, isDirectory.boolValue {
return URL(fileURLWithPath: storedPath, isDirectory: true)
}
}
return defaultRedlineFolder(home: home, fileManager: fileManager)
}
}
@@ -1,125 +0,0 @@
import Foundation
import Testing
import ShotdeckCore
@Test
func syncRootsFindsOneDriveDirsMMDFirstIgnoresNonDirsAndOtherProviders() throws {
let home = try makeFakeHome()
defer { try? FileManager.default.removeItem(at: home) }
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
try FileManager.default.createDirectory(at: cloudStorage, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: cloudStorage.appendingPathComponent("OneDrive-Flowmaster", isDirectory: true),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: cloudStorage.appendingPathComponent("GoogleDrive-x", isDirectory: true),
withIntermediateDirectories: true
)
// A plain FILE (not a directory) named like a OneDrive root must be ignored.
FileManager.default.createFile(
atPath: cloudStorage.appendingPathComponent("OneDrive-notadir").path,
contents: Data("not a directory".utf8)
)
let roots = OneDriveLocator.syncRoots(home: home, fileManager: .default)
#expect(roots.map(\.lastPathComponent) == ["OneDrive-MMDGROUP", "OneDrive-Flowmaster"])
}
@Test
func syncRootsEmptyAndDefaultFolderNilWithNoCloudStorageDirectory() throws {
let home = try makeFakeHome()
defer { try? FileManager.default.removeItem(at: home) }
// No Library/CloudStorage created at all.
let roots = OneDriveLocator.syncRoots(home: home, fileManager: .default)
#expect(roots.isEmpty)
let defaultFolder = OneDriveLocator.defaultRedlineFolder(home: home, fileManager: .default)
#expect(defaultFolder == nil)
}
@Test
func defaultRedlineFolderIsFirstSyncRootPlusRedline() throws {
let home = try makeFakeHome()
defer { try? FileManager.default.removeItem(at: home) }
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
try FileManager.default.createDirectory(
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: cloudStorage.appendingPathComponent("OneDrive-Flowmaster", isDirectory: true),
withIntermediateDirectories: true
)
let defaultFolder = try #require(
OneDriveLocator.defaultRedlineFolder(home: home, fileManager: .default)
)
// Derive "expected" from syncRoots() itself (already covered by its own dedicated
// test) rather than hand-building the path string FileManager's directory
// enumeration can canonicalize /var -> /private/var and the two constructions
// otherwise disagree on that even for a URL that already exists.
let expectedRoot = try #require(OneDriveLocator.syncRoots(home: home, fileManager: .default).first)
let expected = expectedRoot.appendingPathComponent("Redline", isDirectory: true)
#expect(defaultFolder.path == expected.path)
}
@Test
func resolveOneDriveFolderPrefersAnExistingStoredOverride() throws {
let home = try makeFakeHome()
defer { try? FileManager.default.removeItem(at: home) }
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
try FileManager.default.createDirectory(
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
withIntermediateDirectories: true
)
let suite = try makeTransportDefaultsSuite()
defer { tearDownTransportSuite(suite) }
let override = try makeTransportTemporaryDirectory(prefix: "shotdeck-onedrive-override")
defer { try? FileManager.default.removeItem(at: override) }
TransportSettings.setOneDriveFolder(override, defaults: suite.defaults)
let resolved = OneDriveLocator.resolveOneDriveFolder(
defaults: suite.defaults, home: home, fileManager: .default
)
#expect(resolved?.path == override.path)
}
@Test
func resolveOneDriveFolderIgnoresAStoredPathThatNoLongerExists() throws {
let home = try makeFakeHome()
defer { try? FileManager.default.removeItem(at: home) }
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
try FileManager.default.createDirectory(
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
withIntermediateDirectories: true
)
let suite = try makeTransportDefaultsSuite()
defer { tearDownTransportSuite(suite) }
let goneOverride = try makeTransportTemporaryDirectory(prefix: "shotdeck-onedrive-gone")
TransportSettings.setOneDriveFolder(goneOverride, defaults: suite.defaults)
try FileManager.default.removeItem(at: goneOverride)
let resolved = OneDriveLocator.resolveOneDriveFolder(
defaults: suite.defaults, home: home, fileManager: .default
)
let expectedRoot = try #require(OneDriveLocator.syncRoots(home: home, fileManager: .default).first)
let expected = expectedRoot.appendingPathComponent("Redline", isDirectory: true)
#expect(resolved?.path == expected.path)
}
private func makeFakeHome() throws -> URL {
let home = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-fake-home-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
return home
}
@@ -230,55 +230,3 @@ func w27_fsEventsCallbackFiresOnRealArrival() async throws {
await watcher.stop() await watcher.stop()
} }
} }
@Test("recordUncommended defaults to true: an unmarked PDF is still recorded (AirDrop behaviour unchanged)")
func recordUncommendedDefaultTrueRecordsAnUnmarkedPDF() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let ledger = try ReturnLedger(paths: paths)
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260905-090000.pdf")
try makePDF(at: pdfURL, pageCount: 1, creator: "Redline", annotations: [])
let found = try await watcher.scanNow()
#expect(found.count == 1)
#expect(found.first?.isCommented == false)
let all = try await ledger.all()
#expect(all.count == 1)
}
@Test("recordUncommended=false: an unmarked PDF is not recorded or returned; marking it up in place gets it recorded")
func recordUncommendedFalseSkipsUnmarkedThenRecordsAfterInPlaceMarkup() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let ledger = try ReturnLedger(paths: paths)
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
await watcher.setRecordUncommented(false)
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260905-091500.pdf")
// OneDrive mode: the PDF is freshly written here (by "send"), unmarked so far.
try makePDF(at: pdfURL, pageCount: 1, creator: "Redline", annotations: [])
let beforeMarkup = try await watcher.scanNow()
#expect(beforeMarkup.isEmpty)
let allBefore = try await ledger.all()
#expect(allBefore.isEmpty)
// What the iPad does: mark it up in place, in the SAME folder, then save.
let document = try #require(PDFDocument(url: pdfURL))
let page = try #require(document.page(at: 0))
page.addAnnotation(makeAnnotation(.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)))
#expect(document.write(to: pdfURL))
let afterMarkup = try await watcher.scanNow()
#expect(afterMarkup.count == 1)
#expect(afterMarkup.first?.isCommented == true)
let commented = try await ledger.commented()
#expect(commented.count == 1)
#expect(commented.first?.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
}
@@ -1,114 +0,0 @@
import Foundation
import Testing
import ShotdeckCore
@Test
func transportDefaultsToAirDropWhenUnset() throws {
let suite = try makeTransportDefaultsSuite()
defer { tearDownTransportSuite(suite) }
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
}
@Test
func setTransportRoundTrips() throws {
let suite = try makeTransportDefaultsSuite()
defer { tearDownTransportSuite(suite) }
TransportSettings.setTransport(.oneDrive, defaults: suite.defaults)
#expect(TransportSettings.transport(defaults: suite.defaults) == .oneDrive)
TransportSettings.setTransport(.airDrop, defaults: suite.defaults)
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
}
@Test
func garbageStoredTransportFallsBackToAirDrop() throws {
let suite = try makeTransportDefaultsSuite()
defer { tearDownTransportSuite(suite) }
suite.defaults.set("not-a-real-transport", forKey: TransportSettings.transportDefaultsKey)
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
}
@Test
func oneDriveFolderStoreAndReset() throws {
let suite = try makeTransportDefaultsSuite()
defer { tearDownTransportSuite(suite) }
let folder = try makeTransportTemporaryDirectory(prefix: "shotdeck-onedrive-folder")
defer { try? FileManager.default.removeItem(at: folder) }
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == nil)
TransportSettings.setOneDriveFolder(folder, defaults: suite.defaults)
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == folder.path)
TransportSettings.resetOneDriveFolder(defaults: suite.defaults)
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == nil)
}
@Test
func effectiveFoldersForAirDropMatchesFolderSettings() throws {
let suite = try makeTransportDefaultsSuite()
defer { tearDownTransportSuite(suite) }
let outbox = try makeTransportTemporaryDirectory(prefix: "shotdeck-effective-outbox")
defer { try? FileManager.default.removeItem(at: outbox) }
FolderSettings.setOutbox(outbox, defaults: suite.defaults)
let effective = TransportSettings.effectiveFolders(defaults: suite.defaults)
let expected = FolderSettings.resolve(defaults: suite.defaults)
#expect(effective.transport == .airDrop)
#expect(effective.outbox.path == expected.outbox.path)
#expect(effective.watch.path == expected.watch.path)
}
@Test
func effectiveFoldersForOneDriveWithAResolvableFolderUsesItForBoth() throws {
let suite = try makeTransportDefaultsSuite()
defer { tearDownTransportSuite(suite) }
let folder = try makeTransportTemporaryDirectory(prefix: "shotdeck-effective-onedrive")
defer { try? FileManager.default.removeItem(at: folder) }
TransportSettings.setTransport(.oneDrive, defaults: suite.defaults)
TransportSettings.setOneDriveFolder(folder, defaults: suite.defaults)
let effective = TransportSettings.effectiveFolders(defaults: suite.defaults)
#expect(effective.transport == .oneDrive)
#expect(effective.outbox.path == folder.path)
#expect(effective.watch.path == folder.path)
#expect(effective.outbox.path == effective.watch.path)
}
@Test
func oneDriveFolderUnavailableErrorDescriptionContainsThePath() throws {
let path = "/Users/example/Library/CloudStorage/OneDrive-Example/Redline"
let error = ShotdeckError.oneDriveFolderUnavailable(path: path)
let description = try #require(error.errorDescription)
#expect(!description.isEmpty)
#expect(description.contains(path))
}
struct TransportDefaultsSuite {
let name: String
let defaults: UserDefaults
}
func makeTransportDefaultsSuite() throws -> TransportDefaultsSuite {
let name = "shotdeck-transport-test-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: name))
defaults.removePersistentDomain(forName: name)
return TransportDefaultsSuite(name: name, defaults: defaults)
}
func tearDownTransportSuite(_ suite: TransportDefaultsSuite) {
suite.defaults.removePersistentDomain(forName: suite.name)
}
func makeTransportTemporaryDirectory(prefix: String) throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
+191 -9
View File
@@ -13,7 +13,10 @@ PLISTBUDDY="/usr/libexec/PlistBuddy"
REMOTE_HOST="mmd01" REMOTE_HOST="mmd01"
REMOTE_BASE="/opt/mmd-installer-content/cowork/redline" REMOTE_BASE="/opt/mmd-installer-content/cowork/redline"
PUBLIC_BASE="https://get.baobab-ts.com/cowork/redline" PUBLIC_BASE="https://get.baobab-ts.com/cowork/redline"
SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" BUNDLE_ID="ai.flowmaster.shotdeck"
# Used both as the fallback signing identity and as what build-app.sh itself
# still hardcodes for its own (pre-final) signing pass.
FALLBACK_SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
usage() { usage() {
echo "Usage: $0 <version> [\"notes\"]" >&2 echo "Usage: $0 <version> [\"notes\"]" >&2
@@ -72,6 +75,7 @@ else
PUBLIC_DIR="${PUBLIC_BASE}" PUBLIC_DIR="${PUBLIC_BASE}"
fi fi
APP_BUNDLE="${ROOT}/.build/Redline.app"
ZIP_NAME="Redline-${VERSION}.zip" ZIP_NAME="Redline-${VERSION}.zip"
DMG_NAME="Redline-${VERSION}.dmg" DMG_NAME="Redline-${VERSION}.dmg"
ZIP_PATH="${ROOT}/.build/${ZIP_NAME}" ZIP_PATH="${ROOT}/.build/${ZIP_NAME}"
@@ -139,6 +143,38 @@ else
echo "==> --test: skipping git commit of version bump" echo "==> --test: skipping git commit of version bump"
fi fi
# --- Resolve the signing identity for the shipped artifacts -----------------
# REDLINE_KEYCHAIN (optional): a specific keychain to search/sign against,
# for hosts where the Developer ID identity does not live in the login
# keychain that codesign searches by default.
FIND_IDENTITY_ARGS=(-v -p codesigning)
CODESIGN_KEYCHAIN_ARGS=()
if [[ -n "${REDLINE_KEYCHAIN:-}" ]]; then
FIND_IDENTITY_ARGS+=("${REDLINE_KEYCHAIN}")
CODESIGN_KEYCHAIN_ARGS=(--keychain "${REDLINE_KEYCHAIN}")
fi
if [[ -n "${REDLINE_SIGN_IDENTITY:-}" ]]; then
SIGN_IDENTITY="${REDLINE_SIGN_IDENTITY}"
echo "==> Signing identity: ${SIGN_IDENTITY} (REDLINE_SIGN_IDENTITY)"
else
DEVELOPER_ID_LINE="$(security find-identity "${FIND_IDENTITY_ARGS[@]}" 2>/dev/null \
| grep -o '"Developer ID Application:[^"]*"' | head -n1 || true)"
DEVELOPER_ID="${DEVELOPER_ID_LINE//\"/}"
if [[ -n "${DEVELOPER_ID}" ]]; then
SIGN_IDENTITY="${DEVELOPER_ID}"
echo "==> Signing identity: ${SIGN_IDENTITY} (auto-detected Developer ID Application)"
else
SIGN_IDENTITY="${FALLBACK_SIGN_IDENTITY}"
echo
echo "************************************************************************"
echo "WARNING: signing with Apple Development identity — not Developer ID;"
echo "Gatekeeper will block first install on other Macs."
echo "************************************************************************"
echo
fi
fi
# Restricted HOMEs (agent sandboxes) hide the login keychain from codesign. # Restricted HOMEs (agent sandboxes) hide the login keychain from codesign.
# Re-run signed steps with the account's real home when the identity is missing. # Re-run signed steps with the account's real home when the identity is missing.
signing_home() { signing_home() {
@@ -167,31 +203,85 @@ run_signed() {
echo "==> Building signed Redline.app" echo "==> Building signed Redline.app"
run_signed ./scripts/build-app.sh run_signed ./scripts/build-app.sh
if [[ ! -d "${ROOT}/.build/Redline.app" ]]; then if [[ ! -d "${APP_BUNDLE}" ]]; then
echo "Signed app missing at ${ROOT}/.build/Redline.app" >&2 echo "Signed app missing at ${APP_BUNDLE}" >&2
exit 1 exit 1
fi fi
echo "==> Zipping Redline.app -> ${ZIP_PATH}" # build-app.sh always signs with its own hardcoded Apple Development identity
# first (it has to — that identifier+identity pair is what keeps the Screen
# Recording grant alive). Re-sign here with the identity actually resolved
# above, which is what ships. A no-op when the two happen to be the same.
echo "==> Signing ${APP_BUNDLE} with resolved identity"
run_signed codesign --force --options runtime --timestamp \
"${CODESIGN_KEYCHAIN_ARGS[@]}" \
--sign "${SIGN_IDENTITY}" \
--identifier "${BUNDLE_ID}" \
"${APP_BUNDLE}"
build_zip() {
mkdir -p "${ROOT}/.build" mkdir -p "${ROOT}/.build"
( (
cd "${ROOT}/.build" cd "${ROOT}/.build"
rm -f "${ZIP_NAME}" rm -f "${ZIP_NAME}"
ditto -c -k --keepParent Redline.app "${ZIP_NAME}" ditto -c -k --keepParent Redline.app "${ZIP_NAME}"
) )
if [[ ! -s "${ZIP_PATH}" ]]; then if [[ ! -s "${ZIP_PATH}" ]]; then
echo "Zip was not created at ${ZIP_PATH}" >&2 echo "Zip was not created at ${ZIP_PATH}" >&2
exit 1 exit 1
fi fi
}
echo "==> Building manual installer DMG" # Rebuilds the manual-installer DMG from whatever is currently at
run_signed ./scripts/make-dmg.sh # ${APP_BUNDLE} — never re-invokes build-app.sh, so a prior custom signature
# or notarization staple on ${APP_BUNDLE} survives into the DMG untouched.
build_dmg() {
local staging="${ROOT}/.build/dmg-staging"
local mount_point="${ROOT}/.build/dmg-mnt"
rm -rf "${staging}"
mkdir -p "${staging}"
ditto "${APP_BUNDLE}" "${staging}/Redline.app"
ln -s /Applications "${staging}/Applications"
mkdir -p "$(dirname "${DMG_PATH}")"
rm -f "${DMG_PATH}"
hdiutil create -volname "Redline" -srcfolder "${staging}" -ov -format UDZO "${DMG_PATH}"
if [[ -d "${mount_point}" ]] && /sbin/mount | grep -F -q "${mount_point}"; then
hdiutil detach "${mount_point}" || hdiutil detach "${mount_point}" -force
fi
rm -rf "${mount_point}"
mkdir -p "${mount_point}"
hdiutil attach "${DMG_PATH}" -nobrowse -readonly -mountpoint "${mount_point}"
local ok=1
if [[ ! -d "${mount_point}/Redline.app" ]]; then
echo "Verification failed: Redline.app missing from mounted DMG" >&2
ok=0
fi
if [[ "${ok}" -eq 1 && "$(readlink "${mount_point}/Applications" 2>/dev/null || true)" != "/Applications" ]]; then
echo "Verification failed: Applications does not point at /Applications" >&2
ok=0
fi
if [[ "${ok}" -eq 1 ]] && ! codesign --verify --deep --verbose=2 "${mount_point}/Redline.app"; then
ok=0
fi
hdiutil detach "${mount_point}" || hdiutil detach "${mount_point}" -force || true
if [[ "${ok}" -ne 1 ]]; then
exit 1
fi
if [[ ! -s "${DMG_PATH}" ]]; then if [[ ! -s "${DMG_PATH}" ]]; then
echo "DMG was not created at ${DMG_PATH}" >&2 echo "DMG was not created at ${DMG_PATH}" >&2
exit 1 exit 1
fi fi
}
echo "==> Zipping Redline.app -> ${ZIP_PATH}"
build_zip
SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')" SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")" ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")"
@@ -200,18 +290,102 @@ PUBDATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "==> Zip SHA256: ${SHA256}" echo "==> Zip SHA256: ${SHA256}"
echo " Zip bytes: ${ZIP_BYTES}" echo " Zip bytes: ${ZIP_BYTES}"
# --- Notarization (optional) -------------------------------------------------
# Either REDLINE_NOTARY_PROFILE (a `notarytool store-credentials` keychain
# profile) or all three of REDLINE_NOTARY_KEY_ID / REDLINE_NOTARY_ISSUER /
# REDLINE_NOTARY_KEY_PATH (App Store Connect API key). Absent both: skip.
NOTARIZED=0
NOTARY_CONFIGURED=0
if [[ -n "${REDLINE_NOTARY_PROFILE:-}" ]]; then
NOTARY_CONFIGURED=1
elif [[ -n "${REDLINE_NOTARY_KEY_ID:-}" && -n "${REDLINE_NOTARY_ISSUER:-}" && -n "${REDLINE_NOTARY_KEY_PATH:-}" ]]; then
NOTARY_CONFIGURED=1
fi
if [[ "${NOTARY_CONFIGURED}" -eq 1 ]]; then
echo "==> Submitting ${ZIP_PATH} to notarytool"
NOTARY_ARGS=(xcrun notarytool submit "${ZIP_PATH}" --wait --timeout 30m)
if [[ -n "${REDLINE_NOTARY_PROFILE:-}" ]]; then
NOTARY_ARGS+=(--keychain-profile "${REDLINE_NOTARY_PROFILE}")
else
NOTARY_ARGS+=(
--key "${REDLINE_NOTARY_KEY_PATH}"
--key-id "${REDLINE_NOTARY_KEY_ID}"
--issuer "${REDLINE_NOTARY_ISSUER}"
)
fi
set +e
NOTARY_OUTPUT="$("${NOTARY_ARGS[@]}" 2>&1)"
NOTARY_STATUS=$?
set -e
echo "${NOTARY_OUTPUT}"
if [[ "${NOTARY_STATUS}" -ne 0 ]]; then
echo "notarytool submit failed (exit ${NOTARY_STATUS})." >&2
exit 1
fi
if ! grep -qi 'status: *Accepted' <<<"${NOTARY_OUTPUT}"; then
echo "notarytool did not report Accepted." >&2
exit 1
fi
NOTARIZED=1
echo "==> Stapling ${APP_BUNDLE}"
xcrun stapler staple "${APP_BUNDLE}"
echo "==> Rebuilding zip from the stapled app"
build_zip
SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")"
echo " Zip SHA256: ${SHA256}"
echo " Zip bytes: ${ZIP_BYTES}"
echo "==> Rebuilding DMG from the stapled app"
build_dmg
echo "==> Stapling ${DMG_PATH}"
xcrun stapler staple "${DMG_PATH}"
else
echo "==> REDLINE_NOTARY_KEY_ID/ISSUER/KEY_PATH (or REDLINE_NOTARY_PROFILE) not set"
echo "NOT NOTARIZED"
echo "==> Building manual installer DMG"
build_dmg
fi
echo "==> Gatekeeper check: spctl -a -vv -t exec ${APP_BUNDLE}"
set +e
SPCTL_OUTPUT="$(spctl -a -vv -t exec "${APP_BUNDLE}" 2>&1)"
SPCTL_STATUS=$?
set -e
echo "${SPCTL_OUTPUT}"
if [[ "${SPCTL_STATUS}" -ne 0 ]] || ! grep -qi 'accepted' <<<"${SPCTL_OUTPUT}"; then
if [[ "${NOTARIZED}" -eq 1 ]]; then
echo "spctl did not report accepted for ${APP_BUNDLE} although it was notarized." >&2
exit 1
fi
echo "WARNING: Gatekeeper does not accept this build (not notarized). First install on other Macs needs right-click > Open." >&2
fi
TEAM_IDENTIFIER="$(codesign -dv "${APP_BUNDLE}" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2}')"
if [[ -z "${TEAM_IDENTIFIER}" ]]; then
echo "No TeamIdentifier on ${APP_BUNDLE} — the build is not signed with a team identity; refusing to publish." >&2
exit 1
fi
echo "==> Writing ${APPCAST_PATH}" echo "==> Writing ${APPCAST_PATH}"
python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY' python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" "${NOTARIZED}" "${TEAM_IDENTIFIER}" <<'PY'
import json import json
import sys import sys
version, zip_url, sha256, notes, pub_date, out_path = sys.argv[1:] version, zip_url, sha256, notes, pub_date, out_path, notarized, team_identifier = sys.argv[1:]
payload = { payload = {
"version": version, "version": version,
"zipURL": zip_url, "zipURL": zip_url,
"sha256": sha256, "sha256": sha256,
"notes": notes, "notes": notes,
"pubDate": pub_date, "pubDate": pub_date,
"notarized": notarized == "1",
"teamIdentifier": team_identifier,
} }
with open(out_path, "w", encoding="utf-8") as fh: with open(out_path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2) json.dump(payload, fh, indent=2)
@@ -280,6 +454,14 @@ fi
echo echo
echo "Published v${VERSION}" echo "Published v${VERSION}"
echo " identity: ${SIGN_IDENTITY}"
if [[ "${NOTARIZED}" -eq 1 ]]; then
echo " notarized: yes"
else
echo " notarized: no"
fi
echo " spctl: ${SPCTL_OUTPUT}"
echo " team: ${TEAM_IDENTIFIER}"
echo " appcast: ${APPCAST_URL}" echo " appcast: ${APPCAST_URL}"
echo " zip: ${ZIP_URL}" echo " zip: ${ZIP_URL}"
echo " sha256: ${SHA256}" echo " sha256: ${SHA256}"