Files
shotdeck/Sources/Shotdeck/AppModel.swift
2026-09-05 06:17:29 +00:00

452 lines
19 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)?
/// True for the duration of any appcast check (manual or scheduled).
public private(set) var isCheckingForUpdates: Bool = false
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)
}
}
self.updateChecker.onCheckingChanged = { [weak self] checking in
self?.isCheckingForUpdates = checking
}
}
/// CFBundleShortVersionString of the running app.
public var appVersion: String { UpdateChecker.currentVersion() }
/// Version recorded in the app-managed rollback copy, when one exists.
public var previousVersion: String? { updateChecker.previousVersion() }
/// Most recent status text — shared with the general status line by design
/// (Redline has one status channel, not a separate update-only one).
public var updateStatusMessage: String? { statusLine }
// MARK: Seam mutators — the only way a WP-4b/4c extension changes state.
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<Void, Never>` never throws; awaiting an already-completed task's `.value`
/// returns immediately. Not `@Observable`-relevant — pure internal bookkeeping.
var pendingReconcileTask: Task<Void, Never>?
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()
}
/// User-initiated appcast check ("Check for updates" menu row).
public func checkForUpdates() {
Task { @MainActor in
await updateChecker.checkNow(manual: true)
}
}
/// Reverts `/Applications/Redline.app` to the app-managed rollback copy and relaunches.
/// Does nothing unless a `Redline.app.previous` exists and the user clicked the row.
public func revertToPreviousVersion() {
updateChecker.revertToPrevious()
}
/// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new
/// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working.
func reRegisterHotkey() {
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<CaptureRegion?, Never>) 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<CaptureRegion?, Never>) 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 <session>/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
}
}