Compare commits

...
Author SHA1 Message Date
kua-agent 2a3af20d04 Merge pull request 'release: Redline 0.3.2 (MMDB-2697)' (#28) from release/0.3.2-20260905 into main 2026-09-05 07:17:58 +00:00
Claude Fable 5 ae7fb8ee87 release: v0.3.2 2026-09-05 11:16:28 +04:00
kua-agent 821e113f34 Merge pull request 'Redline: timestamped result for the manual update check, update-state renders, and a duplicated-status fix (MMDB-2697)' (#27) from feat/check-updates-feedback-20260905 into main 2026-09-05 07:15:15 +00:00
Claude Fable 5 942e848dde test(update-checker): rewrite tests to exercise real production code
REPLACED: three worthless tests that only tested test code, not production:
- statusMessageUpToDateFormat built the expected string locally and matched it
- statusMessageUpdateReadyFormat same self-referential test
- statusChannelsAreIndependent was literally #expect(true, ...)

ADDED: four real tests that drive production code:
- dubaiTimeCheckTimeFormat: assert DubaiTime.checkTime() formats as HH:MM Dubai
- manualCheckUpToDateIncludesTimestamp: inject stub appcast via testAppcastJSON seam,
  drive UpdateChecker.checkNow(manual: true), verify statusMessage matches exact format
- automaticCheckUpToDateLeavesMessageNil: verify automatic check (manual: false) leaves
  statusMessage nil when up-to-date
- statusChannelsAreIndependent: construct real AppModel via AppDelegate.makeLaunchModel(),
  assert setStatus() does NOT affect updateStatusMessage, setUpdateStatus() does NOT
  affect statusLine, and vice versa. PROVES the defect is caught: test fails with 3 issues
  if updateStatusMessage is reverted to an alias of statusLine.

ADDED: testAppcastJSON seam to UpdateChecker for test injection of appcast data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:13:55 +04:00
Claude Fable 5 905a019823 fix(app-model): separate status channels — update messages no longer hide capture summary
Root cause: updateStatusMessage was an alias to statusLine, so update feedback appeared
both in the header (where capture summary belongs) and footer (intended). Both MenuBarView
header and footer rendered the same value, creating duplication and information loss.

Fix: introduce updateStatus property separate from statusLine. Route UpdateChecker.statusMessage
into setUpdateStatus(), not setStatus(). Header now shows capture summary uninterrupted;
footer-only shows update feedback. Both channels now independent.

- Add public updateStatus property to AppModel
- Add setUpdateStatus() mutator
- Change updateStatusMessage property to return updateStatus instead of statusLine
- Route onChecked callback to setUpdateStatus, not setStatus
- Update PanelSnapshot helper to use setUpdateStatus
- Add test asserting channel independence

Panel-12 and panel-13 now render correctly: capture summary in header, update status only
in footer; no duplication or information loss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:05:14 +04:00
Claude Fable 5 e71c2e7c91 feat(panel-snapshot): add five offscreen panels for update states
Render panels 10-14 showing the update check states:
- panel-10-update-idle: menu with 3 captures, no update available
- panel-11-update-checking: same as 10, but row reads "Checking..."
- panel-12-update-uptodate: footer status shows "Redline X.Y.Z is up to date, checked 10:42 Dubai"
- panel-13-update-staged: row "Update to 9.9.9" present, footer status "Update to 9.9.9 is ready"
- panel-14-update-revert: row "Revert to 0.2.0" present

All five panels driven by model state only; never touch real appcast or UserDefaults.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:01:28 +04:00
Claude Fable 5 169dd2b32c test(update-checker): verify status message formats for manual checks
- Test DubaiTime.checkTime formats as HH:MM Dubai
- Test "Redline X.Y.Z is up to date, checked HH:MM Dubai" format
- Test "Update to X.Y.Z is ready" format

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:01:23 +04:00
Claude Fable 5 7cdc3e7652 feat(update-checker): give manual checks visible, timestamped feedback
- Manual check finds no newer version: status message becomes "Redline X.Y.Z is up to date, checked HH:MM Dubai"
- Manual check stages a newer version: status message becomes "Update to X.Y.Z is ready"
- Automatic (scheduled) checks keep original silent behaviour when up to date
- Add snapshot-only seams for testing: snapshotPreviousVersionOverride and snapshotUsesPreviousVersionOverride

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:01:20 +04:00
Claude Fable 5 a0ff61ae14 feat(dubai-time): add checkTime formatter for HH:MM Dubai timestamps
Manual update checks now display the time checked in Dubai timezone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:01:15 +04:00
kua-agent c3cae010a4 Merge pull request 'release: Redline 0.3.1 (MMDB-2631 + MMDB-2681 on one feed)' (#26) from release/0.3.1-20260905 into main 2026-09-05 06:48:04 +00:00
Claude Fable 5 33e3441580 release: v0.3.1 2026-09-05 10:42:44 +04:00
kua-agent eb1af5bf18 Merge pull request 'Redline: OneDrive folder transport as alternative to AirDrop (MMDB-2631)' (#23) from feat/redline-onedrive-transport-20260905 into main
Merge pull request #23: Redline: OneDrive folder transport as alternative to AirDrop (MMDB-2631)
2026-09-05 06:17:29 +00:00
kua-agent 4da02e243f Merge pull request 'release: Redline 0.3.0 (MMDB-2681)' (#25) from release/0.3.0-20260905 into main
Merge pull request #25: release Redline 0.3.0 (MMDB-2681)
2026-09-05 06:08:59 +00:00
Claude Fable 5 8b53a727e3 release: v0.3.0 2026-09-05 10:07:53 +04:00
Claude Fable 5 fdec105174 release: empty keychain-args array must not trip set -u on macOS bash 3.2
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:07:52 +04:00
kua-agent 12128b016d Merge pull request 'Redline updater hardening: signed-update verification, atomic install + revert, update visibility, notarization pipeline (MMDB-2681)' (#24) from feat/updater-hardening-20260905 into main
Merge pull request #24: Redline updater hardening (MMDB-2681)
2026-09-05 06:07:05 +00:00
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
10 changed files with 865 additions and 92 deletions
+2 -2
View File
@@ -13,9 +13,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.3.0</string>
<string>0.3.2</string>
<key>CFBundleVersion</key>
<string>3</string>
<string>5</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
+28 -3
View File
@@ -28,6 +28,7 @@ public final class AppModel {
public private(set) var allReturns: [ReturnedDocument] = []
public private(set) var commentedReturns: [ReturnedDocument] = []
public private(set) var statusLine: String?
public private(set) var updateStatus: String?
public private(set) var isCapturing: Bool = false
public private(set) var isSending: Bool = false
public private(set) var outboxDisplayName: String
@@ -51,6 +52,8 @@ public final class AppModel {
var hotkeyDisplayString: String { captureHotkey.displayString }
/// Staged update offered in the menu. Set only after checksum + payload validation.
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 spool: SpoolStore
@@ -104,15 +107,24 @@ public final class AppModel {
self.updateChecker.onChecked = { [weak self] in
guard let self else { return }
self.updateAvailable = self.updateChecker.availableUpdate
if let message = self.updateChecker.statusMessage {
self.setStatus(message)
}
self.setUpdateStatus(self.updateChecker.statusMessage)
}
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() }
/// Update-related status text (checked time, staged update, errors). Displayed only in the footer.
public var updateStatusMessage: String? { updateStatus }
// MARK: Seam mutators the only way a WP-4b/4c extension changes state.
func setStatus(_ text: String?) { statusLine = text }
func setUpdateStatus(_ text: String?) { updateStatus = text }
func setSending(_ value: Bool) { isSending = value }
func setCapturing(_ value: Bool) { isCapturing = value }
func replaceSession(_ new: CaptureSession) { session = new }
@@ -282,6 +294,19 @@ public final class AppModel {
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
/// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working.
func reRegisterHotkey() {
+31
View File
@@ -15,6 +15,8 @@ struct MenuBarView: View {
Divider()
returnsBlock
}
Divider()
updateFooter
}
.padding(10)
.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 {
let anchor = NSApp.keyWindow?.contentView
if let sender = model as? SendCapable {
@@ -193,4 +210,18 @@ struct MenuBarView: View {
private var newestReturns: [ReturnedDocument] {
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)
}
}
}
}
+65
View File
@@ -155,6 +155,37 @@ enum PanelSnapshot {
model.replaceRegion(sampleRegion())
try await addSampleCaptures(to: model)
try renderMenuBar(model: model, to: directory, name: "09-captures-present-onedrive")
// 10 update idle: 3 captures, no update available, footer "Redline <ver>", row "Check for updates".
let (updateModel, updateRoot) = try makeIsolatedModel()
defer { try? FileManager.default.removeItem(at: updateRoot) }
updateModel.snapshotSetScreenRecordingGranted(true)
updateModel.replaceRegion(sampleRegion())
try await addSampleCaptures(to: updateModel)
// No update set, updateChecker in idle state, no previous version
updateModel.snapshotSetPreviousVersion(nil)
try renderMenuBar(model: updateModel, to: directory, name: "10-update-idle")
// 11 update checking: same as 10 but isCheckingForUpdates = true.
updateModel.snapshotSetIsCheckingForUpdates(true)
try renderMenuBar(model: updateModel, to: directory, name: "11-update-checking")
updateModel.snapshotSetIsCheckingForUpdates(false)
// 12 update up-to-date: footer status line reads "Redline <ver> is up to date, checked 10:42 Dubai".
let upToDateMessage = "Redline \(updateModel.appVersion) is up to date, checked 10:42 Dubai"
updateModel.snapshotSetUpdateStatusMessage(upToDateMessage)
try renderMenuBar(model: updateModel, to: directory, name: "12-update-uptodate")
// 13 update staged: row "Update to 9.9.9" present, footer status "Update to 9.9.9 is ready".
updateModel.snapshotSetUpdateAvailable(version: "9.9.9", notes: "Test release")
updateModel.snapshotSetUpdateStatusMessage("Update to 9.9.9 is ready")
try renderMenuBar(model: updateModel, to: directory, name: "13-update-staged")
// 14 update revert: row "Revert to 0.2.0" present.
updateModel.snapshotSetUpdateAvailable(version: nil, notes: nil) // Clear the staged update
updateModel.snapshotSetUpdateStatusMessage(nil)
updateModel.snapshotSetPreviousVersion("0.2.0")
try renderMenuBar(model: updateModel, to: directory, name: "14-update-revert")
}
@MainActor
@@ -360,6 +391,40 @@ extension AppModel {
)
self[keyPath: writable] = granted
}
/// Snapshot-only: set isCheckingForUpdates without triggering a real check.
func snapshotSetIsCheckingForUpdates(_ checking: Bool) {
let writable: ReferenceWritableKeyPath<AppModel, Bool> = unsafeBitCast(
\AppModel.isCheckingForUpdates, to: ReferenceWritableKeyPath<AppModel, Bool>.self
)
self[keyPath: writable] = checking
}
/// Snapshot-only: set updateStatusMessage for panel display.
func snapshotSetUpdateStatusMessage(_ message: String?) {
setUpdateStatus(message)
}
/// Snapshot-only: set updateAvailable without triggering a real download.
func snapshotSetUpdateAvailable(version: String?, notes: String?) {
if let version = version, let notes = notes {
let writable: ReferenceWritableKeyPath<AppModel, (version: String, notes: String)?> = unsafeBitCast(
\AppModel.updateAvailable, to: ReferenceWritableKeyPath<AppModel, (version: String, notes: String)?>.self
)
self[keyPath: writable] = (version: version, notes: notes)
} else {
let writable: ReferenceWritableKeyPath<AppModel, (version: String, notes: String)?> = unsafeBitCast(
\AppModel.updateAvailable, to: ReferenceWritableKeyPath<AppModel, (version: String, notes: String)?>.self
)
self[keyPath: writable] = nil
}
}
/// Snapshot-only: set a fake previousVersion for the revert panel.
func snapshotSetPreviousVersion(_ version: String?) {
updateChecker.snapshotPreviousVersionOverride = version
updateChecker.snapshotUsesPreviousVersionOverride = true
}
}
private enum SnapshotError: Error, CustomStringConvertible {
+134 -34
View File
@@ -328,6 +328,9 @@ enum PickerSelfTest {
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 {
let fm = FileManager.default
try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
@@ -335,6 +338,9 @@ enum PickerSelfTest {
guard let sourceApp = ownAppBundleURL() else {
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)
if fm.fileExists(atPath: payload.path) {
@@ -352,32 +358,37 @@ enum PickerSelfTest {
plist["CFBundleShortVersionString"] = "99.0.0"
let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
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")
if fm.fileExists(atPath: zipURL.path) {
try fm.removeItem(at: zipURL)
}
try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path])
let zipData = try Data(contentsOf: zipURL)
let hex = UpdateChecker.sha256Hex(zipData)
let appcastURL = outputDirectory.appendingPathComponent("appcast.json")
let appcast: [String: String] = [
"version": "99.0.0",
"zipURL": zipURL.absoluteString,
"sha256": hex,
"notes": "UPDATE-SELFTEST",
]
let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys])
try appcastData.write(to: appcastURL)
func writeZipAndAppcast() throws {
if fm.fileExists(atPath: zipURL.path) {
try fm.removeItem(at: zipURL)
}
try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path])
let zipData = try Data(contentsOf: zipURL)
let hex = UpdateChecker.sha256Hex(zipData)
let appcast: [String: String] = [
"version": "99.0.0",
"zipURL": zipURL.absoluteString,
"sha256": hex,
"notes": "UPDATE-SELFTEST",
]
let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys])
try appcastData.write(to: appcastURL)
}
try writeZipAndAppcast()
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)
defer {
if let previous {
defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey)
if let previousAppcastPref {
defaults.set(previousAppcastPref, forKey: UpdateChecker.appcastURLDefaultsKey)
} else {
defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey)
}
@@ -386,39 +397,128 @@ enum PickerSelfTest {
let (model, isolatedRoot) = try makeIsolatedUpdateModel()
defer { try? fm.removeItem(at: isolatedRoot) }
// (a) NEGATIVE invalidly-signed payload must never be offered or staged.
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 {
throw UpdateSelfTestError.detail(
"updateAvailable=\(model.updateAvailable?.version ?? "nil")"
"staged-signed: updateAvailable=\(model.updateAvailable?.version ?? "nil")"
)
}
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 {
throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)")
throw UpdateSelfTestError.detail("staged-signed: staged name \(staged.lastPathComponent)")
}
let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck")
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)
if fm.fileExists(atPath: targetRoot.path) {
try fm.removeItem(at: targetRoot)
// (c) ATOMIC INSTALL a throwaway target pre-populated with the real
// running version; never `/Applications`.
let tempAppsRoot = outputDirectory.appendingPathComponent("Applications", isDirectory: true)
if fm.fileExists(atPath: tempAppsRoot.path) {
try fm.removeItem(at: tempAppsRoot)
}
let target = targetRoot.appendingPathComponent("Redline.app")
model.updateChecker.installStaged(to: target)
try fm.createDirectory(at: tempAppsRoot, withIntermediateDirectories: true)
let tempTarget = tempAppsRoot.appendingPathComponent("Redline.app")
try fm.copyItem(at: sourceApp, to: tempTarget)
let installedPlist = target.appendingPathComponent("Contents/Info.plist")
guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any],
let installedVersion = installed["CFBundleShortVersionString"] as? String
else {
throw UpdateSelfTestError.detail("installed Info.plist unreadable")
model.updateChecker.installStaged(to: tempTarget)
guard let installedVersion = readShortVersion(atAppURL: tempTarget) else {
throw UpdateSelfTestError.detail("atomic-install: installed Info.plist unreadable")
}
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: ", "))"
)
}
}
+246 -23
View File
@@ -1,6 +1,8 @@
import AppKit
import CryptoKit
import Foundation
import Security
import ShotdeckCore
/// Built-in updater. Checks an appcast, stages a verified payload, and installs
/// only when the user clicks the menu row never automatically.
@@ -10,22 +12,37 @@ final class UpdateChecker {
static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")!
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 stagedAppURL: URL?
private(set) var statusMessage: String?
private(set) var lastCheckedAt: Date?
private(set) var isCheckingNow: Bool = false
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 var repeatingTimer: Timer?
private var firstCheckTask: Task<Void, Never>?
private var isChecking = false
private var stagingDirectory: URL?
/// Snapshot-only override for previousVersion; when snapshotUsesPreviousVersionOverride is true,
/// this value (including nil) is returned instead of checking the file system.
var snapshotPreviousVersionOverride: String?
var snapshotUsesPreviousVersionOverride: Bool = false
/// Test seam: override appcast JSON. When set, returns this instead of fetching from URL.
var testAppcastJSON: String?
init() {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 15
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 600
config.httpCookieAcceptPolicy = .never
config.httpShouldSetCookies = false
config.httpCookieStorage = nil
@@ -51,10 +68,18 @@ final class UpdateChecker {
repeatingTimer = timer
}
func checkNow() async {
guard !isChecking else { return }
isChecking = true
defer { isChecking = false }
/// Checks the appcast and stages a newer, signature-verified payload.
/// `manual` only affects the status message shown when already up to date
/// a user-initiated check says so; the silent background check stays quiet.
func checkNow(manual: Bool = false) async {
guard !isCheckingNow else { return }
isCheckingNow = true
onCheckingChanged?(true)
defer {
isCheckingNow = false
onCheckingChanged?(false)
}
lastCheckedAt = Date()
let appcast: Appcast
do {
@@ -67,7 +92,12 @@ final class UpdateChecker {
guard Self.isNewer(appcast.version, than: Self.currentVersion()) else {
clearOffer()
statusMessage = nil
if manual {
let timestamp = DubaiTime.checkTime(lastCheckedAt ?? Date())
statusMessage = "Redline \(Self.currentVersion()) is up to date, checked \(timestamp)"
} else {
statusMessage = nil
}
onChecked?()
return
}
@@ -75,11 +105,15 @@ final class UpdateChecker {
do {
try await downloadAndStage(appcast)
availableUpdate = (version: appcast.version, notes: appcast.notes ?? "")
statusMessage = nil
statusMessage = manual ? "Update to \(appcast.version) is ready" : nil
} catch UpdateCheckError.checksumMismatch {
discardStaging()
availableUpdate = nil
statusMessage = "Update file failed the checksum — not installed."
} catch UpdateCheckError.signatureInvalid {
discardStaging()
availableUpdate = nil
statusMessage = "Update is not signed by MMD — not installed."
} catch {
discardStaging()
availableUpdate = nil
@@ -88,9 +122,9 @@ final class UpdateChecker {
onChecked?()
}
/// Copies the staged app onto `target` with ditto (in place; never deletes the old app).
/// Relaunches unless `SHOTDECK_UPDATE_SELFTEST` is set, so the in-process self-test
/// can assert the installed Info.plist without killing the process.
/// Installs the staged app onto `target` atomically, keeping exactly one rollback
/// copy (`Redline.app.previous`), then hands off to a relaunch and quits.
/// Never deletes the old app before the new one is verified in place.
func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) {
guard let staged = stagedAppURL else {
statusMessage = "No update is staged."
@@ -98,29 +132,121 @@ final class UpdateChecker {
return
}
let targetDir = target.deletingLastPathComponent()
let previousURL = targetDir.appendingPathComponent("Redline.app.previous")
do {
try FileManager.default.createDirectory(
at: target.deletingLastPathComponent(),
withIntermediateDirectories: true
try FileManager.default.createDirectory(at: targetDir, 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 {
statusMessage = "The update could not be installed."
onChecked?()
return
}
let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
if isSelfTest { return }
// Defense in depth: re-verify what actually landed on disk, not just the staged copy.
do {
try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path])
try Self.verifySignature(of: target)
} catch {
statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications."
statusMessage = "The update was installed but failed verification."
onChecked?()
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. Respects the snapshot-only override for panel rendering.
func previousVersion(target: URL = UpdateChecker.defaultInstallTarget) -> String? {
if snapshotUsesPreviousVersionOverride {
return snapshotPreviousVersionOverride
}
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 {
@@ -156,6 +282,67 @@ final class UpdateChecker {
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
private struct Appcast: Decodable {
@@ -170,10 +357,16 @@ final class UpdateChecker {
case invalidPayload
case httpStatus(Int)
case processFailed(String)
case signatureInvalid(String)
}
private func fetchAppcast() async throws -> Appcast {
let data = try await fetchData(from: Self.resolvedAppcastURL())
let data: Data
if let testJSON = testAppcastJSON {
data = testJSON.data(using: .utf8) ?? Data()
} else {
data = try await fetchData(from: Self.resolvedAppcastURL())
}
return try JSONDecoder().decode(Appcast.self, from: data)
}
@@ -219,6 +412,7 @@ final class UpdateChecker {
guard FileManager.default.fileExists(atPath: executable.path) else {
throw UpdateCheckError.invalidPayload
}
try Self.verifySignature(of: appURL)
stagedAppURL = appURL
}
@@ -235,6 +429,35 @@ final class UpdateChecker {
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? {
let fm = FileManager.default
let direct = directory.appendingPathComponent("Redline.app")
+25 -1
View File
@@ -24,8 +24,9 @@ struct ShotdeckApp: App {
.environment(appDelegate.model)
} label: {
let state = appDelegate.model.iconState
let hasUpdate = appDelegate.model.updateAvailable != nil
HStack(spacing: 4) {
Image(systemName: state.symbolName)
menuBarIcon(for: state, hasUpdate: hasUpdate)
if let count = state.countText {
Text(count).font(.system(size: 11, weight: .semibold))
}
@@ -36,6 +37,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
final class AppDelegate: NSObject, NSApplicationDelegate {
let model: AppModel
@@ -3,6 +3,7 @@ import Foundation
public enum DubaiTime {
private static let stampFormatter = LockedDateFormatter(dateFormat: "d MMM yyyy, HH:mm 'Dubai'")
private static let fileStampFormatter = LockedDateFormatter(dateFormat: "yyyyMMdd-HHmmss")
private static let checkTimeFormatter = LockedDateFormatter(dateFormat: "HH:mm 'Dubai'")
public static func stamp(_ date: Date) -> String {
stampFormatter.string(from: date)
@@ -11,6 +12,10 @@ public enum DubaiTime {
public static func fileStamp(_ date: Date) -> String {
fileStampFormatter.string(from: date)
}
public static func checkTime(_ date: Date) -> String {
checkTimeFormatter.string(from: date)
}
}
/// DateFormatter is not Sendable. This holder is the only shared mutable state
@@ -0,0 +1,118 @@
import Foundation
import Testing
@testable import Shotdeck
@testable import ShotdeckCore
@Test("DubaiTime.checkTime formats as HH:MM Dubai")
func dubaiTimeCheckTimeFormat() {
let now = Date()
let result = DubaiTime.checkTime(now)
// Dubai timezone format: HH:MM Dubai
let pattern = "^[0-9]{2}:[0-9]{2} Dubai$"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(result.startIndex..<result.endIndex, in: result)
let matches = regex?.matches(in: result, options: [], range: range) ?? []
#expect(!matches.isEmpty, "checkTime should format as HH:MM Dubai, got: \(result)")
}
@Test("Status message: up-to-date manual check includes Dubai timestamp")
@MainActor
func manualCheckUpToDateIncludesTimestamp() async {
let checker = UpdateChecker()
// Inject a stub appcast showing the current version (no update available)
let currentVersion = UpdateChecker.currentVersion()
let stubAppcast = """
{
"version": "\(currentVersion)",
"zipURL": "https://example.com/dummy.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
"""
checker.testAppcastJSON = stubAppcast
// Capture the status message
var capturedStatus: String?
checker.onChecked = {
capturedStatus = checker.statusMessage
}
// Run the manual check
await checker.checkNow(manual: true)
// Verify the message matches the expected format and includes a timestamp
guard let status = capturedStatus else {
#expect(false, "statusMessage should not be nil for manual check finding no update")
return
}
// Message should be "Redline X.Y.Z is up to date, checked HH:MM Dubai"
let pattern = "^Redline [0-9]+\\.[0-9]+\\.[0-9]+ is up to date, checked [0-9]{2}:[0-9]{2} Dubai$"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(status.startIndex..<status.endIndex, in: status)
let matches = regex?.matches(in: status, options: [], range: range) ?? []
#expect(!matches.isEmpty, "Manual check up-to-date message should match format, got: \(status)")
}
@Test("Status message: automatic check doesn't set message when up-to-date")
@MainActor
func automaticCheckUpToDateLeavesMessageNil() async {
let checker = UpdateChecker()
// Inject a stub appcast showing the current version (no update available)
let currentVersion = UpdateChecker.currentVersion()
let stubAppcast = """
{
"version": "\(currentVersion)",
"zipURL": "https://example.com/dummy.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
"""
checker.testAppcastJSON = stubAppcast
// Capture the status message
var capturedStatus: String?
checker.onChecked = {
capturedStatus = checker.statusMessage
}
// Run an AUTOMATIC check (manual: false)
await checker.checkNow(manual: false)
// For automatic checks finding no update, statusMessage should be nil
#expect(capturedStatus == nil,
"Automatic check finding no update should leave statusMessage nil, got: \(capturedStatus ?? "(nil)")")
}
@Test("Update status and general status are independent channels")
@MainActor
func statusChannelsAreIndependent() async throws {
let fm = FileManager.default
let appSupportRoot = fm.temporaryDirectory
.appendingPathComponent("update-checker-channel-test-\(UUID().uuidString)", isDirectory: true)
defer { try? fm.removeItem(at: appSupportRoot) }
// Create a real AppModel using the standard launch pattern
let model = AppDelegate.makeLaunchModel(appSupportRoot: appSupportRoot)
defer { model.hotkeys.unregisterAll() }
// Test 1: Setting statusLine should NOT affect updateStatusMessage
model.setStatus("General status: captured 3")
#expect(model.statusLine == "General status: captured 3", "statusLine should be set")
#expect(model.updateStatusMessage == nil, "updateStatusMessage should remain nil")
// Test 2: Setting updateStatus should NOT affect statusLine
model.setUpdateStatus("Update to 9.9.9 is ready")
#expect(model.statusLine == "General status: captured 3", "statusLine should remain unchanged")
#expect(model.updateStatusMessage == "Update to 9.9.9 is ready", "updateStatusMessage should be set")
// Test 3: Clearing statusLine leaves updateStatus intact
model.setStatus(nil)
#expect(model.statusLine == nil, "statusLine should be cleared")
#expect(model.updateStatusMessage == "Update to 9.9.9 is ready", "updateStatusMessage should persist")
// Test 4: Clearing updateStatus leaves other state unaffected
model.setUpdateStatus(nil)
#expect(model.updateStatusMessage == nil, "updateStatusMessage should be cleared")
}
+211 -29
View File
@@ -13,7 +13,10 @@ PLISTBUDDY="/usr/libexec/PlistBuddy"
REMOTE_HOST="mmd01"
REMOTE_BASE="/opt/mmd-installer-content/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() {
echo "Usage: $0 <version> [\"notes\"]" >&2
@@ -72,6 +75,7 @@ else
PUBLIC_DIR="${PUBLIC_BASE}"
fi
APP_BUNDLE="${ROOT}/.build/Redline.app"
ZIP_NAME="Redline-${VERSION}.zip"
DMG_NAME="Redline-${VERSION}.dmg"
ZIP_PATH="${ROOT}/.build/${ZIP_NAME}"
@@ -139,6 +143,38 @@ else
echo "==> --test: skipping git commit of version bump"
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.
# Re-run signed steps with the account's real home when the identity is missing.
signing_home() {
@@ -167,31 +203,85 @@ run_signed() {
echo "==> Building signed Redline.app"
run_signed ./scripts/build-app.sh
if [[ ! -d "${ROOT}/.build/Redline.app" ]]; then
echo "Signed app missing at ${ROOT}/.build/Redline.app" >&2
if [[ ! -d "${APP_BUNDLE}" ]]; then
echo "Signed app missing at ${APP_BUNDLE}" >&2
exit 1
fi
# 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[@]+"${CODESIGN_KEYCHAIN_ARGS[@]}"} \
--sign "${SIGN_IDENTITY}" \
--identifier "${BUNDLE_ID}" \
"${APP_BUNDLE}"
build_zip() {
mkdir -p "${ROOT}/.build"
(
cd "${ROOT}/.build"
rm -f "${ZIP_NAME}"
ditto -c -k --keepParent Redline.app "${ZIP_NAME}"
)
if [[ ! -s "${ZIP_PATH}" ]]; then
echo "Zip was not created at ${ZIP_PATH}" >&2
exit 1
fi
}
# Rebuilds the manual-installer DMG from whatever is currently at
# ${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
echo "DMG was not created at ${DMG_PATH}" >&2
exit 1
fi
}
echo "==> Zipping Redline.app -> ${ZIP_PATH}"
mkdir -p "${ROOT}/.build"
(
cd "${ROOT}/.build"
rm -f "${ZIP_NAME}"
ditto -c -k --keepParent Redline.app "${ZIP_NAME}"
)
if [[ ! -s "${ZIP_PATH}" ]]; then
echo "Zip was not created at ${ZIP_PATH}" >&2
exit 1
fi
echo "==> Building manual installer DMG"
run_signed ./scripts/make-dmg.sh
if [[ ! -s "${DMG_PATH}" ]]; then
echo "DMG was not created at ${DMG_PATH}" >&2
exit 1
fi
build_zip
SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
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 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}"
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 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 = {
"version": version,
"zipURL": zip_url,
"sha256": sha256,
"notes": notes,
"pubDate": pub_date,
"notarized": notarized == "1",
"teamIdentifier": team_identifier,
}
with open(out_path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
@@ -280,8 +454,16 @@ fi
echo
echo "Published v${VERSION}"
echo " appcast: ${APPCAST_URL}"
echo " zip: ${ZIP_URL}"
echo " sha256: ${SHA256}"
echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}"
echo " dmg: ${PUBLIC_DIR}/Redline.dmg"
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 " zip: ${ZIP_URL}"
echo " sha256: ${SHA256}"
echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}"
echo " dmg: ${PUBLIC_DIR}/Redline.dmg"