AppModel gains pendingReconcileTask (the most recent watcher-reconcile Task
spawned by chooseTransport/chooseOneDriveFolder), which send() now awaits —
see the SendController.swift commit in this series. chooseTransport/
chooseOneDriveFolder store their Task's handle into it instead of firing an
untracked `Task { }`.
bootstrap() now also skips binding the real, process-wide Carbon global
capture hotkey on the same env-var-flagged self-test/headless runs that
already skip the update-check schedule (PickerSelfTest's phases,
PanelSnapshot, and the new ShotdeckTests launch-wiring regression test).
Real Carbon hotkey registration is not safe to exercise in an automated test
process — it can collide with ShotdeckCoreTests' own
HotkeyCenterCarbonTests running in the same test binary — and bootstrap()'s
hotkey step had never actually been exercised by any self-test before (none
of them call bootstrap() directly) until the new real-wiring test in this
series does. A real user launch never sets these env vars, so production
behavior is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
339 lines
13 KiB
Swift
339 lines
13 KiB
Swift
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<SendTransport> {
|
|
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
|
|
}
|
|
}
|