Files
shotdeck/Sources/Shotdeck/SettingsView.swift
T
kua-agentandClaude Fable 5.1 6484fde530 fix: refuse transport changes mid-send; generation-guard rapid toggling; one-line "not found" row
MAJOR: chooseTransport/chooseOneDriveFolder now refuse (status "Finish the
current send first.") while isSending is true, closing off the send-vs-
transport-switch race at the UI entry point (SendController.swift's commit
in this same series is the structural fix underneath).

MINOR: both functions spawned unstructured `Task { }` calls to
watcher.setRecordUncommented/updateWatchFolder; rapid toggling could apply
an earlier, superseded call's folder/flag after a later one had already won.
Fixed with a monotonically increasing `reconcileGeneration` counter
(AppModel.swift) bumped synchronously before each Task starts; the Task
checks its own snapshot against the live value before every mutating step
(not just once via Task.isCancelled), so the LAST choice always wins.
Verified via ONEDRIVE-SELFTEST's new rapid-toggle sub-step (this branch's
PickerSelfTest.swift commit), which proves the watcher ends up watching the
folder from the last chooseTransport call.

Design fix (Ben, panel-08 review): the "No OneDrive folder found — sign in to
OneDrive or choose a folder." value text wrapped over four lines, making that
row tall and ragged next to Choose…. The value column now reads exactly "Not
found" (secondary colour, one line, same as the truncated-path style), and
the explanation moves to the caption below: "No OneDrive folder found. Sign
in to OneDrive, or choose a folder." When a folder IS resolved the caption is
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:27:58 +04:00

336 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.
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
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
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
}
}