Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70231574c9 | ||
|
|
8121738188 | ||
|
|
6484fde530 | ||
|
|
bd11ba96c5 | ||
|
|
ef0d8712f7 | ||
|
|
20de467e87 | ||
|
|
209921f084 | ||
|
|
2937d2d5e0 | ||
|
|
80a128b667 | ||
|
|
78cc7d5b1a | ||
|
|
6449c72b3b | ||
|
|
299d55e884 | ||
|
|
380b704f8a | ||
|
|
8338216f23 | ||
|
|
f0e9b41d90 | ||
|
|
36071aed84 | ||
|
|
5e61cd735c | ||
|
|
461f4e4d75 | ||
|
|
4e7c575455 | ||
|
|
7284568489 |
+7
-11
@@ -2,28 +2,24 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Identity invariants: CFBundleIdentifier stays ai.flowmaster.shotdeck and
|
||||
CFBundleExecutable stays Shotdeck. Changing either one invalidates the
|
||||
user's existing Screen Recording grant. CFBundleName is the user-facing
|
||||
product name only. -->
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Shotdeck</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ai.flowmaster.shotdeck</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Redline</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Shotdeck</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.2.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<string>2</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2026 Flowmaster FZC LLC. All rights reserved.</string>
|
||||
</dict>
|
||||
|
||||
@@ -32,10 +32,20 @@ public final class AppModel {
|
||||
public private(set) var isSending: Bool = false
|
||||
public private(set) var outboxDisplayName: 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
|
||||
/// Ground truth for the Settings OneDrive row: nil means "no OneDrive folder found".
|
||||
/// Views read this instead of calling `OneDriveLocator.resolveOneDriveFolder()`
|
||||
/// directly, so state (and testing with a fake home) flows through the model like
|
||||
/// everything else — never a View reaching past the model for real UserDefaults/home.
|
||||
public private(set) var resolvedOneDriveFolder: URL?
|
||||
/// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect.
|
||||
public private(set) var outboxURL: URL
|
||||
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
|
||||
public private(set) var watchFolderURL: URL
|
||||
/// Absolute URL of the PDF composed this run, if any. Used by "Reveal last PDF".
|
||||
public private(set) var lastComposedPDFURL: URL?
|
||||
/// Currently bound capture combo (the last one Carbon accepted, or the preferred load).
|
||||
private(set) var captureHotkey: HotkeyPreference
|
||||
var hotkeyDisplayString: String { captureHotkey.displayString }
|
||||
@@ -79,12 +89,16 @@ public final class AppModel {
|
||||
)
|
||||
self.region = Self.loadPersistedRegion()
|
||||
self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
|
||||
// Seeded from FolderSettings.resolve() via resolvedAppSupportPaths — never .standard().
|
||||
let folders = FolderSettings.resolve()
|
||||
// Seeded from TransportSettings.effectiveFolders() — the one place that combines
|
||||
// the transport choice with FolderSettings/OneDriveLocator. Never call
|
||||
// FolderSettings.resolve() directly outside that function.
|
||||
let folders = TransportSettings.effectiveFolders()
|
||||
self.outboxURL = folders.outbox
|
||||
self.watchFolderURL = folders.watch
|
||||
self.outboxDisplayName = folders.outbox.lastPathComponent
|
||||
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
||||
self.transport = folders.transport
|
||||
self.resolvedOneDriveFolder = OneDriveLocator.resolveOneDriveFolder()
|
||||
self.captureHotkey = HotkeyPreference.load()
|
||||
self.updateChecker = UpdateChecker()
|
||||
self.updateChecker.onChecked = { [weak self] in
|
||||
@@ -116,6 +130,51 @@ public final class AppModel {
|
||||
watchFolderURL = watch
|
||||
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
|
||||
}
|
||||
func setTransport(_ value: SendTransport) { transport = value }
|
||||
func setResolvedOneDriveFolder(_ value: URL?) { resolvedOneDriveFolder = value }
|
||||
|
||||
/// Bumped by chooseTransport/chooseOneDriveFolder (SettingsView.swift) before each
|
||||
/// spawns its async watcher-reconcile Task; that Task checks its own snapshot
|
||||
/// against the live value before every mutating step, so rapid toggling always
|
||||
/// lets the LAST choice win instead of applying stale, superseded work. Not
|
||||
/// `@Observable`-relevant state — pure internal bookkeeping, never read by a View.
|
||||
var reconcileGeneration = 0
|
||||
func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url }
|
||||
|
||||
/// True when a last-composed PDF path is known this run, or the newest
|
||||
/// `Redline-*.pdf` in the outbox exists on disk.
|
||||
var canRevealLastPDF: Bool { revealablePDFURL() != nil }
|
||||
|
||||
public func revealLastPDF() {
|
||||
guard let url = revealablePDFURL() else { return }
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
|
||||
func revealablePDFURL() -> URL? {
|
||||
if let last = lastComposedPDFURL, FileManager.default.fileExists(atPath: last.path) {
|
||||
return last
|
||||
}
|
||||
return newestOutboxRedlinePDF()
|
||||
}
|
||||
|
||||
func newestOutboxRedlinePDF() -> URL? {
|
||||
let fm = FileManager.default
|
||||
let items = (try? fm.contentsOfDirectory(
|
||||
at: outboxURL,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)) ?? []
|
||||
let matches = items.filter {
|
||||
$0.lastPathComponent.hasPrefix("Redline-") && $0.pathExtension.lowercased() == "pdf"
|
||||
}
|
||||
return matches.max { a, b in
|
||||
let da = (try? a.resourceValues(forKeys: [.contentModificationDateKey])
|
||||
.contentModificationDate) ?? .distantPast
|
||||
let db = (try? b.resourceValues(forKeys: [.contentModificationDateKey])
|
||||
.contentModificationDate) ?? .distantPast
|
||||
return da < db
|
||||
}
|
||||
}
|
||||
|
||||
public var iconState: MenuIconState {
|
||||
if !screenRecordingGranted { return .recordingMissing }
|
||||
@@ -147,7 +206,29 @@ public final class AppModel {
|
||||
// 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)
|
||||
}
|
||||
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
|
||||
await watcher.setRecordUncommented(transport == .airDrop)
|
||||
|
||||
do {
|
||||
// BLOCKER fix: reconcile the watcher's internal watchFolder with the live
|
||||
// watchFolderURL UNCONDITIONALLY, before it ever starts. `paths` (and so the
|
||||
// watcher's initial folder, set in its own init) now comes from the same
|
||||
// transport-aware TransportSettings.effectiveFolders() as watchFolderURL, so
|
||||
// in the normal case this is a no-op — but it is the only thing that would
|
||||
// have caught the old bug (launch paths built AirDrop-only while OneDrive was
|
||||
// the persisted transport, leaving the watcher's FSEvents stream pointed at a
|
||||
// stale folder for the whole session) and it stays cheap insurance against
|
||||
// that class of drift ever recurring. Calling it before start() only updates
|
||||
// the stored folder — no FSEvents stream exists yet to restart.
|
||||
try await watcher.updateWatchFolder(watchFolderURL)
|
||||
try await watcher.start { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
@@ -170,6 +251,8 @@ public final class AppModel {
|
||||
ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
|
||||
|| ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
|
||||
|| ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|
||||
|| ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil
|
||||
|| ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] != nil
|
||||
if !skipSchedule {
|
||||
updateChecker.startSchedule()
|
||||
}
|
||||
|
||||
@@ -91,10 +91,17 @@ struct MenuBarView: View {
|
||||
model.setStatus("Send is not available in this build.")
|
||||
}
|
||||
} label: {
|
||||
actionLabel("Send…")
|
||||
actionLabel(model.transport == .oneDrive ? "Send to OneDrive" : "Send…")
|
||||
}
|
||||
.disabled(model.session.isEmpty || model.isSending)
|
||||
|
||||
Button {
|
||||
model.revealLastPDF()
|
||||
} label: {
|
||||
actionLabel("Reveal last PDF")
|
||||
}
|
||||
.disabled(!model.canRevealLastPDF)
|
||||
|
||||
Button {
|
||||
Task { await model.captureNow() }
|
||||
} label: {
|
||||
|
||||
@@ -61,6 +61,28 @@ enum PanelSnapshot {
|
||||
try renderMenuBar(model: model, to: directory, name: "03-empty-session")
|
||||
|
||||
// 04 — three real PNGs in the temp spool so SessionStrip thumbnails decode.
|
||||
try await addSampleCaptures(to: model)
|
||||
try renderMenuBar(model: model, to: directory, name: "04-captures-present")
|
||||
|
||||
// 05 — two inspected PDFs in the temp ledger, one marked / one not.
|
||||
try await seedReturns(model: model)
|
||||
try renderMenuBar(model: model, to: directory, name: "05-returns-present")
|
||||
|
||||
// 06 — SettingsView against the same isolated model.
|
||||
try render(
|
||||
SettingsView().environment(model),
|
||||
to: directory.appendingPathComponent("panel-06-settings.png")
|
||||
)
|
||||
|
||||
// 07/08/09 — OneDrive-mode Settings + menu bar, on a SEPARATE isolated model so
|
||||
// this transport switch never bleeds into the AirDrop-mode panels above.
|
||||
try await captureOneDrivePanels(to: directory)
|
||||
}
|
||||
|
||||
/// Three real PNGs appended to the given model's temp spool so SessionStrip
|
||||
/// thumbnails decode. Shared by panel 04 (AirDrop) and panel 09 (OneDrive).
|
||||
@MainActor
|
||||
private static func addSampleCaptures(to model: AppModel) async throws {
|
||||
let swatches: [(CGFloat, CGFloat, CGFloat)] = [
|
||||
(0.85, 0.22, 0.18),
|
||||
(0.18, 0.62, 0.32),
|
||||
@@ -77,17 +99,62 @@ enum PanelSnapshot {
|
||||
)
|
||||
}
|
||||
model.replaceSession(try await model.spool.currentSession())
|
||||
try renderMenuBar(model: model, to: directory, name: "04-captures-present")
|
||||
}
|
||||
|
||||
// 05 — two inspected PDFs in the temp ledger, one marked / one not.
|
||||
try await seedReturns(model: model)
|
||||
try renderMenuBar(model: model, to: directory, name: "05-returns-present")
|
||||
/// Panels 07-09: OneDrive transport, on its own isolated model/temp root so
|
||||
/// switching transport here never touches the AirDrop-mode model above, the real
|
||||
/// home directory, or UserDefaults.standard. The "resolved" and "not found" states
|
||||
/// are produced by calling the real OneDriveLocator functions against fake home
|
||||
/// trees built under this snapshot's own temp root — never a hand-typed path.
|
||||
@MainActor
|
||||
private static func captureOneDrivePanels(to directory: URL) async throws {
|
||||
let (model, root) = try makeIsolatedModel()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
model.setTransport(.oneDrive)
|
||||
|
||||
// 06 — SettingsView against the same isolated model.
|
||||
// 07 — a resolved OneDrive folder, shaped like the real default
|
||||
// (…/Library/CloudStorage/OneDrive-MMDGROUP/Redline): a fake home tree with a
|
||||
// real OneDrive-MMDGROUP directory under it, resolved via the same pure
|
||||
// OneDriveLocator function production code uses — never a hand-typed path.
|
||||
let fakeHomeWithOneDrive = root.appendingPathComponent("fake-home-with-onedrive", isDirectory: true)
|
||||
let syncRoot = fakeHomeWithOneDrive
|
||||
.appendingPathComponent("Library/CloudStorage/OneDrive-MMDGROUP", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: syncRoot, withIntermediateDirectories: true)
|
||||
guard let resolvedFolder = OneDriveLocator.defaultRedlineFolder(
|
||||
home: fakeHomeWithOneDrive, fileManager: .default
|
||||
) else {
|
||||
throw SnapshotError.oneDriveFixtureFailed("fake OneDrive-MMDGROUP root did not resolve")
|
||||
}
|
||||
model.setResolvedOneDriveFolder(resolvedFolder)
|
||||
try render(
|
||||
SettingsView().environment(model),
|
||||
to: directory.appendingPathComponent("panel-06-settings.png")
|
||||
to: directory.appendingPathComponent("panel-07-settings-onedrive.png")
|
||||
)
|
||||
|
||||
// 08 — no OneDrive folder found: a fake home with NO Library/CloudStorage at
|
||||
// all, and a throwaway UserDefaults suite (never .standard, never touched
|
||||
// before) so the stored-override check also legitimately finds nothing.
|
||||
let fakeHomeWithoutOneDrive = root.appendingPathComponent("fake-home-without-onedrive", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: fakeHomeWithoutOneDrive, withIntermediateDirectories: true)
|
||||
let isolatedDefaults = try makeIsolatedDefaultsSuite()
|
||||
defer { isolatedDefaults.defaults.removePersistentDomain(forName: isolatedDefaults.suiteName) }
|
||||
let missingFolder = OneDriveLocator.resolveOneDriveFolder(
|
||||
defaults: isolatedDefaults.defaults, home: fakeHomeWithoutOneDrive, fileManager: .default
|
||||
)
|
||||
guard missingFolder == nil else {
|
||||
throw SnapshotError.oneDriveFixtureFailed("fake home without OneDrive unexpectedly resolved")
|
||||
}
|
||||
model.setResolvedOneDriveFolder(nil)
|
||||
try render(
|
||||
SettingsView().environment(model),
|
||||
to: directory.appendingPathComponent("panel-08-settings-onedrive-missing.png")
|
||||
)
|
||||
|
||||
// 09 — menu bar panel, 3 captures present, OneDrive mode ("Send to OneDrive").
|
||||
model.snapshotSetScreenRecordingGranted(true)
|
||||
model.replaceRegion(sampleRegion())
|
||||
try await addSampleCaptures(to: model)
|
||||
try renderMenuBar(model: model, to: directory, name: "09-captures-present-onedrive")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -177,6 +244,23 @@ enum PanelSnapshot {
|
||||
return (model, root)
|
||||
}
|
||||
|
||||
/// A throwaway UserDefaults suite — never `.standard` — for the panel-08 fixture,
|
||||
/// the same isolation pattern ShotdeckCoreTests uses for TransportSettings/
|
||||
/// OneDriveLocator tests.
|
||||
private struct IsolatedDefaultsSuite {
|
||||
let suiteName: String
|
||||
let defaults: UserDefaults
|
||||
}
|
||||
|
||||
private static func makeIsolatedDefaultsSuite() throws -> IsolatedDefaultsSuite {
|
||||
let suiteName = "shotdeck-panel-snapshot-\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
throw SnapshotError.oneDriveFixtureFailed("could not create isolated UserDefaults suite")
|
||||
}
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return IsolatedDefaultsSuite(suiteName: suiteName, defaults: defaults)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func seedReturns(model: AppModel) async throws {
|
||||
let watch = model.paths.watchFolder
|
||||
@@ -283,6 +367,7 @@ private enum SnapshotError: Error, CustomStringConvertible {
|
||||
case encodeFailed(String)
|
||||
case pngGenerationFailed
|
||||
case pdfWriteFailed(String)
|
||||
case oneDriveFixtureFailed(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
@@ -290,6 +375,7 @@ private enum SnapshotError: Error, CustomStringConvertible {
|
||||
case .encodeFailed(let name): return "PNG encode failed for \(name)"
|
||||
case .pngGenerationFailed: return "CoreGraphics PNG generation failed"
|
||||
case .pdfWriteFailed(let name): return "could not write \(name)"
|
||||
case .oneDriveFixtureFailed(let detail): return "OneDrive snapshot fixture failed: \(detail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Darwin
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import PDFKit
|
||||
import ShotdeckCore
|
||||
|
||||
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
||||
@@ -113,10 +116,10 @@ enum PickerSelfTest {
|
||||
fflush(stdout)
|
||||
|
||||
runRegionPersistPhase()
|
||||
if !startUpdateSelfTestIfRequested() {
|
||||
exit(0)
|
||||
}
|
||||
// UPDATE-SELFTEST hops to a later main-actor turn and exits itself.
|
||||
// Hop off this MainActor job so the SEND-TRUTH Task can run; do not
|
||||
// exit(0) here — runSendTruthPhase prints its own PASS/FAIL, then
|
||||
// chains to UPDATE-SELFTEST (or exits if that phase is not requested).
|
||||
runSendTruthPhase()
|
||||
}
|
||||
|
||||
/// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through
|
||||
@@ -160,7 +163,145 @@ enum PickerSelfTest {
|
||||
fflush(stdout)
|
||||
}
|
||||
|
||||
/// Phase 3: builds a fake 99.0.0 bundle, serves a local appcast, stages via
|
||||
/// Phase 3: drive SendController's share-outcome seams with no AirDrop sheet.
|
||||
/// Fail path must leave the session open in the temp spool; success path archives
|
||||
/// and mints a fresh empty session. Scheduled as a new MainActor job because this
|
||||
/// function is called from inside `execute()` — a nested run-loop wait would never
|
||||
/// let the Task start. On success, chains to UPDATE-SELFTEST instead of exiting.
|
||||
private static func runSendTruthPhase() {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await executeSendTruth()
|
||||
print("SEND-TRUTH PASS")
|
||||
fflush(stdout)
|
||||
if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() {
|
||||
exit(0)
|
||||
}
|
||||
} catch {
|
||||
print("SEND-TRUTH FAIL \(error)")
|
||||
fflush(stdout)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func executeSendTruth() async throws {
|
||||
let fm = FileManager.default
|
||||
let root = fm.temporaryDirectory
|
||||
.appendingPathComponent("shotdeck-send-truth-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? fm.removeItem(at: root) }
|
||||
|
||||
let paths = try AppSupportPaths(
|
||||
root: root,
|
||||
outbox: root.appendingPathComponent("outbox", isDirectory: true),
|
||||
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
|
||||
)
|
||||
let ledger = try ReturnLedger(paths: paths)
|
||||
let model = AppModel(
|
||||
paths: paths,
|
||||
spool: try SpoolStore(paths: paths),
|
||||
composer: PDFComposer(),
|
||||
capturer: ScreenCapturer(),
|
||||
hotkeys: HotkeyCenter(),
|
||||
picker: RegionPickerController(),
|
||||
ledger: ledger,
|
||||
watcher: ReturnWatcher(paths: paths, ledger: ledger)
|
||||
)
|
||||
model.setFolderURLs(outbox: paths.outbox, watch: paths.watchFolder)
|
||||
|
||||
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())
|
||||
let openID = model.session.id
|
||||
guard !model.session.isEmpty else {
|
||||
sendTruthFail("seeded session was empty")
|
||||
}
|
||||
|
||||
let pending = try await model.composePDFForSend(outbox: model.outboxURL)
|
||||
guard fm.fileExists(atPath: pending.fileURL.path) else {
|
||||
sendTruthFail("PDF was not written")
|
||||
}
|
||||
|
||||
model.handleDidFailToShareItems(fileName: pending.fileName)
|
||||
let still = try await model.spool.currentSession()
|
||||
guard still.id == openID, !still.isEmpty, still.state == .open else {
|
||||
sendTruthFail("fail path archived or replaced the session")
|
||||
}
|
||||
let spoolDir = paths.sessionDirectory(openID)
|
||||
guard fm.fileExists(atPath: spoolDir.path) else {
|
||||
sendTruthFail("fail path: session missing from temp spool")
|
||||
}
|
||||
guard let status = model.statusLine, status.contains("nothing was sent") else {
|
||||
sendTruthFail("fail path status missing 'nothing was sent': \(model.statusLine ?? "nil")")
|
||||
}
|
||||
|
||||
await model.handleDidShareItems(fileName: pending.fileName, pageCount: pending.pageCount)
|
||||
let fresh = try await model.spool.currentSession()
|
||||
guard fresh.isEmpty, fresh.id != openID, fresh.state == .open else {
|
||||
sendTruthFail("success path did not mint a fresh empty session")
|
||||
}
|
||||
let archived = try await model.spool.archivedSessions()
|
||||
guard archived.contains(where: { $0.id == openID && $0.state == .archived }) else {
|
||||
sendTruthFail("success path did not archive the session")
|
||||
}
|
||||
let archiveDir = paths.archiveDirectory(openID)
|
||||
guard fm.fileExists(atPath: archiveDir.path) else {
|
||||
sendTruthFail("success path: archive dir missing")
|
||||
}
|
||||
guard !fm.fileExists(atPath: spoolDir.path) else {
|
||||
sendTruthFail("success path: session still in spool")
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeTinyPNGData() throws -> Data {
|
||||
let width = 64
|
||||
let height = 48
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
sendTruthFail("could not create PNG context")
|
||||
}
|
||||
context.setFillColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1)
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let image = context.makeImage() else {
|
||||
sendTruthFail("could not make CGImage")
|
||||
}
|
||||
let buffer = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(
|
||||
buffer,
|
||||
"public.png" as CFString,
|
||||
1,
|
||||
nil
|
||||
) else {
|
||||
sendTruthFail("could not create PNG destination")
|
||||
}
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
sendTruthFail("could not finalize PNG")
|
||||
}
|
||||
return buffer as Data
|
||||
}
|
||||
|
||||
private static func sendTruthFail(_ reason: String) -> Never {
|
||||
print("SEND-TRUTH FAIL \(reason)")
|
||||
fflush(stdout)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
/// Phase 4: builds a fake 99.0.0 bundle, serves a local appcast, stages via
|
||||
/// `checkNow`, then `installStaged` into the env dir — never `/Applications`.
|
||||
/// Returns true when the async phase was scheduled (it calls `exit` itself).
|
||||
@discardableResult
|
||||
@@ -175,7 +316,9 @@ enum PickerSelfTest {
|
||||
try await runUpdateSelfTest(outputDirectory: output)
|
||||
print("UPDATE-SELFTEST PASS version=99.0.0")
|
||||
fflush(stdout)
|
||||
exit(0)
|
||||
if !startOneDriveSelfTestIfRequested() {
|
||||
exit(0)
|
||||
}
|
||||
} catch let error as UpdateSelfTestError {
|
||||
updateFail(error.description)
|
||||
} catch {
|
||||
@@ -333,6 +476,282 @@ enum PickerSelfTest {
|
||||
exit(1)
|
||||
}
|
||||
|
||||
/// Phase 5: proves the OneDrive transport end to end against a REAL sync root —
|
||||
/// resolved at runtime via `OneDriveLocator.syncRoots()`, never a hardcoded path, so
|
||||
/// this runs correctly on any Mac/account that has OneDrive signed in (MMD-named
|
||||
/// root preferred, same as production). 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() {
|
||||
// Never a false PASS: no real OneDrive sync root on this machine/account is a
|
||||
// SKIP (still non-zero exit), not silently treated as passing.
|
||||
guard let syncRoot = OneDriveLocator.syncRoots().first else {
|
||||
print("ONEDRIVE-SELFTEST SKIP no OneDrive sync root")
|
||||
fflush(stdout)
|
||||
exit(1)
|
||||
}
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let folder = try await executeOneDriveSelfTest(syncRoot: syncRoot)
|
||||
print("ONEDRIVE-SELFTEST PASS path=\(folder.path)")
|
||||
fflush(stdout)
|
||||
exit(0)
|
||||
} catch let error as OneDriveSelfTestError {
|
||||
oneDriveFail(error.description)
|
||||
} catch {
|
||||
oneDriveFail(String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sub-step 1: builds a session, sends it through the OneDrive branch of
|
||||
/// `send(anchor: nil)` against a NEW folder under `syncRoot`, 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.
|
||||
///
|
||||
/// Sub-step 1b: rapid transport toggling (chooseTransport(.airDrop) immediately
|
||||
/// followed by chooseTransport(.oneDrive), no await between them) must still end
|
||||
/// with the watcher pointed at the OneDrive folder — proves the generation-guarded
|
||||
/// reconcile in chooseTransport/chooseOneDriveFolder (SettingsView.swift) really
|
||||
/// does let the last choice win instead of an earlier, superseded call applying its
|
||||
/// stale folder after a later one already won.
|
||||
///
|
||||
/// Sub-step 2: relaunch simulation — the exact BLOCKER scenario this phase exists to
|
||||
/// catch. OneDrive is still persisted in defaults from sub-step 1; builds a FRESH
|
||||
/// model the same way the real app launches (`AppDelegate.makeLaunchModel()` itself,
|
||||
/// not a reimplementation), bootstraps it, then marks a PDF in the folder in place —
|
||||
/// the relaunched watcher must report it. A temp app-support root keeps this off the
|
||||
/// real ~/Library/Application Support/Shotdeck.
|
||||
///
|
||||
/// Never deletes anything under OneDrive — the created folder and PDFs are left in
|
||||
/// place for Ben to inspect / for the real iPad round trip.
|
||||
private static func executeOneDriveSelfTest(syncRoot: URL) async throws -> URL {
|
||||
let fm = FileManager.default
|
||||
|
||||
// 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 = syncRoot.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.
|
||||
try addInkMark(to: pdfURL)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// Sub-step 1b: rapid toggle race — see the doc comment above this function.
|
||||
model.chooseTransport(.airDrop)
|
||||
model.chooseTransport(.oneDrive) // immediately superseding the call above
|
||||
// The generation guard itself is what's under test, not this wait — it just
|
||||
// gives the (already-guarded) reconcile Task a moment to settle either way.
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
guard model.transport == .oneDrive else {
|
||||
throw OneDriveSelfTestError.detail(
|
||||
"rapid toggle: model.transport ended as \(model.transport), expected .oneDrive"
|
||||
)
|
||||
}
|
||||
let racePDFURL = selftestFolder.appendingPathComponent("Redline-race-\(stamp).pdf")
|
||||
try writeUnmarkedRedlinePDF(to: racePDFURL)
|
||||
try addInkMark(to: racePDFURL)
|
||||
let raceFound = try await model.watcher.scanNow()
|
||||
guard raceFound.first(where: { $0.fileURL == racePDFURL })?.isCommented == true else {
|
||||
throw OneDriveSelfTestError.detail(
|
||||
"rapid toggle: watcher did not end up watching \(selftestFolder.path) — an earlier, superseded chooseTransport call won"
|
||||
)
|
||||
}
|
||||
|
||||
// Sub-step 2: relaunch simulation — see the doc comment above this function.
|
||||
let relaunchAppSupportRoot = fm.temporaryDirectory
|
||||
.appendingPathComponent("shotdeck-onedrive-relaunch-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? fm.removeItem(at: relaunchAppSupportRoot) }
|
||||
|
||||
let relaunchModel = AppDelegate.makeLaunchModel(appSupportRoot: relaunchAppSupportRoot)
|
||||
guard relaunchModel.transport == .oneDrive else {
|
||||
throw OneDriveSelfTestError.detail(
|
||||
"relaunch: model transport was \(relaunchModel.transport), expected .oneDrive"
|
||||
)
|
||||
}
|
||||
guard relaunchModel.watchFolderURL.path == selftestFolder.path else {
|
||||
throw OneDriveSelfTestError.detail(
|
||||
"relaunch: model watchFolderURL was \(relaunchModel.watchFolderURL.path), expected \(selftestFolder.path) — this is the exact BLOCKER this phase guards against"
|
||||
)
|
||||
}
|
||||
|
||||
await relaunchModel.bootstrap()
|
||||
|
||||
let relaunchPDFURL = selftestFolder.appendingPathComponent("Redline-relaunch-\(stamp).pdf")
|
||||
try writeUnmarkedRedlinePDF(to: relaunchPDFURL)
|
||||
try addInkMark(to: relaunchPDFURL)
|
||||
|
||||
let relaunchFound = try await relaunchModel.watcher.scanNow()
|
||||
guard relaunchFound.first(where: { $0.fileURL == relaunchPDFURL })?.isCommented == true else {
|
||||
throw OneDriveSelfTestError.detail(
|
||||
"relaunch: watcher did not report the marked PDF at \(relaunchPDFURL.path) as returned — it was watching the wrong folder after relaunch"
|
||||
)
|
||||
}
|
||||
await relaunchModel.watcher.stop()
|
||||
|
||||
return selftestFolder
|
||||
}
|
||||
|
||||
/// Adds a real PDFKit ink annotation to the PDF at `url` in place and saves it —
|
||||
/// exactly what the iPad does when marking up a page.
|
||||
private static func addInkMark(to url: URL) throws {
|
||||
guard let document = PDFDocument(url: url), let page = document.page(at: 0) else {
|
||||
throw OneDriveSelfTestError.detail("could not reopen \(url.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: url) else {
|
||||
throw OneDriveSelfTestError.detail("could not save the annotated PDF back to \(url.path)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a fresh, unmarked, single-page "Redline"-creator PDF straight to `url` —
|
||||
/// standing in for a PDF that has just landed in the watch folder, before any
|
||||
/// human mark. Used by the rapid-toggle and relaunch sub-steps, which don't need to
|
||||
/// exercise send()/composePDFForSend() again (sub-step 1 already does).
|
||||
private static func writeUnmarkedRedlinePDF(to url: URL) throws {
|
||||
let document = PDFDocument()
|
||||
let page = PDFPage()
|
||||
page.setBounds(CGRect(x: 0, y: 0, width: 612, height: 792), for: .mediaBox)
|
||||
document.insert(page, at: 0)
|
||||
document.documentAttributes = [
|
||||
PDFDocumentAttribute.creatorAttribute: "Redline",
|
||||
PDFDocumentAttribute.subjectAttribute: UUID().uuidString,
|
||||
]
|
||||
guard document.write(to: url) else {
|
||||
throw OneDriveSelfTestError.detail("could not write \(url.path)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func oneDriveFail(_ detail: String) -> Never {
|
||||
print("ONEDRIVE-SELFTEST FAIL \(detail)")
|
||||
fflush(stdout)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
private static func interpolate(_ step: Int) -> NSPoint {
|
||||
let t = CGFloat(step) / CGFloat(dragSteps)
|
||||
return NSPoint(
|
||||
@@ -398,3 +817,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,118 @@ import Darwin
|
||||
import Foundation
|
||||
import ShotdeckCore
|
||||
|
||||
/// Result of composing a send PDF. Kept so the self-test can drive the share
|
||||
/// outcome without presenting a real AirDrop sheet.
|
||||
struct ComposedSend: Sendable {
|
||||
let fileName: String
|
||||
let fileURL: URL
|
||||
let pageCount: Int
|
||||
}
|
||||
|
||||
extension AppModel: SendCapable {
|
||||
public func send(anchor: NSView?) async {
|
||||
guard !session.isEmpty, !isSending else { return }
|
||||
setSending(true)
|
||||
defer { setSending(false) }
|
||||
|
||||
// Snapshot BOTH the transport AND the destination folder into local `let`s
|
||||
// ONCE, before any `await` in this function. chooseTransport/chooseOneDriveFolder
|
||||
// now refuse (status "Finish the current send first.") while isSending is true,
|
||||
// but this snapshot is the actual fix for the race: even without that guard,
|
||||
// everything below operates on these frozen values — composePDFForSend(outbox:)
|
||||
// takes the folder as a parameter and never re-reads `self.outboxURL` after a
|
||||
// suspension point, so a concurrent transport switch mid-send can no longer land
|
||||
// the PDF under one transport's folder while the archive/status branch (which
|
||||
// switches on the same frozen `transport` local) runs the other's.
|
||||
let transport = TransportSettings.transport()
|
||||
let destinationFolder: URL
|
||||
|
||||
// OneDrive mode: verify the real destination exists AND is writable 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 or lost
|
||||
// its permissions since then (OneDrive signed out, external volume unmounted,
|
||||
// folder deleted, chmod'd unwritable) is caught instead of silently attempted
|
||||
// and surfacing as a generic PDF-composition failure.
|
||||
if transport == .oneDrive {
|
||||
guard let folder = OneDriveLocator.resolveOneDriveFolder(),
|
||||
OneDriveLocator.isWritableDirectory(at: folder)
|
||||
else {
|
||||
let path = OneDriveLocator.resolveOneDriveFolder()?.path
|
||||
?? TransportSettings.storedOneDriveFolderPath()
|
||||
?? "no OneDrive folder found"
|
||||
setResolvedOneDriveFolder(nil)
|
||||
setStatus(ShotdeckError.oneDriveFolderUnavailable(path: path).errorDescription)
|
||||
setSending(false)
|
||||
return
|
||||
}
|
||||
destinationFolder = folder
|
||||
setResolvedOneDriveFolder(folder)
|
||||
if outboxURL != folder || watchFolderURL != folder {
|
||||
setFolderURLs(outbox: folder, watch: folder)
|
||||
try? await watcher.updateWatchFolder(folder)
|
||||
}
|
||||
} else {
|
||||
destinationFolder = outboxURL
|
||||
}
|
||||
|
||||
let pending: ComposedSend
|
||||
do {
|
||||
pending = try await composePDFForSend(outbox: destinationFolder)
|
||||
} catch {
|
||||
// Never unlink the published PDF, and never unlink the temp file either:
|
||||
// a rename failure would leave the complete document at the temp name.
|
||||
setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.")
|
||||
setSending(false)
|
||||
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 {
|
||||
handleDidFailToShareItems(fileName: pending.fileName)
|
||||
setSending(false)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in
|
||||
guard let self else { return }
|
||||
if success {
|
||||
await self.handleDidShareItems(
|
||||
fileName: pending.fileName,
|
||||
pageCount: pending.pageCount
|
||||
)
|
||||
} else {
|
||||
self.handleDidFailToShareItems(fileName: pending.fileName)
|
||||
}
|
||||
self.setSending(false)
|
||||
}
|
||||
} catch {
|
||||
// canPerform false, no service, or no visible window: same as cancel.
|
||||
handleDidFailToShareItems(fileName: pending.fileName)
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the PDF to `outboxDir` and records its path. Does not archive the session
|
||||
/// and does not present AirDrop — that happens only after the share completes.
|
||||
/// `outboxDir` is passed in (a value `send(anchor:)` snapshotted before any await)
|
||||
/// rather than read from `self.outboxURL` here, so a concurrent transport switch
|
||||
/// mid-send can never redirect an in-flight compose to a different folder.
|
||||
func composePDFForSend(outbox outboxDir: URL) async throws -> ComposedSend {
|
||||
let workingSession = session
|
||||
let composer = self.composer
|
||||
// Live outbox (FolderSettings), not `paths.outbox` — Settings changes take effect.
|
||||
let outboxDir = outboxURL
|
||||
let sourceDir = paths.sessionDirectory(workingSession.id)
|
||||
let fileName = PDFComposer.fileName(for: workingSession)
|
||||
let finalURL = outboxDir.appendingPathComponent(fileName)
|
||||
@@ -20,52 +122,54 @@ extension AppModel: SendCapable {
|
||||
let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf")
|
||||
let title = "Redline – \(DubaiTime.stamp(workingSession.createdAt))"
|
||||
|
||||
do {
|
||||
// D-13: build off the main actor. Only Sendable values cross into the
|
||||
// detached task — never `anchor` (NSView is not Sendable).
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
_ = try composer.compose(
|
||||
session: workingSession,
|
||||
imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) },
|
||||
title: title,
|
||||
to: tempURL
|
||||
// D-13: build off the main actor. Only Sendable values cross into the
|
||||
// detached task — never `anchor` (NSView is not Sendable).
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
_ = try composer.compose(
|
||||
session: workingSession,
|
||||
imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) },
|
||||
title: title,
|
||||
to: tempURL
|
||||
)
|
||||
// POSIX rename onto `finalURL` replaces any same-name file in one
|
||||
// directory operation; there is never a window where the PDF is gone.
|
||||
if Darwin.rename(tempURL.path, finalURL.path) != 0 {
|
||||
throw ShotdeckError.pdfCompositionFailed(
|
||||
reason: "could not publish the PDF: \(String(cString: strerror(errno)))"
|
||||
)
|
||||
// POSIX rename onto `finalURL` replaces any same-name file in one
|
||||
// directory operation; there is never a window where the PDF is gone.
|
||||
if Darwin.rename(tempURL.path, finalURL.path) != 0 {
|
||||
throw ShotdeckError.pdfCompositionFailed(
|
||||
reason: "could not publish the PDF: \(String(cString: strerror(errno)))"
|
||||
)
|
||||
}
|
||||
try AtomicFile.fsyncDirectory(at: outboxDir)
|
||||
}.value
|
||||
|
||||
// File exists on disk now — archive only after that (D-13). A later AirDrop
|
||||
// failure never deletes this file.
|
||||
guard FileManager.default.fileExists(atPath: finalURL.path) else {
|
||||
throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk")
|
||||
}
|
||||
try AtomicFile.fsyncDirectory(at: outboxDir)
|
||||
}.value
|
||||
|
||||
guard FileManager.default.fileExists(atPath: finalURL.path) else {
|
||||
throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk")
|
||||
}
|
||||
rememberLastComposedPDF(finalURL)
|
||||
return ComposedSend(
|
||||
fileName: fileName,
|
||||
fileURL: finalURL,
|
||||
pageCount: workingSession.captures.count
|
||||
)
|
||||
}
|
||||
|
||||
/// `NSSharingServiceDelegate.sharingService(_:didShareItems:)` seam.
|
||||
func handleDidShareItems(fileName: String, pageCount: Int) async {
|
||||
guard !session.isEmpty else { return }
|
||||
do {
|
||||
_ = try await spool.archiveCurrent(pdfFileName: fileName)
|
||||
replaceSession(try await spool.currentSession())
|
||||
|
||||
let pageWord = workingSession.captures.count == 1 ? "page" : "pages"
|
||||
setStatus("Sent — \(workingSession.captures.count) \(pageWord).")
|
||||
|
||||
guard let anchor else {
|
||||
setStatus("PDF saved to \(outboxDisplayName). Open the panel to AirDrop it.")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try Sharing.airDrop(fileURL: finalURL, from: anchor)
|
||||
} catch {
|
||||
setStatus(
|
||||
"AirDrop is not available right now — the PDF is on your \(outboxDisplayName)."
|
||||
)
|
||||
}
|
||||
let pageWord = pageCount == 1 ? "page" : "pages"
|
||||
setStatus("Sent — \(pageCount) \(pageWord).")
|
||||
} catch {
|
||||
// Never unlink the published PDF, and never unlink `tempURL` either:
|
||||
// a rename failure would leave the complete document at the temp name.
|
||||
setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.")
|
||||
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not archive the session.")
|
||||
}
|
||||
}
|
||||
|
||||
/// `NSSharingServiceDelegate.sharingService(_:didFailToShareItems:error:)` seam,
|
||||
/// also used when `canPerform` is false or the user cancels. Does not archive.
|
||||
func handleDidFailToShareItems(fileName: String) {
|
||||
setStatus(
|
||||
"AirDrop didn't complete — nothing was sent. Your captures are still here; the PDF is on your \(outboxDisplayName) as \(fileName)."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,25 @@ struct SettingsView: View {
|
||||
.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 {
|
||||
Text("Folders")
|
||||
.font(.headline)
|
||||
@@ -41,17 +60,47 @@ struct SettingsView: View {
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
GridRow(alignment: .center) {
|
||||
fieldLabel("Watch folder")
|
||||
folderValue(path: model.watchFolderURL.path) {
|
||||
model.chooseWatchFolder()
|
||||
if model.transport == .airDrop {
|
||||
GridRow(alignment: .center) {
|
||||
fieldLabel("Watch folder")
|
||||
folderValue(path: model.watchFolderURL.path) {
|
||||
model.chooseWatchFolder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .center) {
|
||||
fieldLabel("Output folder")
|
||||
folderValue(path: model.outboxURL.path) {
|
||||
model.chooseOutboxFolder()
|
||||
GridRow(alignment: .center) {
|
||||
fieldLabel("Output folder")
|
||||
folderValue(path: model.outboxURL.path) {
|
||||
model.chooseOutboxFolder()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GridRow(alignment: .center) {
|
||||
fieldLabel("OneDrive folder")
|
||||
if let folder = model.resolvedOneDriveFolder {
|
||||
folderValue(path: folder.path) {
|
||||
model.chooseOneDriveFolder()
|
||||
}
|
||||
} else {
|
||||
// One-line row, same shape as the normal path row: "Not found"
|
||||
// where the path would be, Choose… stays live. The explanation
|
||||
// moves to the caption below instead of wrapping this row.
|
||||
folderValue(path: "Not found") {
|
||||
model.chooseOneDriveFolder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text(
|
||||
model.resolvedOneDriveFolder != nil
|
||||
? "The PDF is saved here and this same folder is watched for the marked-up copy. On the iPad open it from Files > OneDrive."
|
||||
: "No OneDrive folder found. Sign in to OneDrive, or choose a folder."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.gridCellColumns(2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +117,10 @@ struct SettingsView: View {
|
||||
.onDisappear { disarmHotkeyRecorder() }
|
||||
}
|
||||
|
||||
private var transportBinding: Binding<SendTransport> {
|
||||
Binding(get: { model.transport }, set: { model.chooseTransport($0) })
|
||||
}
|
||||
|
||||
private func armHotkeyRecorder() {
|
||||
guard !isRecordingHotkey else { return }
|
||||
isRecordingHotkey = true
|
||||
@@ -190,6 +243,84 @@ 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.
|
||||
/// Refuses while a send is in flight (send() snapshots its own folder/transport, but
|
||||
/// switching mid-send is still confusing UX — nothing to gain by allowing it).
|
||||
/// The async reconcile below is generation-guarded: `reconcileGeneration` is bumped
|
||||
/// synchronously before the Task starts, and the Task checks its own snapshot against
|
||||
/// the live value before every mutating step, so rapid toggling (this function or
|
||||
/// chooseOneDriveFolder, in any order) always lets the LAST choice win instead of an
|
||||
/// earlier, superseded call applying its stale folder/flag after a later one already won.
|
||||
func chooseTransport(_ value: SendTransport) {
|
||||
guard !isSending else {
|
||||
setStatus("Finish the current send first.")
|
||||
return
|
||||
}
|
||||
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)
|
||||
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
|
||||
|
||||
reconcileGeneration += 1
|
||||
let generation = reconcileGeneration
|
||||
Task {
|
||||
guard generation == self.reconcileGeneration else { return }
|
||||
await watcher.setRecordUncommented(value == .airDrop)
|
||||
guard generation == self.reconcileGeneration else { return }
|
||||
do {
|
||||
try await watcher.updateWatchFolder(folders.watch)
|
||||
} catch {
|
||||
guard generation == self.reconcileGeneration else { return }
|
||||
setStatus(
|
||||
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses while a send is in flight, same reasoning as chooseTransport. See
|
||||
/// chooseTransport's doc comment for the generation-guard mechanism shared here.
|
||||
func chooseOneDriveFolder() {
|
||||
guard !isSending else {
|
||||
setStatus("Finish the current send first.")
|
||||
return
|
||||
}
|
||||
let start = resolvedOneDriveFolder ?? FileManager.default.homeDirectoryForCurrentUser
|
||||
guard let url = chooseDirectory(startingAt: start) else { return }
|
||||
TransportSettings.setOneDriveFolder(url)
|
||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
|
||||
guard transport == .oneDrive else { return }
|
||||
setFolderURLs(outbox: url, watch: url)
|
||||
|
||||
reconcileGeneration += 1
|
||||
let generation = reconcileGeneration
|
||||
Task {
|
||||
guard generation == self.reconcileGeneration else { return }
|
||||
do {
|
||||
try await watcher.updateWatchFolder(url)
|
||||
guard generation == self.reconcileGeneration else { return }
|
||||
setStatus("OneDrive folder set to \(url.lastPathComponent).")
|
||||
} catch {
|
||||
guard generation == self.reconcileGeneration else { return }
|
||||
setStatus(
|
||||
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseDirectory(startingAt directory: URL) -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
|
||||
@@ -7,7 +7,14 @@ enum Sharing {
|
||||
/// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created,
|
||||
/// `canPerform` is false, or `view` is not in a visible window (a detached view
|
||||
/// never produces an on-screen sheet).
|
||||
static func airDrop(fileURL: URL, from view: NSView) throws {
|
||||
///
|
||||
/// `onFinished` is invoked on the main actor when the sheet completes: `true` for
|
||||
/// `didShareItems`, `false` for `didFailToShareItems` (including user cancel).
|
||||
static func airDrop(
|
||||
fileURL: URL,
|
||||
from view: NSView,
|
||||
onFinished: @escaping @MainActor @Sendable (Bool) async -> Void
|
||||
) throws {
|
||||
guard let service = NSSharingService(named: .sendViaAirDrop),
|
||||
service.canPerform(withItems: [fileURL]) else {
|
||||
throw ShotdeckError.airDropUnavailable
|
||||
@@ -21,7 +28,12 @@ enum Sharing {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
|
||||
service.subject = fileURL.lastPathComponent
|
||||
let session = AirDropSession(service: service, window: window, view: view)
|
||||
let session = AirDropSession(
|
||||
service: service,
|
||||
window: window,
|
||||
view: view,
|
||||
onFinished: onFinished
|
||||
)
|
||||
AirDropSession.keepAlive(session)
|
||||
service.delegate = session
|
||||
service.perform(withItems: [fileURL])
|
||||
@@ -29,7 +41,9 @@ enum Sharing {
|
||||
}
|
||||
|
||||
/// Retains the sharing service for the life of the picker and supplies the real
|
||||
/// on-screen window as the sheet parent. `NSSharingService.delegate` is weak.
|
||||
/// on-screen window as the sheet parent. `NSSharingService.delegate` is weak, so
|
||||
/// `live` is the strong reference that keeps this object alive until the sheet
|
||||
/// reports success or failure (including cancel).
|
||||
@MainActor
|
||||
private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
||||
static var live: [AirDropSession] = []
|
||||
@@ -37,11 +51,19 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
||||
let service: NSSharingService
|
||||
let window: NSWindow
|
||||
let view: NSView
|
||||
let onFinished: @MainActor @Sendable (Bool) async -> Void
|
||||
private var reported = false
|
||||
|
||||
init(service: NSSharingService, window: NSWindow, view: NSView) {
|
||||
init(
|
||||
service: NSSharingService,
|
||||
window: NSWindow,
|
||||
view: NSView,
|
||||
onFinished: @escaping @MainActor @Sendable (Bool) async -> Void
|
||||
) {
|
||||
self.service = service
|
||||
self.window = window
|
||||
self.view = view
|
||||
self.onFinished = onFinished
|
||||
}
|
||||
|
||||
static func keepAlive(_ session: AirDropSession) {
|
||||
@@ -52,6 +74,15 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
||||
Self.live.removeAll { $0 === self }
|
||||
}
|
||||
|
||||
private func report(_ success: Bool) {
|
||||
guard !reported else { return }
|
||||
reported = true
|
||||
Task { @MainActor in
|
||||
await self.onFinished(success)
|
||||
self.drop()
|
||||
}
|
||||
}
|
||||
|
||||
func sharingService(
|
||||
_ sharingService: NSSharingService,
|
||||
sourceWindowForShareItems items: [Any],
|
||||
@@ -70,7 +101,7 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
||||
}
|
||||
|
||||
func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
|
||||
drop()
|
||||
report(true)
|
||||
}
|
||||
|
||||
func sharingService(
|
||||
@@ -78,6 +109,6 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
||||
didFailToShareItems items: [Any],
|
||||
error: any Error
|
||||
) {
|
||||
drop()
|
||||
report(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import ShotdeckCore
|
||||
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
||||
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" {
|
||||
MainActor.assumeIsolated { PickerSelfTest.runOneDriveOnlyIfRequested() }
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
||||
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
||||
}
|
||||
@@ -47,9 +50,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
Task { await model.bootstrap() }
|
||||
}
|
||||
|
||||
private static func makeLaunchModel() -> AppModel {
|
||||
/// Builds the model exactly the way the real app launches: paths come from
|
||||
/// `TransportSettings.resolvedAppSupportPaths()` — transport-aware, so the watcher
|
||||
/// this feeds is never seeded with a stale AirDrop folder while OneDrive is the
|
||||
/// persisted transport (that was the BLOCKER this function used to have, when it
|
||||
/// called the AirDrop-only `FolderSettings.resolvedAppSupportPaths()` instead).
|
||||
/// `appSupportRoot` exists only so PickerSelfTest's relaunch-simulation sub-step can
|
||||
/// point this at a temp directory instead of the real
|
||||
/// ~/Library/Application Support/Shotdeck — production always calls this with no
|
||||
/// argument (the real root). Internal, not private, for that same reason.
|
||||
static func makeLaunchModel(appSupportRoot: URL? = nil) -> AppModel {
|
||||
do {
|
||||
let paths = try FolderSettings.resolvedAppSupportPaths()
|
||||
let paths = try TransportSettings.resolvedAppSupportPaths(root: appSupportRoot)
|
||||
return try makeModel(paths: paths)
|
||||
} catch {
|
||||
Log.ui.critical(
|
||||
|
||||
@@ -10,6 +10,7 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
|
||||
case pdfCompositionFailed(reason: String)
|
||||
case airDropUnavailable
|
||||
case noCommentedReturns
|
||||
case oneDriveFolderUnavailable(path: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -31,6 +32,8 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
|
||||
return "AirDrop is not available right now."
|
||||
case .noCommentedReturns:
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ public struct PDFComposer: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public static func fileName(for session: CaptureSession) -> String {
|
||||
"Redline-\(DubaiTime.fileStamp(session.createdAt)).pdf"
|
||||
public static func fileName(for _: CaptureSession) -> String {
|
||||
"Redline-\(DubaiTime.fileStamp(Date())).pdf"
|
||||
}
|
||||
|
||||
private static func writePDF(
|
||||
|
||||
@@ -10,6 +10,12 @@ public actor ReturnWatcher {
|
||||
private var bridge: FSEventBridge?
|
||||
private var pendingScanTask: Task<Void, Never>?
|
||||
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
|
||||
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
|
||||
@@ -29,6 +35,12 @@ public actor ReturnWatcher {
|
||||
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
|
||||
/// when never started or already stopped. Cancels any pending debounced scan.
|
||||
public func stop() {
|
||||
@@ -77,6 +89,7 @@ public actor ReturnWatcher {
|
||||
guard let document = PDFDocument(url: url),
|
||||
AnnotationInspector.isShotdeckDocument(document) else { continue }
|
||||
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
|
||||
if !recordUncommented, !inspected.isCommented { continue }
|
||||
try await ledger.record(inspected)
|
||||
results.append(inspected)
|
||||
}
|
||||
|
||||
@@ -11,12 +11,6 @@ public struct AppSupportPaths: Sendable {
|
||||
/// Production paths.
|
||||
public static func standard() throws -> AppSupportPaths {
|
||||
let fileManager = FileManager.default
|
||||
let appSupportParent = try fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let desktop = try fileManager.url(
|
||||
for: .desktopDirectory,
|
||||
in: .userDomainMask,
|
||||
@@ -29,10 +23,25 @@ public struct AppSupportPaths: Sendable {
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let root = appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true)
|
||||
let root = try standardRoot(fileManager: fileManager)
|
||||
return try AppSupportPaths(root: root, outbox: desktop, watchFolder: downloads)
|
||||
}
|
||||
|
||||
/// The standard `~/Library/Application Support/Shotdeck` root. Shared by
|
||||
/// `standard()`, `FolderSettings.resolvedAppSupportPaths()`, and
|
||||
/// `TransportSettings.resolvedAppSupportPaths()` so all three agree on where the
|
||||
/// root lives — the folder-resolution logic (AirDrop-only vs transport-aware)
|
||||
/// differs between those, the root computation never should.
|
||||
public static func standardRoot(fileManager: FileManager = .default) throws -> URL {
|
||||
let appSupportParent = try fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
return appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true)
|
||||
}
|
||||
|
||||
/// Test paths rooted anywhere. Every directory is created if missing.
|
||||
public init(root: URL, outbox: URL, watchFolder: URL) throws {
|
||||
self.root = root
|
||||
|
||||
@@ -70,15 +70,7 @@ public enum FolderSettings {
|
||||
defaults: UserDefaults = .standard,
|
||||
fileManager: FileManager = .default
|
||||
) throws -> AppSupportPaths {
|
||||
let resolvedRoot: URL
|
||||
if let root {
|
||||
resolvedRoot = root
|
||||
} else {
|
||||
let appSupportParent = try fileManager.url(
|
||||
for: .applicationSupportDirectory, in: .userDomainMask,
|
||||
appropriateFor: nil, create: true)
|
||||
resolvedRoot = appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true)
|
||||
}
|
||||
let resolvedRoot = try root ?? AppSupportPaths.standardRoot(fileManager: fileManager)
|
||||
let folders = resolve(defaults: defaults, fileManager: fileManager)
|
||||
return try AppSupportPaths(root: resolvedRoot, outbox: folders.outbox, watchFolder: folders.watch)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an `AppSupportPaths` using `root` (defaults to the standard
|
||||
/// `~/Library/Application Support/Shotdeck` when nil) plus whatever
|
||||
/// `effectiveFolders()` returns for outbox/watch. Unlike
|
||||
/// `FolderSettings.resolvedAppSupportPaths()` (AirDrop-only), this is
|
||||
/// transport-aware — it is the ONLY function launch code should use to build its
|
||||
/// paths, so the watcher it feeds is never seeded with a stale AirDrop folder while
|
||||
/// OneDrive is the persisted transport. `root` is exposed purely so tests (and the
|
||||
/// ONEDRIVE-SELFTEST relaunch simulation) can point it at a temporary directory
|
||||
/// instead of the user's real Application Support folder.
|
||||
public static func resolvedAppSupportPaths(
|
||||
root: URL? = nil,
|
||||
defaults: UserDefaults = .standard,
|
||||
fileManager: FileManager = .default
|
||||
) throws -> AppSupportPaths {
|
||||
let resolvedRoot = try root ?? AppSupportPaths.standardRoot(fileManager: fileManager)
|
||||
let folders = effectiveFolders(defaults: defaults, fileManager: fileManager)
|
||||
return try AppSupportPaths(root: resolvedRoot, outbox: folders.outbox, watchFolder: folders.watch)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// True when `url` exists as a directory AND is writable by the current process.
|
||||
/// The live check `send(anchor:)` performs before ever composing into a OneDrive
|
||||
/// destination — a directory that exists but has had its permissions revoked (e.g.
|
||||
/// `chmod 500`) must be treated as unavailable, not silently attempted and
|
||||
/// surfaced as a generic PDF-composition failure.
|
||||
public static func isWritableDirectory(
|
||||
at url: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> Bool {
|
||||
var isDirectory: ObjCBool = false
|
||||
let exists = fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
||||
guard exists, isDirectory.boolValue else { return false }
|
||||
return fileManager.isWritableFile(atPath: url.path)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -203,6 +203,17 @@ private func pixelWindow(
|
||||
return (pixelX0, pixelY0, pixelX1, pixelY1)
|
||||
}
|
||||
|
||||
@Test("fileName uses compose time, not session.createdAt, and matches Redline-yyyyMMdd-HHmmss.pdf")
|
||||
func fileNameUsesComposeTimeNotSessionCreatedAt() {
|
||||
let old = Date(timeIntervalSince1970: 1_600_000_000) // 2020-09-13
|
||||
let session = makeSession(captures: [], createdAt: old)
|
||||
let name = PDFComposer.fileName(for: session)
|
||||
#expect(name.wholeMatch(of: /^Redline-\d{8}-\d{6}\.pdf$/) != nil)
|
||||
#expect(!name.contains(DubaiTime.fileStamp(old)))
|
||||
let today = String(DubaiTime.fileStamp(Date()).prefix(8))
|
||||
#expect(name.contains(today))
|
||||
}
|
||||
|
||||
@Test("Three-page basic compose")
|
||||
func threePageBasicCompose() throws {
|
||||
let directory = try makeScratchDirectory()
|
||||
|
||||
@@ -230,3 +230,162 @@ func w27_fsEventsCallbackFiresOnRealArrival() async throws {
|
||||
await watcher.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("recordUncommented defaults to true: an unmarked PDF is still recorded (AirDrop behaviour unchanged)")
|
||||
func recordUncommentedDefaultTrueRecordsAnUnmarkedPDF() 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("recordUncommented=false: an unmarked PDF is not recorded or returned; marking it up in place gets it recorded")
|
||||
func recordUncommentedFalseSkipsUnmarkedThenRecordsAfterInPlaceMarkup() 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)
|
||||
}
|
||||
|
||||
// MARK: - Launch-paths BLOCKER regression (adversarial review, 20260905)
|
||||
//
|
||||
// The bug: AppDelegate.makeLaunchModel() built `paths` via the AirDrop-only
|
||||
// FolderSettings.resolvedAppSupportPaths(), so ReturnWatcher's internal watchFolder
|
||||
// (seeded from paths.watchFolder in its own init) was the AirDrop folder even when
|
||||
// OneDrive was the persisted transport, and bootstrap() never reconciled it before
|
||||
// starting. Net effect: PDFs went to OneDrive but FSEvents kept watching the stale
|
||||
// AirDrop folder for the whole session — marked-up returns were never detected.
|
||||
// The fix: launch paths now come from TransportSettings.resolvedAppSupportPaths()
|
||||
// (transport-aware), and AppModel.bootstrap() unconditionally reconciles the watcher's
|
||||
// folder via updateWatchFolder() before it starts. These two tests characterize the
|
||||
// bug (still reproducible via the old AirDrop-only construction) and prove the fix
|
||||
// (the real launch-construction path, end to end).
|
||||
|
||||
@Test("Launch regression (fix): OneDrive persisted -> transport-aware launch paths -> bootstrap-style reconcile -> a marked PDF is detected")
|
||||
func launchStyleConstructionWithOneDriveTransportDetectsAMarkedReturn() async throws {
|
||||
let suite = try makeTransportDefaultsSuite()
|
||||
defer { tearDownTransportSuite(suite) }
|
||||
let oneDriveFolder = try makeTransportTemporaryDirectory(prefix: "shotdeck-launch-onedrive")
|
||||
defer { try? FileManager.default.removeItem(at: oneDriveFolder) }
|
||||
let appSupportRoot = try makeTransportTemporaryDirectory(prefix: "shotdeck-launch-approot")
|
||||
defer { try? FileManager.default.removeItem(at: appSupportRoot) }
|
||||
|
||||
TransportSettings.setTransport(.oneDrive, defaults: suite.defaults)
|
||||
TransportSettings.setOneDriveFolder(oneDriveFolder, defaults: suite.defaults)
|
||||
|
||||
// Exactly what AppDelegate.makeLaunchModel() now does: build launch paths from the
|
||||
// transport-aware resolver — the fix, NOT FolderSettings.resolvedAppSupportPaths(),
|
||||
// which is AirDrop-only and is the root cause the next test characterizes.
|
||||
let paths = try TransportSettings.resolvedAppSupportPaths(
|
||||
root: appSupportRoot, defaults: suite.defaults, fileManager: .default
|
||||
)
|
||||
#expect(paths.outbox.path == oneDriveFolder.path)
|
||||
#expect(paths.watchFolder.path == oneDriveFolder.path)
|
||||
|
||||
let ledger = try ReturnLedger(paths: paths)
|
||||
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||
|
||||
// What AppModel.bootstrap() now does, unconditionally, before watcher.start():
|
||||
await watcher.setRecordUncommented(false) // transport == .oneDrive
|
||||
try await watcher.updateWatchFolder(paths.watchFolder)
|
||||
|
||||
let pdfURL = oneDriveFolder.appendingPathComponent("Redline-20260905-100000.pdf")
|
||||
try makePDF(
|
||||
at: pdfURL, pageCount: 1, creator: "Redline",
|
||||
annotations: [(page: 0, annotation: makeAnnotation(
|
||||
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
|
||||
))]
|
||||
)
|
||||
|
||||
let found = try await watcher.scanNow()
|
||||
#expect(found.contains(where: {
|
||||
$0.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path && $0.isCommented
|
||||
}))
|
||||
|
||||
let commented = try await ledger.commented()
|
||||
#expect(commented.contains(where: {
|
||||
$0.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path
|
||||
}))
|
||||
}
|
||||
|
||||
@Test("Launch regression (characterizes the bug): AirDrop-only launch paths with no reconcile miss an OneDrive-mode return")
|
||||
func airDropOnlyLaunchPathsWithoutReconcileMissesAMarkedOneDriveReturn() async throws {
|
||||
let suite = try makeTransportDefaultsSuite()
|
||||
defer { tearDownTransportSuite(suite) }
|
||||
let oneDriveFolder = try makeTransportTemporaryDirectory(prefix: "shotdeck-buggy-onedrive")
|
||||
defer { try? FileManager.default.removeItem(at: oneDriveFolder) }
|
||||
// A configured AirDrop watch-folder override, isolated to a temp dir — NOT the real
|
||||
// ~/Downloads, which may already hold real marked-up Redline PDFs from actual use
|
||||
// and would make this test's "found.isEmpty" assertion depend on the state of
|
||||
// Ben's real Downloads folder instead of the isolated fixture under test.
|
||||
let staleAirDropFolder = try makeTransportTemporaryDirectory(prefix: "shotdeck-buggy-airdrop-stale")
|
||||
defer { try? FileManager.default.removeItem(at: staleAirDropFolder) }
|
||||
let appSupportRoot = try makeTransportTemporaryDirectory(prefix: "shotdeck-buggy-approot")
|
||||
defer { try? FileManager.default.removeItem(at: appSupportRoot) }
|
||||
|
||||
TransportSettings.setTransport(.oneDrive, defaults: suite.defaults)
|
||||
TransportSettings.setOneDriveFolder(oneDriveFolder, defaults: suite.defaults)
|
||||
FolderSettings.setWatchFolder(staleAirDropFolder, defaults: suite.defaults)
|
||||
|
||||
// The BUG's exact construction: FolderSettings.resolvedAppSupportPaths() ignores
|
||||
// the persisted transport entirely and always resolves the AirDrop folders.
|
||||
let buggyPaths = try FolderSettings.resolvedAppSupportPaths(root: appSupportRoot, defaults: suite.defaults)
|
||||
#expect(buggyPaths.watchFolder.path == staleAirDropFolder.path)
|
||||
#expect(buggyPaths.watchFolder.path != oneDriveFolder.path)
|
||||
|
||||
let ledger = try ReturnLedger(paths: buggyPaths)
|
||||
let watcher = ReturnWatcher(paths: buggyPaths, ledger: ledger)
|
||||
// The old bootstrap(): recordUncommented was set, but there was NO
|
||||
// updateWatchFolder() call before start() to reconcile the folder.
|
||||
await watcher.setRecordUncommented(false)
|
||||
|
||||
let pdfURL = oneDriveFolder.appendingPathComponent("Redline-20260905-100100.pdf")
|
||||
try makePDF(
|
||||
at: pdfURL, pageCount: 1, creator: "Redline",
|
||||
annotations: [(page: 0, annotation: makeAnnotation(
|
||||
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
|
||||
))]
|
||||
)
|
||||
|
||||
// The watcher is still pointed at the stale (configured-AirDrop) watch folder, so
|
||||
// scanning it — NOT the OneDrive folder the PDF actually landed in — finds nothing.
|
||||
// This is the exact BLOCKER the fix above closes.
|
||||
let found = try await watcher.scanNow()
|
||||
#expect(found.isEmpty)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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))
|
||||
}
|
||||
|
||||
@Test
|
||||
func isWritableDirectoryTrueForAnOrdinaryWritableDirectory() throws {
|
||||
let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-writable")
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
#expect(OneDriveLocator.isWritableDirectory(at: dir))
|
||||
}
|
||||
|
||||
@Test
|
||||
func isWritableDirectoryFalseForAnExistingButUnwritableDirectory() throws {
|
||||
let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-unwritable")
|
||||
defer {
|
||||
// Restore perms BEFORE removal — an unwritable dir can't otherwise be cleaned up.
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: dir.path)
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
}
|
||||
#expect(OneDriveLocator.isWritableDirectory(at: dir)) // sanity check before chmod
|
||||
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: dir.path)
|
||||
#expect(!OneDriveLocator.isWritableDirectory(at: dir))
|
||||
}
|
||||
|
||||
@Test
|
||||
func isWritableDirectoryFalseForAPlainFileAndForANonexistentPath() throws {
|
||||
let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-writable-check-parent")
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
let filePath = dir.appendingPathComponent("plain-file.txt")
|
||||
FileManager.default.createFile(atPath: filePath.path, contents: Data("x".utf8))
|
||||
|
||||
#expect(!OneDriveLocator.isWritableDirectory(at: filePath))
|
||||
#expect(!OneDriveLocator.isWritableDirectory(at: dir.appendingPathComponent("does-not-exist")))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Executable
+287
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# One-command Redline release: bump Info.plist, commit, signed build, zip,
|
||||
# DMG, appcast, upload to mmd01, verify the public URLs.
|
||||
# Hidden flag: --test — upload under .../redline/test/ and skip the git commit.
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT}"
|
||||
|
||||
PLIST="${ROOT}/Info.plist"
|
||||
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)"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <version> [\"notes\"]" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
TEST_MODE=0
|
||||
VERSION=""
|
||||
NOTES=""
|
||||
NOTES_SET=0
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--test)
|
||||
TEST_MODE=1
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
;;
|
||||
--*)
|
||||
echo "Unknown argument: ${arg}" >&2
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
if [[ -z "${VERSION}" ]]; then
|
||||
VERSION="${arg}"
|
||||
elif [[ "${NOTES_SET}" -eq 0 ]]; then
|
||||
NOTES="${arg}"
|
||||
NOTES_SET=1
|
||||
else
|
||||
echo "Unexpected extra argument: ${arg}" >&2
|
||||
usage
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${VERSION}" ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)*$ ]]; then
|
||||
echo "Version '${VERSION}' is not a semver (e.g. 1.2.3 or 0.0.0-test)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${VERSION}" == *'/'* || "${VERSION}" == *'..'* ]]; then
|
||||
echo "Version contains illegal path characters: ${VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${TEST_MODE}" -eq 1 ]]; then
|
||||
REMOTE_DIR="${REMOTE_BASE}/test"
|
||||
PUBLIC_DIR="${PUBLIC_BASE}/test"
|
||||
else
|
||||
REMOTE_DIR="${REMOTE_BASE}"
|
||||
PUBLIC_DIR="${PUBLIC_BASE}"
|
||||
fi
|
||||
|
||||
ZIP_NAME="Redline-${VERSION}.zip"
|
||||
DMG_NAME="Redline-${VERSION}.dmg"
|
||||
ZIP_PATH="${ROOT}/.build/${ZIP_NAME}"
|
||||
DMG_PATH="${ROOT}/.build/Redline.dmg"
|
||||
APPCAST_PATH="${ROOT}/.build/appcast.json"
|
||||
ZIP_URL="${PUBLIC_DIR}/${ZIP_NAME}"
|
||||
APPCAST_URL="${PUBLIC_DIR}/appcast.json"
|
||||
|
||||
if [[ "${TEST_MODE}" -eq 1 ]]; then
|
||||
echo "==> Publish Redline ${VERSION} (test)"
|
||||
else
|
||||
echo "==> Publish Redline ${VERSION}"
|
||||
fi
|
||||
echo " remote: ${REMOTE_HOST}:${REMOTE_DIR}/"
|
||||
echo " public: ${PUBLIC_DIR}/"
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "Not inside a git work tree." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Tracked files must match HEAD. Untracked files are ignored so this script
|
||||
# can be dry-run (--test) before it is itself committed.
|
||||
if [[ -n "$(git status --porcelain -uno)" ]]; then
|
||||
echo "git tree is not clean; commit or stash before publishing." >&2
|
||||
git status --porcelain -uno >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -x "${PLISTBUDDY}" ]]; then
|
||||
echo "PlistBuddy not found at ${PLISTBUDDY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "${PLIST}" ]]; then
|
||||
echo "Info.plist not found at ${PLIST}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
restore_plist() {
|
||||
git checkout -- "${PLIST}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
if [[ "${TEST_MODE}" -eq 1 ]]; then
|
||||
trap restore_plist EXIT
|
||||
fi
|
||||
|
||||
CURRENT_BUILD="$("${PLISTBUDDY}" -c 'Print :CFBundleVersion' "${PLIST}")"
|
||||
if [[ ! "${CURRENT_BUILD}" =~ ^[0-9]+$ ]]; then
|
||||
echo "CFBundleVersion is not an integer: ${CURRENT_BUILD}" >&2
|
||||
exit 1
|
||||
fi
|
||||
NEW_BUILD=$((CURRENT_BUILD + 1))
|
||||
|
||||
echo "==> Bumping Info.plist"
|
||||
echo " CFBundleShortVersionString -> ${VERSION}"
|
||||
echo " CFBundleVersion ${CURRENT_BUILD} -> ${NEW_BUILD}"
|
||||
"${PLISTBUDDY}" -c "Set :CFBundleShortVersionString ${VERSION}" "${PLIST}"
|
||||
"${PLISTBUDDY}" -c "Set :CFBundleVersion ${NEW_BUILD}" "${PLIST}"
|
||||
|
||||
if [[ "${TEST_MODE}" -eq 0 ]]; then
|
||||
echo "==> Committing version bump on $(git rev-parse --abbrev-ref HEAD)"
|
||||
git add "${PLIST}"
|
||||
git commit -m "release: v${VERSION}"
|
||||
else
|
||||
echo "==> --test: skipping git commit of version bump"
|
||||
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() {
|
||||
if security find-identity -v -p codesigning 2>/dev/null | grep -Fq "${SIGN_IDENTITY}"; then
|
||||
echo "${HOME}"
|
||||
return
|
||||
fi
|
||||
local rh
|
||||
rh="$(dscl . -read "/Users/$(id -un)" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
|
||||
if [[ -n "${rh}" && -d "${rh}" ]]; then
|
||||
echo "${rh}"
|
||||
else
|
||||
echo "${HOME}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_signed() {
|
||||
local sign_home
|
||||
sign_home="$(signing_home)"
|
||||
if [[ "${sign_home}" != "${HOME}" ]]; then
|
||||
echo "==> Using HOME=${sign_home} so codesign can see the login keychain"
|
||||
fi
|
||||
HOME="${sign_home}" "$@"
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
|
||||
ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")"
|
||||
PUBDATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
echo "==> Zip SHA256: ${SHA256}"
|
||||
echo " Zip bytes: ${ZIP_BYTES}"
|
||||
|
||||
echo "==> Writing ${APPCAST_PATH}"
|
||||
python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
version, zip_url, sha256, notes, pub_date, out_path = sys.argv[1:]
|
||||
payload = {
|
||||
"version": version,
|
||||
"zipURL": zip_url,
|
||||
"sha256": sha256,
|
||||
"notes": notes,
|
||||
"pubDate": pub_date,
|
||||
}
|
||||
with open(out_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh, indent=2)
|
||||
fh.write("\n")
|
||||
PY
|
||||
|
||||
echo "==> Uploading to ${REMOTE_HOST}:${REMOTE_DIR}/"
|
||||
ssh -o BatchMode=yes "${REMOTE_HOST}" "mkdir -p '${REMOTE_DIR}'"
|
||||
rsync -e "ssh -o BatchMode=yes" -av "${ZIP_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${ZIP_NAME}"
|
||||
rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/Redline.dmg"
|
||||
rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${DMG_NAME}"
|
||||
rsync -e "ssh -o BatchMode=yes" -av "${APPCAST_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/appcast.json"
|
||||
ssh -o BatchMode=yes "${REMOTE_HOST}" \
|
||||
"chmod 644 \
|
||||
'${REMOTE_DIR}/${ZIP_NAME}' \
|
||||
'${REMOTE_DIR}/Redline.dmg' \
|
||||
'${REMOTE_DIR}/${DMG_NAME}' \
|
||||
'${REMOTE_DIR}/appcast.json'"
|
||||
|
||||
echo "==> Verifying public appcast ${APPCAST_URL}"
|
||||
APPCAST_BODY=""
|
||||
ok=0
|
||||
attempt=1
|
||||
while [[ "${attempt}" -le 15 ]]; do
|
||||
if APPCAST_BODY="$(curl -fsS "${APPCAST_URL}")"; then
|
||||
echo "${APPCAST_BODY}"
|
||||
if grep -F -q "${VERSION}" <<<"${APPCAST_BODY}"; then
|
||||
echo "OK: appcast contains ${VERSION}"
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "appcast fetched but does not contain '${VERSION}' (attempt ${attempt})" >&2
|
||||
else
|
||||
echo "appcast fetch failed (attempt ${attempt})" >&2
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 2
|
||||
done
|
||||
if [[ "${ok}" -ne 1 ]]; then
|
||||
echo "Public appcast verification failed for ${APPCAST_URL}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Verifying public zip HEAD ${ZIP_URL}"
|
||||
ok=0
|
||||
attempt=1
|
||||
HEAD_OUT=""
|
||||
while [[ "${attempt}" -le 15 ]]; do
|
||||
HEAD_OUT="$(curl -sS -D - -o /dev/null -I "${ZIP_URL}" || true)"
|
||||
echo "${HEAD_OUT}"
|
||||
HTTP_CODE="$(awk 'BEGIN{c=""} toupper($1) ~ /^HTTP\//{c=$2} END{print c}' <<<"${HEAD_OUT}" | tr -d '\r')"
|
||||
CONTENT_LENGTH="$(awk 'tolower($1)=="content-length:" {gsub("\r","",$2); print $2}' <<<"${HEAD_OUT}" | tail -n 1)"
|
||||
if [[ "${HTTP_CODE}" == "200" && "${CONTENT_LENGTH}" == "${ZIP_BYTES}" ]]; then
|
||||
echo "OK: zip HTTP ${HTTP_CODE}, Content-Length ${CONTENT_LENGTH} matches local ${ZIP_BYTES}"
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "zip HEAD mismatch (attempt ${attempt}): HTTP '${HTTP_CODE}', Content-Length '${CONTENT_LENGTH}', local '${ZIP_BYTES}'" >&2
|
||||
attempt=$((attempt + 1))
|
||||
sleep 2
|
||||
done
|
||||
if [[ "${ok}" -ne 1 ]]; then
|
||||
echo "Public zip verification failed for ${ZIP_URL}" >&2
|
||||
exit 1
|
||||
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"
|
||||
Reference in New Issue
Block a user