From fdac2f2f4787eb8a07163eb96646354139993674 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 15:59:37 +0400 Subject: [PATCH] WP-4a: menu-bar shell, AppModel, session strip per SPEC-A2b + integration contracts --- Sources/Shotdeck/AppModel.swift | 265 ++++++++++++++++++++++++++++ Sources/Shotdeck/MenuBarView.swift | 180 +++++++++++++++++++ Sources/Shotdeck/SessionStrip.swift | 78 ++++++++ Sources/Shotdeck/main.swift | 77 +++++++- 4 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 Sources/Shotdeck/AppModel.swift create mode 100644 Sources/Shotdeck/MenuBarView.swift create mode 100644 Sources/Shotdeck/SessionStrip.swift diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift new file mode 100644 index 0000000..6c95b5a --- /dev/null +++ b/Sources/Shotdeck/AppModel.swift @@ -0,0 +1,265 @@ +import AppKit +import Carbon.HIToolbox +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 + + let paths: AppSupportPaths + let spool: SpoolStore + let composer: PDFComposer + let capturer: ScreenCapturer + let hotkeys: HotkeyCenter + let picker: RegionPickerController + let ledger: ReturnLedger + let watcher: ReturnWatcher + + 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 = nil + 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 + } + + // 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) + } + + 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 registered = hotkeys.register( + id: "capture", + keyCode: UInt32(kVK_ANSI_2), + modifiers: UInt32(optionKey | shiftKey) + ) { [weak self] in + Task { await self?.captureNow() } + } + if !registered { + setStatus("⌥⇧2 is already used by another app — capture only works from the menu.") + } + } + + 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) + } + + 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 + } +} diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift new file mode 100644 index 0000000..5b1f1be --- /dev/null +++ b/Sources/Shotdeck/MenuBarView.swift @@ -0,0 +1,180 @@ +import AppKit +import SwiftUI +import ShotdeckCore + +struct MenuBarView: View { + @Environment(AppModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + statusRow + SessionStrip() + Divider() + actionsList + if !model.allReturns.isEmpty { + Divider() + returnsBlock + } + } + .padding(10) + .frame(width: 320, alignment: .leading) + .controlSize(.small) + } + + @ViewBuilder + private var statusRow: some View { + if !model.screenRecordingGranted { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.yellow) + VStack(alignment: .leading, spacing: 4) { + Text( + ShotdeckError.screenRecordingNotGranted.errorDescription + ?? "Screen Recording is turned off." + ) + .font(.caption) + .fixedSize(horizontal: false, vertical: true) + Button("Open Screen Recording settings") { + model.openScreenRecordingSettings() + } + } + } + } else { + Text(model.statusLine ?? defaultStatusText) + .font(.caption) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var defaultStatusText: String { + guard let region = model.region else { + return "No region yet — press ⌥⇧2 to pick one." + } + let w = Int(region.rect.width) + let h = Int(region.rect.height) + let display = displayName(for: region) + if model.session.isEmpty { + return "Region \(w) × \(h) on \(display) · Nothing captured yet." + } + let count = model.session.captures.count + return "\(count) captures · region \(w) × \(h) on \(display)" + } + + private func displayName(for region: CaptureRegion) -> String { + for (index, screen) in NSScreen.screens.enumerated() { + let id = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)? + .uint32Value + if id == region.displayID { + return "Display \(index + 1)" + } + } + return "Display 1" + } + + private var actionsList: some View { + VStack(alignment: .leading, spacing: 2) { + Button { + let anchor = NSApp.keyWindow?.contentView + if let sender = model as? SendCapable { + Task { await sender.send(anchor: anchor) } + } else { + model.setStatus("Send is not available in this build.") + } + } label: { + actionLabel("Send…") + } + .disabled(model.session.isEmpty || model.isSending) + + Button { + Task { await model.captureNow() } + } label: { + actionLabel("Capture now", trailing: "⌥⇧2") + } + .disabled(model.isCapturing) + + Button { + Task { await model.rePickRegion() } + } label: { + actionLabel("Re-select area") + } + + Button { + Task { await model.copyCommentedLinks() } + } label: { + actionLabel( + "Copy commented links", + trailing: model.commentedReturns.isEmpty ? nil : "\(model.commentedReturns.count)" + ) + } + .disabled(model.commentedReturns.isEmpty) + + Button { + model.openSpoolFolder() + } label: { + actionLabel("Open spool folder") + } + + Button { + if let presenter = model as? SettingsWindowPresenting { + presenter.presentSettingsWindow() + } else { + model.setStatus("Settings is not available in this build.") + } + } label: { + actionLabel("Settings…") + } + + Button { + NSApp.terminate(nil) + } label: { + actionLabel("Quit Shotdeck") + } + } + .buttonStyle(.plain) + } + + private func actionLabel(_ title: String, trailing: String? = nil) -> some View { + HStack(spacing: 8) { + Text(title) + Spacer(minLength: 8) + if let trailing { + Text(trailing) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .padding(.vertical, 3) + } + + @ViewBuilder + private var returnsBlock: some View { + if let provider = model as? ReturnsSectionProviding { + provider.returnsSection() + } else { + VStack(alignment: .leading, spacing: 4) { + Text("Came back from your device") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(newestReturns.prefix(8)) { doc in + HStack(spacing: 8) { + Text(doc.fileURL.lastPathComponent) + .lineLimit(1) + Spacer(minLength: 8) + Text(doc.isCommented + ? "\(doc.annotatedPages.count) page\(doc.annotatedPages.count == 1 ? "" : "s") marked" + : "not marked") + .font(.caption) + .foregroundStyle(doc.isCommented ? .primary : .secondary) + } + } + } + } + } + + private var newestReturns: [ReturnedDocument] { + model.allReturns.sorted { $0.detectedAt > $1.detectedAt } + } +} diff --git a/Sources/Shotdeck/SessionStrip.swift b/Sources/Shotdeck/SessionStrip.swift new file mode 100644 index 0000000..4786340 --- /dev/null +++ b/Sources/Shotdeck/SessionStrip.swift @@ -0,0 +1,78 @@ +import AppKit +import SwiftUI +import ShotdeckCore + +struct SessionStrip: View { + @Environment(AppModel.self) private var model + + var body: some View { + if model.session.captures.isEmpty { + Text("Nothing captured yet.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 6) { + ForEach(model.session.captures) { capture in + SessionThumb(capture: capture) + } + } + } + .frame(height: 64) + } + } +} + +private struct SessionThumb: View { + @Environment(AppModel.self) private var model + let capture: Capture + @State private var hovering = false + + var body: some View { + ZStack(alignment: .topLeading) { + thumbnail + Text("\(capture.sequence)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(.black.opacity(0.65)) + .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) + .padding(3) + if hovering { + VStack { + Spacer() + Button("Remove") { + Task { await model.removeCapture(id: capture.id) } + } + .font(.system(size: 10, weight: .semibold)) + .buttonStyle(.plain) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 3) + .background(.black.opacity(0.7)) + } + } + } + .frame(height: 64) + .clipped() + .onHover { hovering = $0 } + .help(DubaiTime.stamp(capture.capturedAt)) + } + + @ViewBuilder + private var thumbnail: some View { + let url = model.paths.sessionDirectory(model.session.id).appendingPathComponent(capture.fileName) + if let image = NSImage(contentsOf: url) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 64) + } else { + Rectangle() + .fill(Color.secondary.opacity(0.2)) + .frame(width: 64, height: 64) + } + } +} diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift index 0cba481..60de9bc 100644 --- a/Sources/Shotdeck/main.swift +++ b/Sources/Shotdeck/main.swift @@ -1,6 +1,75 @@ import AppKit +import SwiftUI +import ShotdeckCore -// WP-4 replaces this body with the real menu-bar UI. -let application = NSApplication.shared -application.setActivationPolicy(.accessory) -application.run() +// SwiftPM treats a file named main.swift as top-level code, which forbids `@main`. +// App.main() is the equivalent entry point. +ShotdeckApp.main() + +struct ShotdeckApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + MenuBarExtra { + MenuBarView() + .environment(appDelegate.model) + } label: { + let state = appDelegate.model.iconState + HStack(spacing: 4) { + Image(systemName: state.symbolName) + if let count = state.countText { + Text(count).font(.system(size: 11, weight: .semibold)) + } + } + .accessibilityLabel("Shotdeck") + } + .menuBarExtraStyle(.window) + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + let model: AppModel + + override init() { + NSApplication.shared.setActivationPolicy(.accessory) + model = AppDelegate.makeLaunchModel() + super.init() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + Task { await model.bootstrap() } + } + + private static func makeLaunchModel() -> AppModel { + do { + let paths = try FolderSettings.resolvedAppSupportPaths() + return try makeModel(paths: paths) + } catch { + Log.ui.critical( + "AppModel init failed: \(String(describing: error), privacy: .public)" + ) + let tmp = FileManager.default.temporaryDirectory + let fallbackRoot = tmp.appendingPathComponent("Shotdeck-fallback", isDirectory: true) + // Safe: temp-dir creation for a path this process controls cannot legitimately fail. + let fallback = try! AppSupportPaths(root: fallbackRoot, outbox: tmp, watchFolder: tmp) + let model = try! makeModel(paths: fallback) + model.setStatus("Shotdeck could not access its storage folder. Captures will not persist.") + return model + } + } + + private static func makeModel(paths: AppSupportPaths) throws -> AppModel { + let ledger = try ReturnLedger(paths: paths) + return AppModel( + paths: paths, + spool: try SpoolStore(paths: paths), + composer: PDFComposer(), + capturer: ScreenCapturer(), + hotkeys: HotkeyCenter(), + picker: RegionPickerController(), + ledger: ledger, + watcher: ReturnWatcher(paths: paths, ledger: ledger) + ) + } +}