fix: window session reset, model-row locking, startup order, hotkey release, counter, history gate, dictionary editor and search polish

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 16:04:37 +02:00
co-authored by Claude Sonnet 5.5
parent 31c34a1d4a
commit ab7359156b
6 changed files with 52 additions and 11 deletions
+35 -8
View File
@@ -47,6 +47,7 @@ final class AppModel: ObservableObject {
@Published private(set) var installedVariants: Set<String> = []
@Published private(set) var recordingStartedAt: Date?
@Published private(set) var lastDuration: TimeInterval?
@Published private(set) var loadingVariant: String?
/// Bumped whenever the history or dictionary changes, so views that cache derived data can refresh.
@Published private(set) var historyRevision = 0
@Published private(set) var dictionaryRevision = 0
@@ -61,7 +62,6 @@ final class AppModel: ObservableObject {
private let policy: InsertionPolicy
private let controller: DictationController
private var modelTask: Task<Void, Never>?
private var loadingVariant: String?
private var cancellables = Set<AnyCancellable>()
convenience init() {
@@ -123,6 +123,11 @@ final class AppModel: ObservableObject {
}
}
/// Like `recordingBlocker`, plus: a window recording is only kept when the history is on.
var windowRecordingBlocker: String? {
recordingBlocker ?? (settings.keepHistory ? nil : Strings.historyNeededForWindow)
}
var statusLine: String {
if let blocker = recordingBlocker, state == .idle { return blocker }
switch state {
@@ -153,14 +158,19 @@ final class AppModel: ObservableObject {
controller.onStateChange = { [weak self] state in
guard let self else { return }
self.state = state
if state != .recording, let started = self.recordingStartedAt {
// The counter shows the recording length, not the time spent transcribing and pasting.
self.lastDuration = Date().timeIntervalSince(started)
self.recordingStartedAt = nil
}
switch state {
case .recording:
self.lastError = nil
self.recordingStartedAt = Date()
self.levelSource.reset()
case .idle:
if let started = self.recordingStartedAt { self.lastDuration = Date().timeIntervalSince(started) }
self.recordingStartedAt = nil
// Also covers a window recording that was ended with the hotkey; the paste decision is already made.
self.policy.windowSession = false
self.levelSource.reset()
default: break
}
@@ -192,6 +202,9 @@ final class AppModel: ObservableObject {
permissions.objectWillChange
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
settings.objectWillChange
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
dictionary.onChange = { [weak self] in
guard let self else { return }
@@ -199,10 +212,15 @@ final class AppModel: ObservableObject {
self.dictionaryList.reload()
self.dictionaryRevision += 1
}
dictionary.onExternalChange = { [weak self] in self?.dictionaryList.externalChangeDetected() }
history.onChange = { [weak self] in
self?.historyRevision += 1
self?.historyList.refresh()
}
// Backstop for editors that write in place, which the folder watcher can miss.
NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.dictionary.reloadIfChanged() }
}
NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in
MainActor.assumeIsolated { history.flush() }
}
@@ -211,19 +229,28 @@ final class AppModel: ObservableObject {
private func start() async {
do { try dictionary.load() } catch { lastError = error.localizedDescription }
dictionary.startWatching()
if case .recoveredCorrupt = history.load() {
switch history.load() {
case .recoveredCorrupt:
lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet."
case .unreadable:
lastError = Strings.historyUnreadable
case .empty, .loaded:
break
}
refreshInstalled()
if !ModelCatalog.all.contains(where: { $0.id == settings.modelVariant }) {
settings.modelVariant = ModelCatalog.defaultVariant
}
activateModel(settings.modelVariant) // the download must not wait for the microphone prompt
await permissions.requestMissing()
retryHotkey()
activateModel(settings.modelVariant)
}
func retryHotkey() {
permissions.refresh()
do {
try hotkey.start()
if lastError == HotkeyMonitor.HotkeyError.tapUnavailable.errorDescription { lastError = nil }
} catch {
lastError = error.localizedDescription
}
@@ -232,7 +259,7 @@ final class AppModel: ObservableObject {
// MARK: recording
private func handleHotkey(_ event: HotkeyEvent) {
guard modelReady else { return }
guard event == .released || modelReady else { return }
if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
lastError = Strings.microphoneMissing
return
@@ -250,7 +277,7 @@ final class AppModel: ObservableObject {
}
func startWindowRecording() {
guard recordingBlocker == nil, state == .idle else { return }
guard windowRecordingBlocker == nil, state == .idle else { return }
policy.windowSession = true
Task {
await controller.handle(.pressed)
@@ -305,7 +332,7 @@ final class AppModel: ObservableObject {
}
func deleteModel(_ variant: String) {
guard variant != activeVariant else { return }
guard variant != activeVariant, variant != loadingVariant else { return }
do {
try storage.delete(variant)
} catch {