Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- Identity invariants: CFBundleIdentifier stays ai.flowmaster.shotdeck and
|
<key>CFBundleExecutable</key>
|
||||||
CFBundleExecutable stays Shotdeck. Changing either one invalidates the
|
<string>Shotdeck</string>
|
||||||
user's existing Screen Recording grant. CFBundleName is the user-facing
|
<key>CFBundleIconFile</key>
|
||||||
product name only. -->
|
<string>AppIcon</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>ai.flowmaster.shotdeck</string>
|
<string>ai.flowmaster.shotdeck</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Redline</string>
|
<string>Redline</string>
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>Shotdeck</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.2.0</string>
|
<string>0.2.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>2</string>
|
||||||
<key>CFBundleIconFile</key>
|
|
||||||
<string>AppIcon</string>
|
|
||||||
<key>LSUIElement</key>
|
|
||||||
<true/>
|
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>14.0</string>
|
<string>14.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>Copyright © 2026 Flowmaster FZC LLC. All rights reserved.</string>
|
<string>Copyright © 2026 Flowmaster FZC LLC. All rights reserved.</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -32,10 +32,15 @@ public final class AppModel {
|
|||||||
public private(set) var isSending: Bool = false
|
public private(set) var isSending: Bool = false
|
||||||
public private(set) var outboxDisplayName: String
|
public private(set) var outboxDisplayName: String
|
||||||
public private(set) var watchFolderDisplayName: String
|
public private(set) var watchFolderDisplayName: String
|
||||||
|
/// Live transport choice; WP-onedrive reads this to pick the send path and to drive
|
||||||
|
/// the Settings "Send via" picker and the menu's "Send…" label.
|
||||||
|
public private(set) var transport: SendTransport
|
||||||
/// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect.
|
/// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect.
|
||||||
public private(set) var outboxURL: URL
|
public private(set) var outboxURL: URL
|
||||||
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
|
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
|
||||||
public private(set) var watchFolderURL: URL
|
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).
|
/// Currently bound capture combo (the last one Carbon accepted, or the preferred load).
|
||||||
private(set) var captureHotkey: HotkeyPreference
|
private(set) var captureHotkey: HotkeyPreference
|
||||||
var hotkeyDisplayString: String { captureHotkey.displayString }
|
var hotkeyDisplayString: String { captureHotkey.displayString }
|
||||||
@@ -79,12 +84,15 @@ public final class AppModel {
|
|||||||
)
|
)
|
||||||
self.region = Self.loadPersistedRegion()
|
self.region = Self.loadPersistedRegion()
|
||||||
self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
|
self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted
|
||||||
// Seeded from FolderSettings.resolve() via resolvedAppSupportPaths — never .standard().
|
// Seeded from TransportSettings.effectiveFolders() — the one place that combines
|
||||||
let folders = FolderSettings.resolve()
|
// the transport choice with FolderSettings/OneDriveLocator. Never call
|
||||||
|
// FolderSettings.resolve() directly outside that function.
|
||||||
|
let folders = TransportSettings.effectiveFolders()
|
||||||
self.outboxURL = folders.outbox
|
self.outboxURL = folders.outbox
|
||||||
self.watchFolderURL = folders.watch
|
self.watchFolderURL = folders.watch
|
||||||
self.outboxDisplayName = folders.outbox.lastPathComponent
|
self.outboxDisplayName = folders.outbox.lastPathComponent
|
||||||
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
||||||
|
self.transport = folders.transport
|
||||||
self.captureHotkey = HotkeyPreference.load()
|
self.captureHotkey = HotkeyPreference.load()
|
||||||
self.updateChecker = UpdateChecker()
|
self.updateChecker = UpdateChecker()
|
||||||
self.updateChecker.onChecked = { [weak self] in
|
self.updateChecker.onChecked = { [weak self] in
|
||||||
@@ -116,6 +124,43 @@ public final class AppModel {
|
|||||||
watchFolderURL = watch
|
watchFolderURL = watch
|
||||||
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
|
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
|
||||||
}
|
}
|
||||||
|
func setTransport(_ value: SendTransport) { transport = value }
|
||||||
|
func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url }
|
||||||
|
|
||||||
|
/// 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 {
|
public var iconState: MenuIconState {
|
||||||
if !screenRecordingGranted { return .recordingMissing }
|
if !screenRecordingGranted { return .recordingMissing }
|
||||||
@@ -147,6 +192,16 @@ public final class AppModel {
|
|||||||
// Empty ledger on first run is not an error.
|
// Empty ledger on first run is not an error.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OneDrive mode: outbox == watch folder, so a freshly written, unmarked PDF must
|
||||||
|
// never show up as a return; only a document that already carries a mark does.
|
||||||
|
// Also make sure the resolved OneDrive folder actually exists before the
|
||||||
|
// watcher starts watching it (bootstrap is the other creation trigger besides
|
||||||
|
// chooseTransport/chooseOneDriveFolder — see TransportSettings.effectiveFolders).
|
||||||
|
if transport == .oneDrive {
|
||||||
|
try? FileManager.default.createDirectory(at: outboxURL, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
await watcher.setRecordUncommented(transport == .airDrop)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await watcher.start { [weak self] _ in
|
try await watcher.start { [weak self] _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -170,6 +225,8 @@ public final class AppModel {
|
|||||||
ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
|
ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
|
||||||
|| ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
|
|| ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
|
||||||
|| ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|
|| ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|
||||||
|
|| ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil
|
||||||
|
|| ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] != nil
|
||||||
if !skipSchedule {
|
if !skipSchedule {
|
||||||
updateChecker.startSchedule()
|
updateChecker.startSchedule()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,10 +91,17 @@ struct MenuBarView: View {
|
|||||||
model.setStatus("Send is not available in this build.")
|
model.setStatus("Send is not available in this build.")
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
actionLabel("Send…")
|
actionLabel(model.transport == .oneDrive ? "Send to OneDrive" : "Send…")
|
||||||
}
|
}
|
||||||
.disabled(model.session.isEmpty || model.isSending)
|
.disabled(model.session.isEmpty || model.isSending)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
model.revealLastPDF()
|
||||||
|
} label: {
|
||||||
|
actionLabel("Reveal last PDF")
|
||||||
|
}
|
||||||
|
.disabled(!model.canRevealLastPDF)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
Task { await model.captureNow() }
|
Task { await model.captureNow() }
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import CoreGraphics
|
||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import PDFKit
|
||||||
import ShotdeckCore
|
import ShotdeckCore
|
||||||
|
|
||||||
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
||||||
@@ -113,10 +116,10 @@ enum PickerSelfTest {
|
|||||||
fflush(stdout)
|
fflush(stdout)
|
||||||
|
|
||||||
runRegionPersistPhase()
|
runRegionPersistPhase()
|
||||||
if !startUpdateSelfTestIfRequested() {
|
// Hop off this MainActor job so the SEND-TRUTH Task can run; do not
|
||||||
exit(0)
|
// exit(0) here — runSendTruthPhase prints its own PASS/FAIL, then
|
||||||
}
|
// chains to UPDATE-SELFTEST (or exits if that phase is not requested).
|
||||||
// UPDATE-SELFTEST hops to a later main-actor turn and exits itself.
|
runSendTruthPhase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through
|
/// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through
|
||||||
@@ -160,7 +163,145 @@ enum PickerSelfTest {
|
|||||||
fflush(stdout)
|
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()
|
||||||
|
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`.
|
/// `checkNow`, then `installStaged` into the env dir — never `/Applications`.
|
||||||
/// Returns true when the async phase was scheduled (it calls `exit` itself).
|
/// Returns true when the async phase was scheduled (it calls `exit` itself).
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -175,7 +316,9 @@ enum PickerSelfTest {
|
|||||||
try await runUpdateSelfTest(outputDirectory: output)
|
try await runUpdateSelfTest(outputDirectory: output)
|
||||||
print("UPDATE-SELFTEST PASS version=99.0.0")
|
print("UPDATE-SELFTEST PASS version=99.0.0")
|
||||||
fflush(stdout)
|
fflush(stdout)
|
||||||
|
if !startOneDriveSelfTestIfRequested() {
|
||||||
exit(0)
|
exit(0)
|
||||||
|
}
|
||||||
} catch let error as UpdateSelfTestError {
|
} catch let error as UpdateSelfTestError {
|
||||||
updateFail(error.description)
|
updateFail(error.description)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -333,6 +476,194 @@ enum PickerSelfTest {
|
|||||||
exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Real sync root this Mac has; the phase proves the transport against the actual
|
||||||
|
/// OneDrive file provider, never a fake home tree (that is what
|
||||||
|
/// OneDriveLocatorTests in ShotdeckCoreTests are for).
|
||||||
|
private static let realOneDriveSyncRoot = URL(
|
||||||
|
fileURLWithPath: "/Users/benjaminhippler/Library/CloudStorage/OneDrive-MMDGROUP",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Phase 5: proves the OneDrive transport end to end against the real sync root.
|
||||||
|
/// Triggered by `SHOTDECK_ONEDRIVE_SELFTEST` when chained after PICKER/SEND-TRUTH/
|
||||||
|
/// UPDATE-SELFTEST — the exact pattern `startUpdateSelfTestIfRequested` uses for its
|
||||||
|
/// own env var. Returns true when the async phase was scheduled (it calls `exit` itself).
|
||||||
|
@discardableResult
|
||||||
|
private static func startOneDriveSelfTestIfRequested() -> Bool {
|
||||||
|
guard ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
runOneDriveSelfTestAndExit()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point for running ONLY this phase, bypassing the on-screen picker chain
|
||||||
|
/// entirely. The harness has no other per-phase selector, so this is the escape
|
||||||
|
/// hatch: `REDLINE_SELFTEST_PHASE=onedrive`.
|
||||||
|
static func runOneDriveOnlyIfRequested() {
|
||||||
|
guard ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same hop as runIfRequested(): a plain main-queue turn after NSApp starts, so
|
||||||
|
// AppKit/PDFKit calls inside the phase are not racing app launch.
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
runOneDriveSelfTestAndExit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runOneDriveSelfTestAndExit() {
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let folder = try await executeOneDriveSelfTest()
|
||||||
|
print("ONEDRIVE-SELFTEST PASS path=\(folder.path)")
|
||||||
|
fflush(stdout)
|
||||||
|
exit(0)
|
||||||
|
} catch let error as OneDriveSelfTestError {
|
||||||
|
oneDriveFail(error.description)
|
||||||
|
} catch {
|
||||||
|
oneDriveFail(String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a session, sends it through the OneDrive branch of `send(anchor: nil)`
|
||||||
|
/// against a NEW folder under the real OneDrive sync root, confirms the watcher does
|
||||||
|
/// NOT report the freshly-written unmarked PDF as a return, then adds a real PDFKit
|
||||||
|
/// ink annotation in place (what the iPad does) and confirms the watcher now reports
|
||||||
|
/// it as commented. Never deletes anything under OneDrive — the created folder and
|
||||||
|
/// PDF are left in place for Ben to inspect / for the real iPad round trip.
|
||||||
|
private static func executeOneDriveSelfTest() async throws -> URL {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: realOneDriveSyncRoot.path) else {
|
||||||
|
throw OneDriveSelfTestError.detail(
|
||||||
|
"real OneDrive sync root not found at \(realOneDriveSyncRoot.path)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDefaults.standard is the ONLY defaults instance send()/TransportSettings
|
||||||
|
// actually read at runtime (there is no defaults-threading through AppModel), so
|
||||||
|
// "isolated" here means snapshot-and-restore around the real keys — the same
|
||||||
|
// pattern runRegionPersistPhase already uses for CaptureRegion.defaultsKey.
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let previousTransport = defaults.string(forKey: TransportSettings.transportDefaultsKey)
|
||||||
|
let previousFolder = defaults.string(forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||||
|
defer {
|
||||||
|
if let previousTransport {
|
||||||
|
defaults.set(previousTransport, forKey: TransportSettings.transportDefaultsKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: TransportSettings.transportDefaultsKey)
|
||||||
|
}
|
||||||
|
if let previousFolder {
|
||||||
|
defaults.set(previousFolder, forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stamp = DubaiTime.fileStamp(Date())
|
||||||
|
let selftestFolder = realOneDriveSyncRoot
|
||||||
|
.appendingPathComponent("Redline-selftest-\(stamp)", isDirectory: true)
|
||||||
|
try fm.createDirectory(at: selftestFolder, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
TransportSettings.setTransport(.oneDrive, defaults: defaults)
|
||||||
|
TransportSettings.setOneDriveFolder(selftestFolder, defaults: defaults)
|
||||||
|
|
||||||
|
// Local spool root only — the outbox/watch folder is the real OneDrive folder.
|
||||||
|
let spoolRoot = fm.temporaryDirectory
|
||||||
|
.appendingPathComponent("shotdeck-onedrive-selftest-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
defer { try? fm.removeItem(at: spoolRoot) }
|
||||||
|
|
||||||
|
let paths = try AppSupportPaths(root: spoolRoot, outbox: selftestFolder, watchFolder: selftestFolder)
|
||||||
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
|
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||||
|
await watcher.setRecordUncommented(false) // OneDrive mode: today's default is AirDrop's `true`.
|
||||||
|
|
||||||
|
let model = AppModel(
|
||||||
|
paths: paths,
|
||||||
|
spool: try SpoolStore(paths: paths),
|
||||||
|
composer: PDFComposer(),
|
||||||
|
capturer: ScreenCapturer(),
|
||||||
|
hotkeys: HotkeyCenter(),
|
||||||
|
picker: RegionPickerController(),
|
||||||
|
ledger: ledger,
|
||||||
|
watcher: watcher
|
||||||
|
)
|
||||||
|
model.setFolderURLs(outbox: selftestFolder, watch: selftestFolder)
|
||||||
|
model.setTransport(.oneDrive)
|
||||||
|
|
||||||
|
let png = try makeTinyPNGData()
|
||||||
|
_ = try await model.spool.append(
|
||||||
|
pngData: png, pixelWidth: 64, pixelHeight: 48, scale: 1, capturedAt: Date()
|
||||||
|
)
|
||||||
|
model.replaceSession(try await model.spool.currentSession())
|
||||||
|
guard !model.session.isEmpty else {
|
||||||
|
throw OneDriveSelfTestError.detail("seeded session was empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
await model.send(anchor: nil)
|
||||||
|
|
||||||
|
guard let status = model.statusLine, status.hasPrefix("Saved to OneDrive") else {
|
||||||
|
throw OneDriveSelfTestError.detail(
|
||||||
|
"status did not start with 'Saved to OneDrive': \(model.statusLine ?? "nil")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard model.session.isEmpty else {
|
||||||
|
throw OneDriveSelfTestError.detail("session was not archived after the OneDrive send")
|
||||||
|
}
|
||||||
|
|
||||||
|
let written = (try? fm.contentsOfDirectory(at: selftestFolder, includingPropertiesForKeys: nil)) ?? []
|
||||||
|
guard let pdfURL = written.first(where: { $0.pathExtension.lowercased() == "pdf" }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("no PDF found in \(selftestFolder.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarked so far: the watcher must not treat it as a return.
|
||||||
|
let beforeMarkup = try await watcher.scanNow()
|
||||||
|
guard !beforeMarkup.contains(where: { $0.fileURL == pdfURL }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("unmarked PDF was reported as returned by scanNow")
|
||||||
|
}
|
||||||
|
let commentedBefore = try await ledger.commented()
|
||||||
|
guard !commentedBefore.contains(where: { $0.fileURL == pdfURL }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("unmarked PDF was recorded as commented in the ledger")
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the iPad does: mark it up in place with a real ink annotation, then save.
|
||||||
|
guard let document = PDFDocument(url: pdfURL), let page = document.page(at: 0) else {
|
||||||
|
throw OneDriveSelfTestError.detail("could not reopen \(pdfURL.path) to annotate it")
|
||||||
|
}
|
||||||
|
let ink = PDFAnnotation(
|
||||||
|
bounds: CGRect(x: 20, y: 20, width: 60, height: 60),
|
||||||
|
forType: .ink,
|
||||||
|
withProperties: nil
|
||||||
|
)
|
||||||
|
let stroke = NSBezierPath()
|
||||||
|
stroke.move(to: NSPoint(x: 20, y: 20))
|
||||||
|
stroke.line(to: NSPoint(x: 80, y: 80))
|
||||||
|
ink.add(stroke)
|
||||||
|
page.addAnnotation(ink)
|
||||||
|
guard document.write(to: pdfURL) else {
|
||||||
|
throw OneDriveSelfTestError.detail("could not save the annotated PDF back to \(pdfURL.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let afterMarkup = try await watcher.scanNow()
|
||||||
|
guard let recorded = afterMarkup.first(where: { $0.fileURL == pdfURL }), recorded.isCommented else {
|
||||||
|
throw OneDriveSelfTestError.detail("annotated PDF was not reported as commented by scanNow")
|
||||||
|
}
|
||||||
|
let commentedAfter = try await ledger.commented()
|
||||||
|
guard commentedAfter.contains(where: { $0.fileURL == pdfURL }) else {
|
||||||
|
throw OneDriveSelfTestError.detail("annotated PDF was not recorded in the ledger as commented")
|
||||||
|
}
|
||||||
|
|
||||||
|
return selftestFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func oneDriveFail(_ detail: String) -> Never {
|
||||||
|
print("ONEDRIVE-SELFTEST FAIL \(detail)")
|
||||||
|
fflush(stdout)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
private static func interpolate(_ step: Int) -> NSPoint {
|
private static func interpolate(_ step: Int) -> NSPoint {
|
||||||
let t = CGFloat(step) / CGFloat(dragSteps)
|
let t = CGFloat(step) / CGFloat(dragSteps)
|
||||||
return NSPoint(
|
return NSPoint(
|
||||||
@@ -398,3 +729,12 @@ private enum UpdateSelfTestError: Error, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum OneDriveSelfTestError: Error, CustomStringConvertible {
|
||||||
|
case detail(String)
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .detail(let s): return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,103 @@ import Darwin
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ShotdeckCore
|
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 {
|
extension AppModel: SendCapable {
|
||||||
public func send(anchor: NSView?) async {
|
public func send(anchor: NSView?) async {
|
||||||
guard !session.isEmpty, !isSending else { return }
|
guard !session.isEmpty, !isSending else { return }
|
||||||
setSending(true)
|
setSending(true)
|
||||||
defer { setSending(false) }
|
|
||||||
|
|
||||||
|
let transport = TransportSettings.transport()
|
||||||
|
|
||||||
|
// OneDrive mode: verify the real destination exists RIGHT NOW, before composing
|
||||||
|
// anything. `outboxURL` is kept in sync with the resolved OneDrive folder by
|
||||||
|
// bootstrap/chooseTransport/chooseOneDriveFolder, but this is re-resolved fresh
|
||||||
|
// here (never trusted stale) so a folder that vanished since then (OneDrive
|
||||||
|
// signed out, external volume unmounted, folder deleted) is caught instead of
|
||||||
|
// silently writing into whatever `outboxURL` happens to hold.
|
||||||
|
if transport == .oneDrive {
|
||||||
|
guard let folder = OneDriveLocator.resolveOneDriveFolder(),
|
||||||
|
Self.directoryExists(at: folder)
|
||||||
|
else {
|
||||||
|
let path = OneDriveLocator.resolveOneDriveFolder()?.path
|
||||||
|
?? TransportSettings.storedOneDriveFolderPath()
|
||||||
|
?? "no OneDrive folder found"
|
||||||
|
setStatus(ShotdeckError.oneDriveFolderUnavailable(path: path).errorDescription)
|
||||||
|
setSending(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if outboxURL != folder || watchFolderURL != folder {
|
||||||
|
setFolderURLs(outbox: folder, watch: folder)
|
||||||
|
try? await watcher.updateWatchFolder(folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending: ComposedSend
|
||||||
|
do {
|
||||||
|
pending = try await composePDFForSend()
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func directoryExists(at url: URL) -> Bool {
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
||||||
|
return exists && isDirectory.boolValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the PDF to the outbox and records its path. Does not archive the session
|
||||||
|
/// and does not present AirDrop — that happens only after the share completes.
|
||||||
|
func composePDFForSend() async throws -> ComposedSend {
|
||||||
let workingSession = session
|
let workingSession = session
|
||||||
let composer = self.composer
|
let composer = self.composer
|
||||||
// Live outbox (FolderSettings), not `paths.outbox` — Settings changes take effect.
|
// Live outbox (FolderSettings), not `paths.outbox` — Settings changes take effect.
|
||||||
@@ -20,7 +111,6 @@ extension AppModel: SendCapable {
|
|||||||
let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf")
|
let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf")
|
||||||
let title = "Redline – \(DubaiTime.stamp(workingSession.createdAt))"
|
let title = "Redline – \(DubaiTime.stamp(workingSession.createdAt))"
|
||||||
|
|
||||||
do {
|
|
||||||
// D-13: build off the main actor. Only Sendable values cross into the
|
// D-13: build off the main actor. Only Sendable values cross into the
|
||||||
// detached task — never `anchor` (NSView is not Sendable).
|
// detached task — never `anchor` (NSView is not Sendable).
|
||||||
try await Task.detached(priority: .userInitiated) {
|
try await Task.detached(priority: .userInitiated) {
|
||||||
@@ -40,32 +130,35 @@ extension AppModel: SendCapable {
|
|||||||
try AtomicFile.fsyncDirectory(at: outboxDir)
|
try AtomicFile.fsyncDirectory(at: outboxDir)
|
||||||
}.value
|
}.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 {
|
guard FileManager.default.fileExists(atPath: finalURL.path) else {
|
||||||
throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk")
|
throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk")
|
||||||
}
|
}
|
||||||
_ = try await spool.archiveCurrent(pdfFileName: fileName)
|
rememberLastComposedPDF(finalURL)
|
||||||
replaceSession(try await spool.currentSession())
|
return ComposedSend(
|
||||||
|
fileName: fileName,
|
||||||
let pageWord = workingSession.captures.count == 1 ? "page" : "pages"
|
fileURL: finalURL,
|
||||||
setStatus("Sent — \(workingSession.captures.count) \(pageWord).")
|
pageCount: workingSession.captures.count
|
||||||
|
|
||||||
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)."
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `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 = pageCount == 1 ? "page" : "pages"
|
||||||
|
setStatus("Sent — \(pageCount) \(pageWord).")
|
||||||
} catch {
|
} catch {
|
||||||
// Never unlink the published PDF, and never unlink `tempURL` either:
|
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not archive the session.")
|
||||||
// a rename failure would leave the complete document at the temp name.
|
|
||||||
setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `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)
|
.frame(minHeight: 22)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GridRow {
|
||||||
|
Text("Send via")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.gridCellColumns(2)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
GridRow {
|
||||||
|
Picker("Send via", selection: transportBinding) {
|
||||||
|
ForEach(SendTransport.allCases, id: \.self) { transport in
|
||||||
|
Text(transport.displayName).tag(transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.gridCellColumns(2)
|
||||||
|
}
|
||||||
|
|
||||||
GridRow {
|
GridRow {
|
||||||
Text("Folders")
|
Text("Folders")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -41,6 +60,7 @@ struct SettingsView: View {
|
|||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if model.transport == .airDrop {
|
||||||
GridRow(alignment: .center) {
|
GridRow(alignment: .center) {
|
||||||
fieldLabel("Watch folder")
|
fieldLabel("Watch folder")
|
||||||
folderValue(path: model.watchFolderURL.path) {
|
folderValue(path: model.watchFolderURL.path) {
|
||||||
@@ -54,6 +74,36 @@ struct SettingsView: View {
|
|||||||
model.chooseOutboxFolder()
|
model.chooseOutboxFolder()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
GridRow(alignment: .center) {
|
||||||
|
fieldLabel("OneDrive folder")
|
||||||
|
if let folder = resolvedOneDriveFolder {
|
||||||
|
folderValue(path: folder.path) {
|
||||||
|
model.chooseOneDriveFolder()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("No OneDrive folder found — sign in to OneDrive or choose a folder.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Button("Choose…") { model.chooseOneDriveFolder() }
|
||||||
|
}
|
||||||
|
.frame(minHeight: 22)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GridRow {
|
||||||
|
Text(
|
||||||
|
"The PDF is saved here and this same folder is watched for the marked-up copy. On the iPad open it from Files > OneDrive."
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.gridCellColumns(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GridRow {
|
GridRow {
|
||||||
Button("Reveal spool folder") { model.openSpoolFolder() }
|
Button("Reveal spool folder") { model.openSpoolFolder() }
|
||||||
@@ -68,6 +118,17 @@ struct SettingsView: View {
|
|||||||
.onDisappear { disarmHotkeyRecorder() }
|
.onDisappear { disarmHotkeyRecorder() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var transportBinding: Binding<SendTransport> {
|
||||||
|
Binding(get: { model.transport }, set: { model.chooseTransport($0) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ground truth from OneDriveLocator, not `model.outboxURL` — the model may be
|
||||||
|
/// showing an AirDrop-folder fallback when no real OneDrive folder resolves, and
|
||||||
|
/// the Settings row must say so plainly rather than repeat that fallback path.
|
||||||
|
private var resolvedOneDriveFolder: URL? {
|
||||||
|
OneDriveLocator.resolveOneDriveFolder()
|
||||||
|
}
|
||||||
|
|
||||||
private func armHotkeyRecorder() {
|
private func armHotkeyRecorder() {
|
||||||
guard !isRecordingHotkey else { return }
|
guard !isRecordingHotkey else { return }
|
||||||
isRecordingHotkey = true
|
isRecordingHotkey = true
|
||||||
@@ -190,6 +251,53 @@ extension AppModel: SettingsWindowPresenting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings "Send via" picker action. Persists the choice, recomputes the effective
|
||||||
|
/// outbox/watch folder for the new transport, creates the OneDrive folder if it
|
||||||
|
/// doesn't exist yet, and re-points the running watcher (folder + recordUncommented)
|
||||||
|
/// at the new state. Switching back to AirDrop restores its own stored overrides
|
||||||
|
/// untouched, since AirDrop and OneDrive folder settings are stored under separate keys.
|
||||||
|
func chooseTransport(_ value: SendTransport) {
|
||||||
|
guard value != transport else { return }
|
||||||
|
TransportSettings.setTransport(value)
|
||||||
|
setTransport(value)
|
||||||
|
let folders = TransportSettings.effectiveFolders()
|
||||||
|
if value == .oneDrive {
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: folders.outbox, withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setFolderURLs(outbox: folders.outbox, watch: folders.watch)
|
||||||
|
Task {
|
||||||
|
await watcher.setRecordUncommented(value == .airDrop)
|
||||||
|
do {
|
||||||
|
try await watcher.updateWatchFolder(folders.watch)
|
||||||
|
} catch {
|
||||||
|
setStatus(
|
||||||
|
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseOneDriveFolder() {
|
||||||
|
let start = OneDriveLocator.resolveOneDriveFolder() ?? FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
guard let url = chooseDirectory(startingAt: start) else { return }
|
||||||
|
TransportSettings.setOneDriveFolder(url)
|
||||||
|
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
guard transport == .oneDrive else { return }
|
||||||
|
setFolderURLs(outbox: url, watch: url)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await watcher.updateWatchFolder(url)
|
||||||
|
setStatus("OneDrive folder set to \(url.lastPathComponent).")
|
||||||
|
} catch {
|
||||||
|
setStatus(
|
||||||
|
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func chooseDirectory(startingAt directory: URL) -> URL? {
|
private func chooseDirectory(startingAt directory: URL) -> URL? {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.canChooseDirectories = true
|
panel.canChooseDirectories = true
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ enum Sharing {
|
|||||||
/// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created,
|
/// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created,
|
||||||
/// `canPerform` is false, or `view` is not in a visible window (a detached view
|
/// `canPerform` is false, or `view` is not in a visible window (a detached view
|
||||||
/// never produces an on-screen sheet).
|
/// 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),
|
guard let service = NSSharingService(named: .sendViaAirDrop),
|
||||||
service.canPerform(withItems: [fileURL]) else {
|
service.canPerform(withItems: [fileURL]) else {
|
||||||
throw ShotdeckError.airDropUnavailable
|
throw ShotdeckError.airDropUnavailable
|
||||||
@@ -21,7 +28,12 @@ enum Sharing {
|
|||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
|
||||||
service.subject = fileURL.lastPathComponent
|
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)
|
AirDropSession.keepAlive(session)
|
||||||
service.delegate = session
|
service.delegate = session
|
||||||
service.perform(withItems: [fileURL])
|
service.perform(withItems: [fileURL])
|
||||||
@@ -29,7 +41,9 @@ enum Sharing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Retains the sharing service for the life of the picker and supplies the real
|
/// 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
|
@MainActor
|
||||||
private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
||||||
static var live: [AirDropSession] = []
|
static var live: [AirDropSession] = []
|
||||||
@@ -37,11 +51,19 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
|||||||
let service: NSSharingService
|
let service: NSSharingService
|
||||||
let window: NSWindow
|
let window: NSWindow
|
||||||
let view: NSView
|
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.service = service
|
||||||
self.window = window
|
self.window = window
|
||||||
self.view = view
|
self.view = view
|
||||||
|
self.onFinished = onFinished
|
||||||
}
|
}
|
||||||
|
|
||||||
static func keepAlive(_ session: AirDropSession) {
|
static func keepAlive(_ session: AirDropSession) {
|
||||||
@@ -52,6 +74,15 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
|||||||
Self.live.removeAll { $0 === self }
|
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(
|
func sharingService(
|
||||||
_ sharingService: NSSharingService,
|
_ sharingService: NSSharingService,
|
||||||
sourceWindowForShareItems items: [Any],
|
sourceWindowForShareItems items: [Any],
|
||||||
@@ -70,7 +101,7 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
|
func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
|
||||||
drop()
|
report(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sharingService(
|
func sharingService(
|
||||||
@@ -78,6 +109,6 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
|||||||
didFailToShareItems items: [Any],
|
didFailToShareItems items: [Any],
|
||||||
error: any Error
|
error: any Error
|
||||||
) {
|
) {
|
||||||
drop()
|
report(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import ShotdeckCore
|
|||||||
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
||||||
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
||||||
}
|
}
|
||||||
|
if ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" {
|
||||||
|
MainActor.assumeIsolated { PickerSelfTest.runOneDriveOnlyIfRequested() }
|
||||||
|
}
|
||||||
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
||||||
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
|
|||||||
case pdfCompositionFailed(reason: String)
|
case pdfCompositionFailed(reason: String)
|
||||||
case airDropUnavailable
|
case airDropUnavailable
|
||||||
case noCommentedReturns
|
case noCommentedReturns
|
||||||
|
case oneDriveFolderUnavailable(path: String)
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -31,6 +32,8 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
|
|||||||
return "AirDrop is not available right now."
|
return "AirDrop is not available right now."
|
||||||
case .noCommentedReturns:
|
case .noCommentedReturns:
|
||||||
return "None of the returned PDFs have comments on them."
|
return "None of the returned PDFs have comments on them."
|
||||||
|
case .oneDriveFolderUnavailable(let path):
|
||||||
|
return "Your OneDrive folder is not available: \(path). Check that OneDrive is signed in, or choose another folder in Settings."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ public struct PDFComposer: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func fileName(for session: CaptureSession) -> String {
|
public static func fileName(for _: CaptureSession) -> String {
|
||||||
"Redline-\(DubaiTime.fileStamp(session.createdAt)).pdf"
|
"Redline-\(DubaiTime.fileStamp(Date())).pdf"
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func writePDF(
|
private static func writePDF(
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ public actor ReturnWatcher {
|
|||||||
private var bridge: FSEventBridge?
|
private var bridge: FSEventBridge?
|
||||||
private var pendingScanTask: Task<Void, Never>?
|
private var pendingScanTask: Task<Void, Never>?
|
||||||
private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents")
|
private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents")
|
||||||
|
/// When false, a document with zero human marks is neither recorded into the ledger
|
||||||
|
/// nor included in scanNow's/onChange's results — needed for OneDrive mode, where the
|
||||||
|
/// outbox and watch folder are the same folder and a freshly written, unmarked PDF
|
||||||
|
/// must not be treated as a return. Defaults to true (today's AirDrop behaviour).
|
||||||
|
/// A document that IS commented is always recorded, regardless of this flag.
|
||||||
|
public var recordUncommented: Bool = true
|
||||||
|
|
||||||
/// Watch folder is `paths.watchFolder`, which production constructs from
|
/// Watch folder is `paths.watchFolder`, which production constructs from
|
||||||
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
|
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
|
||||||
@@ -29,6 +35,12 @@ public actor ReturnWatcher {
|
|||||||
onChange(found)
|
onChange(found)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets `recordUncommented`. A `func` (not a plain property set) only because
|
||||||
|
/// callers outside this actor must `await` it like any other actor mutation.
|
||||||
|
public func setRecordUncommented(_ value: Bool) {
|
||||||
|
recordUncommented = value
|
||||||
|
}
|
||||||
|
|
||||||
/// Idempotent. Stops and releases the FSEventStream if one is running; safe to call
|
/// Idempotent. Stops and releases the FSEventStream if one is running; safe to call
|
||||||
/// when never started or already stopped. Cancels any pending debounced scan.
|
/// when never started or already stopped. Cancels any pending debounced scan.
|
||||||
public func stop() {
|
public func stop() {
|
||||||
@@ -77,6 +89,7 @@ public actor ReturnWatcher {
|
|||||||
guard let document = PDFDocument(url: url),
|
guard let document = PDFDocument(url: url),
|
||||||
AnnotationInspector.isShotdeckDocument(document) else { continue }
|
AnnotationInspector.isShotdeckDocument(document) else { continue }
|
||||||
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
|
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
|
||||||
|
if !recordUncommented, !inspected.isCommented { continue }
|
||||||
try await ledger.record(inspected)
|
try await ledger.record(inspected)
|
||||||
results.append(inspected)
|
results.append(inspected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The two ways a composed PDF can reach the iPad and come back marked up.
|
||||||
|
public enum SendTransport: String, Codable, Sendable, CaseIterable {
|
||||||
|
case airDrop
|
||||||
|
case oneDrive
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .airDrop: return "AirDrop"
|
||||||
|
case .oneDrive: return "OneDrive folder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-configurable transport choice plus the OneDrive folder override, backed by
|
||||||
|
/// UserDefaults the same way `FolderSettings` is. See `FolderSettings` for why a plain
|
||||||
|
/// path (not a security-scoped bookmark) is correct for this unsandboxed app.
|
||||||
|
public enum TransportSettings {
|
||||||
|
public static let transportDefaultsKey = "ai.flowmaster.shotdeck.transport"
|
||||||
|
public static let oneDriveFolderDefaultsKey = "ai.flowmaster.shotdeck.oneDriveFolder"
|
||||||
|
|
||||||
|
/// Defaults to `.airDrop` when unset or when the stored value cannot be parsed.
|
||||||
|
public static func transport(defaults: UserDefaults = .standard) -> SendTransport {
|
||||||
|
guard let raw = defaults.string(forKey: transportDefaultsKey),
|
||||||
|
let value = SendTransport(rawValue: raw)
|
||||||
|
else { return .airDrop }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func setTransport(_ value: SendTransport, defaults: UserDefaults = .standard) {
|
||||||
|
defaults.set(value.rawValue, forKey: transportDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw stored path (or nil if never set / cleared). Does NOT validate that the
|
||||||
|
/// directory still exists.
|
||||||
|
public static func storedOneDriveFolderPath(defaults: UserDefaults = .standard) -> String? {
|
||||||
|
defaults.string(forKey: oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func setOneDriveFolder(_ url: URL, defaults: UserDefaults = .standard) {
|
||||||
|
defaults.set(url.path, forKey: oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func resetOneDriveFolder(defaults: UserDefaults = .standard) {
|
||||||
|
defaults.removeObject(forKey: oneDriveFolderDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outbox/watch folders Redline should actually use right now, for the current
|
||||||
|
/// transport. AirDrop mode delegates to `FolderSettings.resolve()` unchanged.
|
||||||
|
/// OneDrive mode uses the SAME folder for both outbox and watch — see
|
||||||
|
/// `OneDriveLocator.resolveOneDriveFolder`. When no OneDrive folder can be resolved
|
||||||
|
/// at all (no sync root, no override), this falls back to the AirDrop folders so the
|
||||||
|
/// app always has somewhere to write; `send(anchor:)` performs its own live
|
||||||
|
/// existence check before ever composing into a OneDrive send, so that fallback is
|
||||||
|
/// never mistaken for a valid OneDrive destination.
|
||||||
|
public static func effectiveFolders(
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> (outbox: URL, watch: URL, transport: SendTransport) {
|
||||||
|
let transport = transport(defaults: defaults)
|
||||||
|
switch transport {
|
||||||
|
case .airDrop:
|
||||||
|
let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager)
|
||||||
|
return (folders.outbox, folders.watch, transport)
|
||||||
|
case .oneDrive:
|
||||||
|
if let folder = OneDriveLocator.resolveOneDriveFolder(
|
||||||
|
defaults: defaults,
|
||||||
|
home: fileManager.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: fileManager
|
||||||
|
) {
|
||||||
|
return (folder, folder, transport)
|
||||||
|
}
|
||||||
|
let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager)
|
||||||
|
return (folders.outbox, folders.watch, transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure path logic for locating a OneDrive sync root under
|
||||||
|
/// `~/Library/CloudStorage` and the Redline folder inside it. No side effects — never
|
||||||
|
/// creates a directory. Fully unit-testable with a fake home tree.
|
||||||
|
public enum OneDriveLocator {
|
||||||
|
/// Every directory directly under `<home>/Library/CloudStorage` whose name starts
|
||||||
|
/// with "OneDrive-", sorted so a name containing "MMD" (case-insensitive) sorts
|
||||||
|
/// first, then alphabetically. Empty when CloudStorage does not exist.
|
||||||
|
public static func syncRoots(
|
||||||
|
home: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> [URL] {
|
||||||
|
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard fileManager.fileExists(atPath: cloudStorage.path, isDirectory: &isDirectory),
|
||||||
|
isDirectory.boolValue
|
||||||
|
else { return [] }
|
||||||
|
|
||||||
|
let items = (try? fileManager.contentsOfDirectory(
|
||||||
|
at: cloudStorage,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
)) ?? []
|
||||||
|
|
||||||
|
let roots = items.filter { url in
|
||||||
|
guard url.lastPathComponent.hasPrefix("OneDrive-") else { return false }
|
||||||
|
var itemIsDirectory: ObjCBool = false
|
||||||
|
let exists = fileManager.fileExists(atPath: url.path, isDirectory: &itemIsDirectory)
|
||||||
|
return exists && itemIsDirectory.boolValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots.sorted { a, b in
|
||||||
|
let aName = a.lastPathComponent
|
||||||
|
let bName = b.lastPathComponent
|
||||||
|
let aIsMMD = aName.localizedCaseInsensitiveContains("MMD")
|
||||||
|
let bIsMMD = bName.localizedCaseInsensitiveContains("MMD")
|
||||||
|
if aIsMMD != bIsMMD { return aIsMMD }
|
||||||
|
return aName.localizedStandardCompare(bName) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First sync root's "Redline" subfolder, or nil when there is no sync root at all.
|
||||||
|
public static func defaultRedlineFolder(
|
||||||
|
home: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL? {
|
||||||
|
guard let first = syncRoots(home: home, fileManager: fileManager).first else { return nil }
|
||||||
|
return first.appendingPathComponent("Redline", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored override when it is set AND still exists as a directory; otherwise
|
||||||
|
/// `defaultRedlineFolder`. Never creates anything.
|
||||||
|
public static func resolveOneDriveFolder(
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
home: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL? {
|
||||||
|
if let storedPath = TransportSettings.storedOneDriveFolderPath(defaults: defaults) {
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
let exists = fileManager.fileExists(atPath: storedPath, isDirectory: &isDirectory)
|
||||||
|
if exists, isDirectory.boolValue {
|
||||||
|
return URL(fileURLWithPath: storedPath, isDirectory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultRedlineFolder(home: home, fileManager: fileManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import ShotdeckCore
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func syncRootsFindsOneDriveDirsMMDFirstIgnoresNonDirsAndOtherProviders() throws {
|
||||||
|
let home = try makeFakeHome()
|
||||||
|
defer { try? FileManager.default.removeItem(at: home) }
|
||||||
|
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: cloudStorage, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-Flowmaster", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("GoogleDrive-x", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
// A plain FILE (not a directory) named like a OneDrive root must be ignored.
|
||||||
|
FileManager.default.createFile(
|
||||||
|
atPath: cloudStorage.appendingPathComponent("OneDrive-notadir").path,
|
||||||
|
contents: Data("not a directory".utf8)
|
||||||
|
)
|
||||||
|
|
||||||
|
let roots = OneDriveLocator.syncRoots(home: home, fileManager: .default)
|
||||||
|
|
||||||
|
#expect(roots.map(\.lastPathComponent) == ["OneDrive-MMDGROUP", "OneDrive-Flowmaster"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func syncRootsEmptyAndDefaultFolderNilWithNoCloudStorageDirectory() throws {
|
||||||
|
let home = try makeFakeHome()
|
||||||
|
defer { try? FileManager.default.removeItem(at: home) }
|
||||||
|
// No Library/CloudStorage created at all.
|
||||||
|
|
||||||
|
let roots = OneDriveLocator.syncRoots(home: home, fileManager: .default)
|
||||||
|
#expect(roots.isEmpty)
|
||||||
|
|
||||||
|
let defaultFolder = OneDriveLocator.defaultRedlineFolder(home: home, fileManager: .default)
|
||||||
|
#expect(defaultFolder == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func defaultRedlineFolderIsFirstSyncRootPlusRedline() throws {
|
||||||
|
let home = try makeFakeHome()
|
||||||
|
defer { try? FileManager.default.removeItem(at: home) }
|
||||||
|
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-Flowmaster", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let defaultFolder = try #require(
|
||||||
|
OneDriveLocator.defaultRedlineFolder(home: home, fileManager: .default)
|
||||||
|
)
|
||||||
|
// Derive "expected" from syncRoots() itself (already covered by its own dedicated
|
||||||
|
// test) rather than hand-building the path string — FileManager's directory
|
||||||
|
// enumeration can canonicalize /var -> /private/var and the two constructions
|
||||||
|
// otherwise disagree on that even for a URL that already exists.
|
||||||
|
let expectedRoot = try #require(OneDriveLocator.syncRoots(home: home, fileManager: .default).first)
|
||||||
|
let expected = expectedRoot.appendingPathComponent("Redline", isDirectory: true)
|
||||||
|
#expect(defaultFolder.path == expected.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func resolveOneDriveFolderPrefersAnExistingStoredOverride() throws {
|
||||||
|
let home = try makeFakeHome()
|
||||||
|
defer { try? FileManager.default.removeItem(at: home) }
|
||||||
|
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
let override = try makeTransportTemporaryDirectory(prefix: "shotdeck-onedrive-override")
|
||||||
|
defer { try? FileManager.default.removeItem(at: override) }
|
||||||
|
TransportSettings.setOneDriveFolder(override, defaults: suite.defaults)
|
||||||
|
|
||||||
|
let resolved = OneDriveLocator.resolveOneDriveFolder(
|
||||||
|
defaults: suite.defaults, home: home, fileManager: .default
|
||||||
|
)
|
||||||
|
#expect(resolved?.path == override.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func resolveOneDriveFolderIgnoresAStoredPathThatNoLongerExists() throws {
|
||||||
|
let home = try makeFakeHome()
|
||||||
|
defer { try? FileManager.default.removeItem(at: home) }
|
||||||
|
let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cloudStorage.appendingPathComponent("OneDrive-MMDGROUP", isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
let goneOverride = try makeTransportTemporaryDirectory(prefix: "shotdeck-onedrive-gone")
|
||||||
|
TransportSettings.setOneDriveFolder(goneOverride, defaults: suite.defaults)
|
||||||
|
try FileManager.default.removeItem(at: goneOverride)
|
||||||
|
|
||||||
|
let resolved = OneDriveLocator.resolveOneDriveFolder(
|
||||||
|
defaults: suite.defaults, home: home, fileManager: .default
|
||||||
|
)
|
||||||
|
let expectedRoot = try #require(OneDriveLocator.syncRoots(home: home, fileManager: .default).first)
|
||||||
|
let expected = expectedRoot.appendingPathComponent("Redline", isDirectory: true)
|
||||||
|
#expect(resolved?.path == expected.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeFakeHome() throws -> URL {
|
||||||
|
let home = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("shotdeck-fake-home-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
|
||||||
|
return home
|
||||||
|
}
|
||||||
@@ -203,6 +203,17 @@ private func pixelWindow(
|
|||||||
return (pixelX0, pixelY0, pixelX1, pixelY1)
|
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")
|
@Test("Three-page basic compose")
|
||||||
func threePageBasicCompose() throws {
|
func threePageBasicCompose() throws {
|
||||||
let directory = try makeScratchDirectory()
|
let directory = try makeScratchDirectory()
|
||||||
|
|||||||
@@ -230,3 +230,55 @@ func w27_fsEventsCallbackFiresOnRealArrival() async throws {
|
|||||||
await watcher.stop()
|
await watcher.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("recordUncommended defaults to true: an unmarked PDF is still recorded (AirDrop behaviour unchanged)")
|
||||||
|
func recordUncommendedDefaultTrueRecordsAnUnmarkedPDF() async throws {
|
||||||
|
let (paths, cleanup) = try makeCasePaths()
|
||||||
|
defer { try? FileManager.default.removeItem(at: cleanup) }
|
||||||
|
|
||||||
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
|
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||||
|
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260905-090000.pdf")
|
||||||
|
|
||||||
|
try makePDF(at: pdfURL, pageCount: 1, creator: "Redline", annotations: [])
|
||||||
|
|
||||||
|
let found = try await watcher.scanNow()
|
||||||
|
#expect(found.count == 1)
|
||||||
|
#expect(found.first?.isCommented == false)
|
||||||
|
|
||||||
|
let all = try await ledger.all()
|
||||||
|
#expect(all.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("recordUncommended=false: an unmarked PDF is not recorded or returned; marking it up in place gets it recorded")
|
||||||
|
func recordUncommendedFalseSkipsUnmarkedThenRecordsAfterInPlaceMarkup() async throws {
|
||||||
|
let (paths, cleanup) = try makeCasePaths()
|
||||||
|
defer { try? FileManager.default.removeItem(at: cleanup) }
|
||||||
|
|
||||||
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
|
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||||
|
await watcher.setRecordUncommented(false)
|
||||||
|
let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260905-091500.pdf")
|
||||||
|
|
||||||
|
// OneDrive mode: the PDF is freshly written here (by "send"), unmarked so far.
|
||||||
|
try makePDF(at: pdfURL, pageCount: 1, creator: "Redline", annotations: [])
|
||||||
|
|
||||||
|
let beforeMarkup = try await watcher.scanNow()
|
||||||
|
#expect(beforeMarkup.isEmpty)
|
||||||
|
let allBefore = try await ledger.all()
|
||||||
|
#expect(allBefore.isEmpty)
|
||||||
|
|
||||||
|
// What the iPad does: mark it up in place, in the SAME folder, then save.
|
||||||
|
let document = try #require(PDFDocument(url: pdfURL))
|
||||||
|
let page = try #require(document.page(at: 0))
|
||||||
|
page.addAnnotation(makeAnnotation(.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)))
|
||||||
|
#expect(document.write(to: pdfURL))
|
||||||
|
|
||||||
|
let afterMarkup = try await watcher.scanNow()
|
||||||
|
#expect(afterMarkup.count == 1)
|
||||||
|
#expect(afterMarkup.first?.isCommented == true)
|
||||||
|
|
||||||
|
let commented = try await ledger.commented()
|
||||||
|
#expect(commented.count == 1)
|
||||||
|
#expect(commented.first?.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import ShotdeckCore
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func transportDefaultsToAirDropWhenUnset() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
|
||||||
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func setTransportRoundTrips() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
|
||||||
|
TransportSettings.setTransport(.oneDrive, defaults: suite.defaults)
|
||||||
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .oneDrive)
|
||||||
|
|
||||||
|
TransportSettings.setTransport(.airDrop, defaults: suite.defaults)
|
||||||
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func garbageStoredTransportFallsBackToAirDrop() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
|
||||||
|
suite.defaults.set("not-a-real-transport", forKey: TransportSettings.transportDefaultsKey)
|
||||||
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func oneDriveFolderStoreAndReset() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
let folder = try makeTransportTemporaryDirectory(prefix: "shotdeck-onedrive-folder")
|
||||||
|
defer { try? FileManager.default.removeItem(at: folder) }
|
||||||
|
|
||||||
|
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == nil)
|
||||||
|
|
||||||
|
TransportSettings.setOneDriveFolder(folder, defaults: suite.defaults)
|
||||||
|
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == folder.path)
|
||||||
|
|
||||||
|
TransportSettings.resetOneDriveFolder(defaults: suite.defaults)
|
||||||
|
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func effectiveFoldersForAirDropMatchesFolderSettings() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
let outbox = try makeTransportTemporaryDirectory(prefix: "shotdeck-effective-outbox")
|
||||||
|
defer { try? FileManager.default.removeItem(at: outbox) }
|
||||||
|
FolderSettings.setOutbox(outbox, defaults: suite.defaults)
|
||||||
|
|
||||||
|
let effective = TransportSettings.effectiveFolders(defaults: suite.defaults)
|
||||||
|
let expected = FolderSettings.resolve(defaults: suite.defaults)
|
||||||
|
|
||||||
|
#expect(effective.transport == .airDrop)
|
||||||
|
#expect(effective.outbox.path == expected.outbox.path)
|
||||||
|
#expect(effective.watch.path == expected.watch.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func effectiveFoldersForOneDriveWithAResolvableFolderUsesItForBoth() throws {
|
||||||
|
let suite = try makeTransportDefaultsSuite()
|
||||||
|
defer { tearDownTransportSuite(suite) }
|
||||||
|
let folder = try makeTransportTemporaryDirectory(prefix: "shotdeck-effective-onedrive")
|
||||||
|
defer { try? FileManager.default.removeItem(at: folder) }
|
||||||
|
|
||||||
|
TransportSettings.setTransport(.oneDrive, defaults: suite.defaults)
|
||||||
|
TransportSettings.setOneDriveFolder(folder, defaults: suite.defaults)
|
||||||
|
|
||||||
|
let effective = TransportSettings.effectiveFolders(defaults: suite.defaults)
|
||||||
|
|
||||||
|
#expect(effective.transport == .oneDrive)
|
||||||
|
#expect(effective.outbox.path == folder.path)
|
||||||
|
#expect(effective.watch.path == folder.path)
|
||||||
|
#expect(effective.outbox.path == effective.watch.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func oneDriveFolderUnavailableErrorDescriptionContainsThePath() throws {
|
||||||
|
let path = "/Users/example/Library/CloudStorage/OneDrive-Example/Redline"
|
||||||
|
let error = ShotdeckError.oneDriveFolderUnavailable(path: path)
|
||||||
|
let description = try #require(error.errorDescription)
|
||||||
|
#expect(!description.isEmpty)
|
||||||
|
#expect(description.contains(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TransportDefaultsSuite {
|
||||||
|
let name: String
|
||||||
|
let defaults: UserDefaults
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTransportDefaultsSuite() throws -> TransportDefaultsSuite {
|
||||||
|
let name = "shotdeck-transport-test-\(UUID().uuidString)"
|
||||||
|
let defaults = try #require(UserDefaults(suiteName: name))
|
||||||
|
defaults.removePersistentDomain(forName: name)
|
||||||
|
return TransportDefaultsSuite(name: name, defaults: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tearDownTransportSuite(_ suite: TransportDefaultsSuite) {
|
||||||
|
suite.defaults.removePersistentDomain(forName: suite.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTransportTemporaryDirectory(prefix: String) throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
return url
|
||||||
|
}
|
||||||
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