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 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 FolderSettings.resolve() via resolvedAppSupportPaths — never .standard(). let folders = FolderSettings.resolve() self.outboxURL = folders.outbox self.watchFolderURL = folders.watch self.outboxDisplayName = folders.outbox.lastPathComponent self.watchFolderDisplayName = folders.watch.lastPathComponent 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 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. } do { 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.") } let pref = HotkeyPreference.load() captureHotkey = pref if !bindCaptureHotkey(pref) { setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.") } let skipSchedule = ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil if !skipSchedule { 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) 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 } }