import AppKit import Foundation import Observation import SwiftUI import ShotdeckCore @MainActor public protocol SendCapable: AnyObject { func send(anchor: NSView?) async } @MainActor public protocol SettingsWindowPresenting: AnyObject { func presentSettingsWindow() } @MainActor public protocol ReturnsSectionProviding: AnyObject { @ViewBuilder func returnsSection() -> AnyView } @MainActor @Observable public final class AppModel { public private(set) var session: CaptureSession public private(set) var region: CaptureRegion? public private(set) var screenRecordingGranted: Bool public private(set) var allReturns: [ReturnedDocument] = [] public private(set) var commentedReturns: [ReturnedDocument] = [] public private(set) var statusLine: String? public private(set) var isCapturing: Bool = false 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 } /// Staged update offered in the menu. Set only after checksum + payload validation. public private(set) var updateAvailable: (version: String, notes: String)? let paths: AppSupportPaths let spool: SpoolStore let composer: PDFComposer let capturer: ScreenCapturer let hotkeys: HotkeyCenter let picker: RegionPickerController let ledger: ReturnLedger let watcher: ReturnWatcher let updateChecker: UpdateChecker public init( paths: AppSupportPaths, spool: SpoolStore, composer: PDFComposer, capturer: ScreenCapturer, hotkeys: HotkeyCenter, picker: RegionPickerController, ledger: ReturnLedger, watcher: ReturnWatcher ) { self.paths = paths self.spool = spool self.composer = composer self.capturer = capturer self.hotkeys = hotkeys self.picker = picker self.ledger = ledger self.watcher = watcher self.session = CaptureSession( id: UUID(), createdAt: Date(), state: .open, captures: [], pdfFileName: nil ) self.region = Self.loadPersistedRegion() self.screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted // 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 guard let self else { return } self.updateAvailable = self.updateChecker.availableUpdate if let message = self.updateChecker.statusMessage { self.setStatus(message) } } } // MARK: Seam mutators — the only way a WP-4b/4c extension changes state. func setStatus(_ text: String?) { statusLine = text } func setSending(_ value: Bool) { isSending = value } func setCapturing(_ value: Bool) { isCapturing = value } func replaceSession(_ new: CaptureSession) { session = new } func replaceRegion(_ new: CaptureRegion?) { region = new } func setReturns(all: [ReturnedDocument], commented: [ReturnedDocument]) { allReturns = all commentedReturns = commented } func setFolderDisplayNames(outbox: String, watch: String) { outboxDisplayName = outbox watchFolderDisplayName = watch } func setFolderURLs(outbox: URL, watch: URL) { outboxURL = outbox 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 /// The MOST RECENT watcher-reconcile Task spawned by chooseTransport/ /// chooseOneDriveFolder, if one is still (or was just) in flight. send() awaits /// this BEFORE snapshotting transport/folder, so a toggle immediately followed by /// Send can never race ahead of the reconcile it depends on (the watcher's /// recordUncommented flag briefly lagging the just-chosen transport, for example). /// `Task` never throws; awaiting an already-completed task's `.value` /// returns immediately. Not `@Observable`-relevant — pure internal bookkeeping. var pendingReconcileTask: Task? 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 } if isCapturing { return .capturing } if region == nil { return .noRegion } if session.captures.isEmpty { return .regionEmpty } return .hasCaptures(session.captures.count) } public func bootstrap() async { if let data = UserDefaults.standard.data(forKey: CaptureRegion.defaultsKey), let decoded = try? JSONDecoder().decode(CaptureRegion.self, from: data), decoded.isStillValid { replaceRegion(decoded) } do { let recovered = try await spool.currentSession() replaceSession(recovered) } catch { setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not open the spool.") } do { let initial = try await ledger.all() let commented = try await ledger.commented() setReturns(all: initial, commented: commented) } catch { // 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 } let all = (try? await self.ledger.all()) ?? [] let commented = (try? await self.ledger.commented()) ?? [] self.setReturns(all: all, commented: commented) } } } catch { setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.") } // Env-var-flagged self-test/headless runs (PickerSelfTest's phases, PanelSnapshot, // and the new ShotdeckTests launch-wiring regression test) skip two real-world // side effects that are unsafe or meaningless in that context: the update-check // schedule (a real network call), and binding the REAL, process-wide Carbon // global hotkey — which is not safe to exercise in an automated/parallel test // process (it can collide with ShotdeckCoreTests' own HotkeyCenterCarbonTests // running in the same test binary) and was never meaningfully exercised by any // self-test anyway. A real user launch never sets these env vars, so production // behavior is unchanged. let isSelfTestRun = 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 let pref = HotkeyPreference.load() captureHotkey = pref if !isSelfTestRun, !bindCaptureHotkey(pref) { setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.") } if !isSelfTestRun { updateChecker.startSchedule() } } /// Installs the staged update over `/Applications/Redline.app` and relaunches. /// Does nothing unless the user clicked the menu row. public func installUpdate() { updateChecker.installStaged() } /// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new /// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working. func reRegisterHotkey() { let previous = captureHotkey let next = HotkeyPreference.load() hotkeys.unregister(id: "capture") if bindCaptureHotkey(next) { captureHotkey = next return } setStatus("That combination is taken — pick another.") previous.save() if bindCaptureHotkey(previous) { captureHotkey = previous } } @discardableResult private func bindCaptureHotkey(_ pref: HotkeyPreference) -> Bool { hotkeys.register( id: "capture", keyCode: pref.keyCode, modifiers: pref.modifiers ) { [weak self] in Task { await self?.captureNow() } } } public func captureNow() async { guard !isCapturing else { return } setCapturing(true) defer { setCapturing(false) } var target = region if target == nil { target = await withCheckedContinuation { (cont: CheckedContinuation) in picker.pick { picked in cont.resume(returning: picked) } } guard let picked = target else { setStatus("No region selected.") return } persistRegion(picked) } guard let region = target else { return } do { let image = try await capturer.capture(region) let capture = try await spool.append( pngData: image.pngData, pixelWidth: image.pixelWidth, pixelHeight: image.pixelHeight, scale: image.scale, capturedAt: Date() ) replaceSession(try await spool.currentSession()) setStatus("Captured page \(capture.sequence).") } catch { screenRecordingGranted = ScreenCapturer.isScreenRecordingGranted setStatus((error as? ShotdeckError)?.errorDescription ?? "The screenshot could not be taken.") } } public func rePickRegion() async { let picked = await withCheckedContinuation { (cont: CheckedContinuation) in picker.pick { cont.resume(returning: $0) } } guard let picked else { return } persistRegion(picked) setStatus("Region set: \(Int(picked.rect.width)) × \(Int(picked.rect.height)).") } public func removeCapture(id: UUID) async { do { // D-11: SpoolStore.remove MOVES the PNG to /removed/; it is never unlinked. replaceSession(try await spool.remove(captureID: id)) } catch { setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not remove that capture.") } } public func copyCommentedLinks() async { do { let text = try await ledger.clipboardText() let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) let count = commentedReturns.count setStatus("Copied \(count) link\(count == 1 ? "" : "s").") } catch { setStatus((error as? ShotdeckError)?.errorDescription ?? "Nothing to copy.") } } public func openSpoolFolder() { NSWorkspace.shared.open(paths.spool) } public func openScreenRecordingSettings() { guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") else { setStatus("Could not open System Settings.") return } NSWorkspace.shared.open(url) } static func loadPersistedRegion() -> CaptureRegion? { guard let data = UserDefaults.standard.data(forKey: CaptureRegion.defaultsKey), let decoded = try? JSONDecoder().decode(CaptureRegion.self, from: data), decoded.isStillValid else { return nil } return decoded } private func persistRegion(_ picked: CaptureRegion) { replaceRegion(picked) if let encoded = try? JSONEncoder().encode(picked) { UserDefaults.standard.set(encoded, forKey: CaptureRegion.defaultsKey) } } } public enum MenuIconState: Equatable { case noRegion, regionEmpty, hasCaptures(Int), capturing, recordingMissing public var symbolName: String { switch self { case .noRegion: return "viewfinder" case .regionEmpty: return "viewfinder.rectangular" case .hasCaptures: return "viewfinder.rectangular" case .capturing: return "viewfinder.circle.fill" case .recordingMissing: return "exclamationmark.triangle" } } public var countText: String? { if case .hasCaptures(let n) = self { return "\(n)" } return nil } }