Compare commits

..
Author SHA1 Message Date
kua-agentandClaude Fable 5.1 80a128b667 selftest: ONEDRIVE-SELFTEST phase, proven against the real OneDrive sync root
New phase chained after UPDATE-SELFTEST (so a full SHOTDECK_PICKER_SELFTEST
chain now prints all five PASS lines) and also runnable standalone via
REDLINE_SELFTEST_PHASE=onedrive, since the harness has no other per-phase
selector.

Points TransportSettings at a NEW "Redline-selftest-<Dubai timestamp>" folder
under the real /Users/.../OneDrive-MMDGROUP sync root (never a fake home tree
— that proves the transport against the actual OneDrive file provider), drives
a seeded session through the OneDrive branch of send(anchor: nil), asserts the
PDF landed, the session archived, and the status starts with "Saved to
OneDrive", then confirms the watcher does NOT report the fresh unmarked PDF
as returned. It then adds a real PDFKit ink annotation to that PDF in place —
what the iPad does — saves it, and confirms the watcher now reports it as
commented. Prints "ONEDRIVE-SELFTEST PASS path=<folder>". Never deletes
anything under OneDrive; the created folder and PDF are left in place.

UserDefaults.standard's transport/oneDriveFolder keys are snapshotted and
restored around the phase, the same pattern runRegionPersistPhase already
uses for CaptureRegion — there is no separate defaults-suite threading
through AppModel/send(), so this is the only way to drive the real send()
path without leaving the real app pointed at the selftest folder afterward.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:01:29 +04:00
kua-agentandClaude Fable 5.1 78cc7d5b1a test: OneDrive transport settings/locator unit tests + ReturnWatcher recordUncommended coverage
TransportSettingsTests: default airDrop, set/get round-trip, garbage stored
value falls back to airDrop, oneDriveFolder store/reset, effectiveFolders for
both transports, oneDriveFolderUnavailable's errorDescription contains the path.

OneDriveLocatorTests: fake home tree under Library/CloudStorage — syncRoots
returns only real OneDrive-* directories (ignores a same-named plain file and
a GoogleDrive-* one), MMD-named root sorts first; no CloudStorage dir means
empty roots and a nil defaultRedlineFolder; resolveOneDriveFolder prefers an
existing stored override and falls back to the default when the stored path
no longer exists. All against temp dirs, never the real home.

ReturnWatcherTests: recordUncommended defaults to true and still records an
unmarked PDF (existing AirDrop tests are unaffected); with it set false, an
unmarked PDF is neither recorded nor returned by scanNow, and marking it up
in place with a real PDFKit ink annotation then re-scanning does record it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:01:21 +04:00
kua-agentandClaude Fable 5.1 6449c72b3b feature: OneDrive folder as a second send transport
send(anchor:) now branches on TransportSettings.transport(). OneDrive mode
skips AirDrop entirely: it verifies the resolved OneDrive folder exists right
before composing (never trusts stale state), archives the session immediately
after the PDF lands, and sets "Saved to OneDrive — N page(s). Open it in Files
on your iPad." AirDrop's existing behaviour, including handleDidFailToShareItems,
is untouched and only reached from the .airDrop branch.

AppModel seeds outbox/watch from TransportSettings.effectiveFolders() instead
of FolderSettings.resolve() directly, tracks the live `transport`, and
bootstrap() creates the OneDrive folder and sets the watcher's
recordUncommented flag (true only for AirDrop) before the watcher starts.

Settings gets a "Send via" segmented picker above Folders. AirDrop shows the
existing watch/output rows; OneDrive shows a single read-only OneDrive folder
row (Choose... reuses the existing directory picker) plus one caption
explaining the same-folder round trip, or a "No OneDrive folder found" prompt
when nothing resolves (Choose... stays usable). Switching transport re-points
the watcher's folder and recordUncommended live; AirDrop's own folder
overrides are stored separately and are untouched by a OneDrive-and-back
round trip.

