import AppKit import SwiftUI import ShotdeckCore struct SettingsView: View { @Environment(AppModel.self) private var model @State private var isRecordingHotkey = false @State private var recorder = HotkeyRecorderBox() private let labelWidth: CGFloat = 104 var body: some View { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { GridRow { Text("Hotkey") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) .gridCellColumns(2) } GridRow(alignment: .center) { fieldLabel("Capture") HStack(spacing: 8) { Button { armHotkeyRecorder() } label: { Text(isRecordingHotkey ? "Press keys…" : model.hotkeyDisplayString) .foregroundStyle(isRecordingHotkey ? .secondary : .primary) .lineLimit(1) } Spacer(minLength: 0) } .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 { Text("Folders") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) .gridCellColumns(2) .padding(.top, 6) } if model.transport == .airDrop { GridRow(alignment: .center) { fieldLabel("Watch folder") folderValue(path: model.watchFolderURL.path) { model.chooseWatchFolder() } } GridRow(alignment: .center) { fieldLabel("Output folder") folderValue(path: model.outboxURL.path) { model.chooseOutboxFolder() } } } else { GridRow(alignment: .center) { fieldLabel("OneDrive folder") if let folder = model.resolvedOneDriveFolder { folderValue(path: folder.path) { model.chooseOneDriveFolder() } } else { // One-line row, same shape as the normal path row: "Not found" // where the path would be, Choose… stays live. The explanation // moves to the caption below instead of wrapping this row. folderValue(path: "Not found") { model.chooseOneDriveFolder() } } } GridRow { Text( model.resolvedOneDriveFolder != nil ? "The PDF is saved here and this same folder is watched for the marked-up copy. On the iPad open it from Files > OneDrive." : "No OneDrive folder found. Sign in to OneDrive, or choose a folder." ) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .gridCellColumns(2) } } GridRow { Button("Reveal spool folder") { model.openSpoolFolder() } .gridCellColumns(2) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 4) } } .padding(16) .frame(minWidth: 320, idealWidth: 360, maxWidth: 360, alignment: .leading) .controlSize(.small) .onDisappear { disarmHotkeyRecorder() } } private var transportBinding: Binding { Binding(get: { model.transport }, set: { model.chooseTransport($0) }) } private func armHotkeyRecorder() { guard !isRecordingHotkey else { return } isRecordingHotkey = true recorder.onKey = { keyCode, flags in handleRecorderKey(keyCode: keyCode, flags: flags) } recorder.arm() } private func handleRecorderKey(keyCode: UInt16, flags: NSEvent.ModifierFlags) { if keyCode == 53 { // kVK_Escape disarmHotkeyRecorder() return } guard let pref = HotkeyPreference.fromKeyEvent(keyCode: keyCode, modifierFlags: flags) else { return } pref.save() model.reRegisterHotkey() disarmHotkeyRecorder() } private func disarmHotkeyRecorder() { recorder.disarm() recorder.onKey = nil isRecordingHotkey = false } private func fieldLabel(_ title: String) -> some View { Text(title) .lineLimit(1) .frame(width: labelWidth, alignment: .trailing) .gridColumnAlignment(.trailing) .frame(minHeight: 22, alignment: .trailing) } private func folderValue(path: String, choose: @escaping () -> Void) -> some View { HStack(spacing: 8) { Text(path) .lineLimit(1) .truncationMode(.middle) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Button("Choose…") { choose() } } .frame(minHeight: 22) } } /// Local keyDown monitor for the Settings capture-hotkey recorder. Callbacks hop onto the /// main actor the same way `RegionPickerController` does — local monitors fire on the /// main run loop during `NSApp.sendEvent`. @MainActor private final class HotkeyRecorderBox { var onKey: ((UInt16, NSEvent.ModifierFlags) -> Void)? private var monitor: Any? func arm() { disarm() monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self else { return event } let keyCode = event.keyCode let rawFlags = event.modifierFlags.rawValue MainActor.assumeIsolated { self.onKey?(keyCode, NSEvent.ModifierFlags(rawValue: rawFlags)) } return nil } } func disarm() { if let monitor { NSEvent.removeMonitor(monitor) } monitor = nil } } extension AppModel: SettingsWindowPresenting { private static var settingsWindowController: NSWindowController? public func presentSettingsWindow() { if let existing = Self.settingsWindowController { existing.window?.makeKeyAndOrderFront(nil) NSApp.activate() return } let hosting = NSHostingController(rootView: SettingsView().environment(self)) let window = NSWindow(contentViewController: hosting) window.title = "Redline Settings" window.styleMask = [.titled, .closable] window.isReleasedWhenClosed = false window.center() let controller = NSWindowController(window: window) Self.settingsWindowController = controller controller.showWindow(nil) NSApp.activate() } func chooseOutboxFolder() { guard let url = chooseDirectory(startingAt: outboxURL) else { return } FolderSettings.setOutbox(url) setFolderURLs(outbox: url, watch: watchFolderURL) setStatus("Output folder set to \(url.lastPathComponent).") } func chooseWatchFolder() { guard let url = chooseDirectory(startingAt: watchFolderURL) else { return } FolderSettings.setWatchFolder(url) setFolderURLs(outbox: outboxURL, watch: url) Task { do { try await watcher.updateWatchFolder(url) setStatus("Watch folder set to \(url.lastPathComponent).") } catch { setStatus( (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." ) } } } /// 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. /// Refuses while a send is in flight (send() snapshots its own folder/transport, but /// switching mid-send is still confusing UX — nothing to gain by allowing it). /// The async reconcile below is generation-guarded: `reconcileGeneration` is bumped /// synchronously before the Task starts, and the Task checks its own snapshot against /// the live value before every mutating step, so rapid toggling (this function or /// chooseOneDriveFolder, in any order) always lets the LAST choice win instead of an /// earlier, superseded call applying its stale folder/flag after a later one already won. /// The Task's handle is stored in `pendingReconcileTask` so send() can await its /// completion before snapshotting transport/folder — closing the OTHER race, where a /// toggle is immediately followed by Send before this reconcile has settled. func chooseTransport(_ value: SendTransport) { guard !isSending else { setStatus("Finish the current send first.") return } 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) setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder()) reconcileGeneration += 1 let generation = reconcileGeneration pendingReconcileTask = Task { guard generation == self.reconcileGeneration else { return } await watcher.setRecordUncommented(value == .airDrop) guard generation == self.reconcileGeneration else { return } do { try await watcher.updateWatchFolder(folders.watch) } catch { guard generation == self.reconcileGeneration else { return } setStatus( (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." ) } } } /// Refuses while a send is in flight, same reasoning as chooseTransport. See /// chooseTransport's doc comment for the generation-guard mechanism shared here. func chooseOneDriveFolder() { guard !isSending else { setStatus("Finish the current send first.") return } let start = resolvedOneDriveFolder ?? FileManager.default.homeDirectoryForCurrentUser guard let url = chooseDirectory(startingAt: start) else { return } TransportSettings.setOneDriveFolder(url) try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder()) guard transport == .oneDrive else { return } setFolderURLs(outbox: url, watch: url) reconcileGeneration += 1 let generation = reconcileGeneration pendingReconcileTask = Task { guard generation == self.reconcileGeneration else { return } do { try await watcher.updateWatchFolder(url) guard generation == self.reconcileGeneration else { return } setStatus("OneDrive folder set to \(url.lastPathComponent).") } catch { guard generation == self.reconcileGeneration else { return } setStatus( (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." ) } } } private func chooseDirectory(startingAt directory: URL) -> URL? { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = false panel.canCreateDirectories = true panel.prompt = "Choose" panel.directoryURL = directory guard panel.runModal() == .OK else { return nil } return panel.url } }