Menu's "Send..." row reads "Send to OneDrive" when that transport is active.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:01:13 +04:00
kua-agentandClaude Fable 5.1 299d55e884 feature(core): OneDrive transport settings, locator, and watcher recordUncommented flag
Adds SendTransport/TransportSettings (UserDefaults-backed, mirrors FolderSettings)
and OneDriveLocator, which finds a OneDrive-* sync root under
~/Library/CloudStorage and resolves the Redline send/watch folder inside it
(MMD-named roots preferred). TransportSettings.effectiveFolders() is the one
function that combines the transport choice with FolderSettings/OneDriveLocator.

ReturnWatcher gains recordUncommented (default true, today's AirDrop behaviour):
when false, a document with zero human marks is neither recorded into the
ledger nor returned by scanNow. This is needed because in OneDrive mode the
outbox and watch folder are the same folder, so a freshly written, unmarked
PDF must not be treated as a return — only a later, actually marked-up save
of the same file should be.

Adds ShotdeckError.oneDriveFolderUnavailable(path:) for when the OneDrive
folder is missing or unwritable at send time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:01:04 +04:00
14 changed files with 943 additions and 683 deletions
+21 -28
View File
@@ -32,6 +32,9 @@ 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`.
@@ -43,8 +46,6 @@ 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
@@ -83,12 +84,15 @@ public final class AppModel {
) )
self.region = Self.loadPersistedRegion() self.region = Self.loadPersistedRegion()
self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
// Seeded from FolderSettings.resolve() via resolvedAppSupportPaths never .standard(). // Seeded from TransportSettings.effectiveFolders() the one place that combines
let folders = FolderSettings.resolve() // the transport choice with FolderSettings/OneDriveLocator. Never call
// 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
@@ -98,18 +102,7 @@ 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.
@@ -131,6 +124,7 @@ 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
@@ -198,6 +192,16 @@ 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
@@ -221,6 +225,8 @@ 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()
} }
@@ -232,19 +238,6 @@ 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() {
+1 -32
View File
@@ -15,8 +15,6 @@ struct MenuBarView: View {
Divider() Divider()
returnsBlock returnsBlock
} }
Divider()
updateFooter
} }
.padding(10) .padding(10)
.frame(width: 320, alignment: .leading) .frame(width: 320, alignment: .leading)
@@ -85,21 +83,6 @@ 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 {
@@ -108,7 +91,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("Send…") actionLabel(model.transport == .oneDrive ? "Send to OneDrive" : "Send…")
} }
.disabled(model.session.isEmpty || model.isSending) .disabled(model.session.isEmpty || model.isSending)
@@ -210,18 +193,4 @@ 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)
}
}
}
} }
+222 -122
View File
@@ -3,6 +3,7 @@ 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`.
@@ -173,7 +174,7 @@ enum PickerSelfTest {
try await executeSendTruth() try await executeSendTruth()
print("SEND-TRUTH PASS") print("SEND-TRUTH PASS")
fflush(stdout) fflush(stdout)
if !startUpdateSelfTestIfRequested() { if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() {
exit(0) exit(0)
} }
} catch { } catch {
@@ -315,7 +316,9 @@ 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 {
@@ -325,9 +328,6 @@ 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,9 +335,6 @@ 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) {
@@ -355,20 +352,17 @@ 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,
@@ -377,15 +371,13 @@ 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 previousAppcastPref = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey) let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey)
defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey) defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey)
defer { defer {
if let previousAppcastPref { if let previous {
defaults.set(previousAppcastPref, forKey: UpdateChecker.appcastURLDefaultsKey) defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey)
} else { } else {
defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey) defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey)
} }
@@ -394,128 +386,39 @@ 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(
"staged-signed: updateAvailable=\(model.updateAvailable?.version ?? "nil")" "updateAvailable=\(model.updateAvailable?.version ?? "nil")"
) )
} }
guard let staged = model.updateChecker.stagedAppURL else { guard let staged = model.updateChecker.stagedAppURL else {
throw UpdateSelfTestError.detail("staged-signed: staged payload missing") throw UpdateSelfTestError.detail("staged payload missing")
} }
guard staged.lastPathComponent == "Redline.app" else { guard staged.lastPathComponent == "Redline.app" else {
throw UpdateSelfTestError.detail("staged-signed: staged name \(staged.lastPathComponent)") throw UpdateSelfTestError.detail("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-signed: staged Contents/MacOS/Shotdeck missing") throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing")
} }
print("UPDATE-SELFTEST staged-signed PASS")
fflush(stdout)
// (c) ATOMIC INSTALL a throwaway target pre-populated with the real let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true)
// running version; never `/Applications`. if fm.fileExists(atPath: targetRoot.path) {
let tempAppsRoot = outputDirectory.appendingPathComponent("Applications", isDirectory: true) try fm.removeItem(at: targetRoot)
if fm.fileExists(atPath: tempAppsRoot.path) {
try fm.removeItem(at: tempAppsRoot)
} }
try fm.createDirectory(at: tempAppsRoot, withIntermediateDirectories: true) let target = targetRoot.appendingPathComponent("Redline.app")
let tempTarget = tempAppsRoot.appendingPathComponent("Redline.app") model.updateChecker.installStaged(to: target)
try fm.copyItem(at: sourceApp, to: tempTarget)
model.updateChecker.installStaged(to: tempTarget) let installedPlist = target.appendingPathComponent("Contents/Info.plist")
guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any],
guard let installedVersion = readShortVersion(atAppURL: tempTarget) else { let installedVersion = installed["CFBundleShortVersionString"] as? String
throw UpdateSelfTestError.detail("atomic-install: installed Info.plist unreadable") else {
throw UpdateSelfTestError.detail("installed Info.plist unreadable")
} }
guard installedVersion == "99.0.0" else { guard installedVersion == "99.0.0" else {
throw UpdateSelfTestError.detail("atomic-install: installed version \(installedVersion)") throw UpdateSelfTestError.detail("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: ", "))"
)
} }
} }
@@ -573,6 +476,194 @@ 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(
@@ -638,3 +729,12 @@ 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,6 +16,31 @@ 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()
@@ -27,6 +52,18 @@ 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)
@@ -52,6 +89,13 @@ 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,6 +33,25 @@ 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)
@@ -41,6 +60,7 @@ 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) {
@@ -54,6 +74,36 @@ 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() }
@@ -68,6 +118,17 @@ 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
@@ -190,6 +251,53 @@ 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
+20 -223
View File
@@ -1,7 +1,6 @@
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.
@@ -11,31 +10,22 @@ 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 = 30 config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 600 config.timeoutIntervalForResource = 15
config.httpCookieAcceptPolicy = .never config.httpCookieAcceptPolicy = .never
config.httpShouldSetCookies = false config.httpShouldSetCookies = false
config.httpCookieStorage = nil config.httpCookieStorage = nil
@@ -61,18 +51,10 @@ final class UpdateChecker {
repeatingTimer = timer repeatingTimer = timer
} }
/// Checks the appcast and stages a newer, signature-verified payload. func checkNow() async {
/// `manual` only affects the status message shown when already up to date guard !isChecking else { return }
/// a user-initiated check says so; the silent background check stays quiet. isChecking = true
func checkNow(manual: Bool = false) async { defer { isChecking = false }
guard !isCheckingNow else { return }
isCheckingNow = true
onCheckingChanged?(true)
defer {
isCheckingNow = false
onCheckingChanged?(false)
}
lastCheckedAt = Date()
let appcast: Appcast let appcast: Appcast
do { do {
@@ -85,7 +67,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 = manual ? "Redline \(Self.currentVersion()) is up to date." : nil statusMessage = nil
onChecked?() onChecked?()
return return
} }
@@ -98,10 +80,6 @@ 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
@@ -110,9 +88,9 @@ final class UpdateChecker {
onChecked?() onChecked?()
} }
/// Installs the staged app onto `target` atomically, keeping exactly one rollback /// Copies the staged app onto `target` with ditto (in place; never deletes the old app).
/// copy (`Redline.app.previous`), then hands off to a relaunch and quits. /// Relaunches unless `SHOTDECK_UPDATE_SELFTEST` is set, so the in-process self-test
/// Never deletes the old app before the new one is verified in place. /// can assert the installed Info.plist without killing the process.
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."
@@ -120,118 +98,29 @@ final class UpdateChecker {
return return
} }
let targetDir = target.deletingLastPathComponent()
let previousURL = targetDir.appendingPathComponent("Redline.app.previous")
do { do {
try FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(
at: target.deletingLastPathComponent(),
let replacementDir = try FileManager.default.url( withIntermediateDirectories: true
for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: target,
create: true
) )
defer { try? FileManager.default.removeItem(at: replacementDir) } try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, target.path])
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
} }
// Defense in depth: re-verify what actually landed on disk, not just the staged copy. let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
do { if isSelfTest { return }
try Self.verifySignature(of: target)
} catch {
statusMessage = "The update was installed but failed verification."
onChecked?()
return
}
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 { do {
try Self.verifySignature(of: previousURL) try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path])
} catch { } catch {
statusMessage = "The previous version failed verification and was not restored." statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications."
onChecked?() onChecked?()
return return
} }
NSApp.terminate(nil)
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 {
@@ -267,67 +156,6 @@ 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 {
@@ -342,7 +170,6 @@ 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 {
@@ -392,7 +219,6 @@ 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
} }
@@ -409,35 +235,6 @@ 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")
+4 -25
View File
@@ -7,6 +7,9 @@ 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() }
} }
@@ -21,9 +24,8 @@ 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) {
menuBarIcon(for: state, hasUpdate: hasUpdate) Image(systemName: state.symbolName)
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))
} }
@@ -34,29 +36,6 @@ 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,6 +10,7 @@ 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 {
@@ -31,6 +32,8 @@ 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,6 +10,12 @@ 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;
@@ -29,6 +35,12 @@ 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() {
@@ -77,6 +89,7 @@ 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)
} }
@@ -0,0 +1,145 @@
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)
}
}
@@ -0,0 +1,125 @@
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,3 +230,55 @@ 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)
}
@@ -0,0 +1,114 @@
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
}
+16 -198
View File
@@ -13,10 +13,7 @@ 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"
BUNDLE_ID="ai.flowmaster.shotdeck" SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
# 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
@@ -75,7 +72,6 @@ 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}"
@@ -143,38 +139,6 @@ 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() {
@@ -203,85 +167,31 @@ 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 "${APP_BUNDLE}" ]]; then if [[ ! -d "${ROOT}/.build/Redline.app" ]]; then
echo "Signed app missing at ${APP_BUNDLE}" >&2 echo "Signed app missing at ${ROOT}/.build/Redline.app" >&2
exit 1 exit 1
fi fi
# build-app.sh always signs with its own hardcoded Apple Development identity echo "==> Zipping Redline.app -> ${ZIP_PATH}"
# first (it has to — that identifier+identity pair is what keeps the Screen mkdir -p "${ROOT}/.build"
# 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"
(
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
}
# Rebuilds the manual-installer DMG from whatever is currently at echo "==> Building manual installer DMG"
# ${APP_BUNDLE} — never re-invokes build-app.sh, so a prior custom signature run_signed ./scripts/make-dmg.sh
# 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}" if [[ ! -s "${DMG_PATH}" ]]; then
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 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}")"
@@ -290,102 +200,18 @@ 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}" "${NOTARIZED}" "${TEAM_IDENTIFIER}" <<'PY' python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY'
import json import json
import sys import sys
version, zip_url, sha256, notes, pub_date, out_path, notarized, team_identifier = sys.argv[1:] version, zip_url, sha256, notes, pub_date, out_path = 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)
@@ -454,14 +280,6 @@ 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}"