From 92d4b8d54948eda407a0f688f544bc5721b9629b Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:20:52 -0400 Subject: [PATCH 1/3] fix(settings): clear preset save errors after a successful retry --- Sources/DilatedUI/AppModel.swift | 9 +++++++-- Tests/DilatedUITests/AppModelTests.swift | 22 ++++++++++++++++++---- docs/RELEASING.md | 2 +- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/Sources/DilatedUI/AppModel.swift b/Sources/DilatedUI/AppModel.swift index 4e649db..7c26222 100644 --- a/Sources/DilatedUI/AppModel.swift +++ b/Sources/DilatedUI/AppModel.swift @@ -1152,6 +1152,7 @@ public struct AppSettings: Codable, Equatable, Sendable { presets.map(String.init).joined(separator: ", ") } private static let presetsError = "Use up to 10 durations between 1 and 10080 minutes." + @ObservationIgnored private var presetsSaveError: String? func clearPresetsError() { if message == Self.presetsError { message = nil } } @@ -1170,12 +1171,16 @@ public struct AppSettings: Codable, Equatable, Sendable { value.defaultMinutes = 0 } do { try applySettings(value) } catch { - message = "Couldn't save settings: \(error.localizedDescription)" + let issue = "Couldn't save settings: \(error.localizedDescription)" + presetsSaveError = issue + message = issue onChange?() return false } - onChange?() + if message == presetsSaveError { message = nil } + presetsSaveError = nil clearPresetsError() + onChange?() return true } func setTriggersEnabled(_ enabled: Bool) throws { diff --git a/Tests/DilatedUITests/AppModelTests.swift b/Tests/DilatedUITests/AppModelTests.swift index 8318f0a..a7cb01e 100644 --- a/Tests/DilatedUITests/AppModelTests.swift +++ b/Tests/DilatedUITests/AppModelTests.swift @@ -82,7 +82,8 @@ import Testing #expect(model.message == nil) } - @Test func applyPresetsReportsSaveFailureAndKeepsPresets() throws { + @Test(arguments: [false, true]) + func presetSaveRetryClearsOnlyItsOwnFailure(replaceMessage: Bool) throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let model = AppModel(endpoint: .init(stateDirectory: directory), power: { .init() }) defer { @@ -91,12 +92,25 @@ import Testing [.posixPermissions: 0o700], ofItemAtPath: directory.path) try? FileManager.default.removeItem(at: directory) } - #expect(model.applyPresets(text: "15, 30, 60")) + try #require(model.applyPresets(text: "15, 30, 60")) try FileManager.default.setAttributes( [.posixPermissions: 0o500], ofItemAtPath: directory.path) - #expect(!model.applyPresets(text: "10, 20")) + try #require(!model.applyPresets(text: "10, 20")) #expect(model.settings.presets == [15, 30, 60]) - #expect(model.message?.hasPrefix("Couldn't save settings") == true) + let failure = try #require(model.message) + try #require(failure.hasPrefix("Couldn't save settings")) + model.clearPresetsError() + #expect(model.message == failure) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], ofItemAtPath: directory.path) + if replaceMessage { model.message = "Something else" } + let expectedMessage = replaceMessage ? "Something else" : nil + var lastNotification: String? = "No notification" + model.onChange = { [weak model] in lastNotification = model?.message } + try #require(model.applyPresets(text: "10, 20")) + #expect(model.settings.presets == [10, 20]) + #expect(model.message == expectedMessage) + #expect(lastNotification == expectedMessage) } @Test func plannedUpdateRestoresOnlyTimersWithTheirOriginalDeadlines() async throws { diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 8f5167e..9962a72 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -46,7 +46,7 @@ A JSON file tied to the exact artifacts: `checks` needs an entry for every name in `ACCEPTANCE_CHECKS` in `scripts/stage-public-release.py`: `macOS26ClosedLid`, `macOS27ClosedLid`, `helperCrashRecovery`, `appCrashRecovery`, `heartbeatExpiry`, `stopResume`, `guardTransitions`, `rebootRecovery`, `approvalDenied`, `helperUpgrade`, `sparkleUpgrade`, `uninstallNormalSleep` and `nativeUI`. Each has `status`, `machine`, `macOS` and `evidence`. -For a GA version every check in `ACCEPTANCE_CHECKS` must be `passed`. For an alpha, beta or rc, `macOS26ClosedLid`, `heartbeatExpiry` and `stopResume` must be `passed`. The others can be `failed` or `notRun`, with evidence saying why. Staging lists those as unverified in the manifest, `latest.json` and the cask caveats. Fixtures and mock runs aren't hardware evidence. +For GA releases, every check in `ACCEPTANCE_CHECKS` must be `passed`. For an alpha, beta or rc, `macOS26ClosedLid`, `heartbeatExpiry` and `stopResume` must be `passed`. The others can be `failed` or `notRun`, with evidence saying why. Staging lists those as unverified in the manifest, `latest.json` and the cask caveats. Fixtures and mock runs aren't hardware evidence. ## 5. Stage the public release From 48c747f15caf6297e70ef530b1d68d429b0fd5f3 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:51:26 -0400 Subject: [PATCH 2/3] fix(settings): keep preset drafts when glass appearance changes --- Package.resolved | 11 ++- Package.swift | 7 +- .../DilatedUI/Settings/SettingsWindow.swift | 14 +++- .../SettingsContentInteractionTests.swift | 75 +++++++++++++++++++ 4 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 Tests/DilatedUITests/SettingsContentInteractionTests.swift diff --git a/Package.resolved b/Package.resolved index 961ef59..6c3f010 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "19a2338454762c7e64d98193d4c410989830c72966aac6c42670e72f14cbe9bd", + "originHash" : "bcdf4e770b85b6723c300bf8dcb4bb1665bdf088f574776fcc10cefc9ff101a5", "pins" : [ { "identity" : "sparkle", @@ -9,6 +9,15 @@ "revision" : "eef1a539a373c1f1a320624b1130fc5de7b2e100", "version" : "2.10.0" } + }, + { + "identity" : "viewinspector", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nalexn/ViewInspector", + "state" : { + "revision" : "2dacb6e514be19379c605a5a11564d0f3fea89d9", + "version" : "0.10.5" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index a0719cd..86e63fb 100644 --- a/Package.swift +++ b/Package.swift @@ -13,7 +13,8 @@ let package = Package( .executable(name: "dilated-helper", targets: ["DilatedHelper"]), ], dependencies: [ - .package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.10.0") + .package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.10.0"), + .package(url: "https://github.com/nalexn/ViewInspector", exact: "0.10.5"), ], targets: [ .target(name: "DilatedCore"), @@ -32,6 +33,8 @@ let package = Package( .executableTarget(name: "DilatedHelper", dependencies: ["DilatedCore"]), .testTarget(name: "DilatedCoreTests", dependencies: ["DilatedCore"]), .testTarget(name: "DilatedControlTests", dependencies: ["DilatedControl"]), - .testTarget(name: "DilatedUITests", dependencies: ["DilatedUI"]), + .testTarget( + name: "DilatedUITests", + dependencies: ["DilatedUI", .product(name: "ViewInspector", package: "ViewInspector")]), ] ) diff --git a/Sources/DilatedUI/Settings/SettingsWindow.swift b/Sources/DilatedUI/Settings/SettingsWindow.swift index 3576142..5b111b3 100644 --- a/Sources/DilatedUI/Settings/SettingsWindow.swift +++ b/Sources/DilatedUI/Settings/SettingsWindow.swift @@ -148,12 +148,16 @@ private enum SettingsPage: String, CaseIterable, Identifiable { } @MainActor struct SettingsContent: View { + #if DEBUG + var didAppear: ((Self) -> Void)? + #endif @Bindable var model: AppModel var solid = false var close: () -> Void = {} @State private var page: SettingsPage? = .overview @FocusState private var focusedPage: SettingsPage? @State private var presets = "" + @State private var presetsLoaded = false @FocusState private var presetsFocused: Bool @State private var loginRefresh = 0 @State private var cliRefresh = 0 @@ -247,7 +251,15 @@ private enum SettingsPage: String, CaseIterable, Identifiable { } .font(DesignFont.sans(13)).foregroundStyle(DesignPalette.ink).tint(SettingsTheme.control) .frame(minWidth: 860, minHeight: 600) - .onAppear { presets = AppModel.presetsText(model.settings.presets) } + .onAppear { + if !presetsLoaded { + presets = AppModel.presetsText(model.settings.presets) + presetsLoaded = true + } + #if DEBUG + didAppear?(self) + #endif + } .onChange(of: model.settings.presets) { _, saved in if !presetsFocused { presets = AppModel.presetsText(saved) } } diff --git a/Tests/DilatedUITests/SettingsContentInteractionTests.swift b/Tests/DilatedUITests/SettingsContentInteractionTests.swift new file mode 100644 index 0000000..63b187e --- /dev/null +++ b/Tests/DilatedUITests/SettingsContentInteractionTests.swift @@ -0,0 +1,75 @@ +import AppKit +import DilatedControl +import DilatedCore +import SwiftUI +import Testing +import ViewInspector + +@testable import DilatedUI + +#if DEBUG + @MainActor struct SettingsContentInteractionTests { + @Test(arguments: ["30, ", ""]) + func changingGlassPreservesTheSelectedPageAndUnsavedPresets(draft: String) throws { + _ = NSApplication.shared + FontRegistry.registerBundledFonts() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = AppModel(endpoint: .init(stateDirectory: directory), power: { .init() }) + defer { model.shutdown() } + let originalPresets = model.settings.presets + let controller = SettingsWindow(model: model) + defer { + controller.stop() + controller.close() + } + let window = try #require(controller.window) + let host = try #require(controller.hostingViewForTesting as? NSHostingView) + var mounted: SettingsContent? + defer { mounted = nil } + var content = host.rootView + content.didAppear = { mounted = $0 } + host.rootView = content + try render(host) + let live = try #require(mounted) + let page = try live.inspect().find(ViewType.Button.self) { + try $0.accessibilityLabel().string() == "Keep Awake" + } + try page.tap() + try render(host) + let field = try #require( + descendants(of: host).compactMap { $0 as? NSTextField }.first { + $0.placeholderString == "15, 30, 60" + }) + try #require(window.makeFirstResponder(field)) + let editor = try #require(field.currentEditor() as? NSTextView) + editor.insertText( + draft, replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) + try render(host) + try #require(field.stringValue == draft) + for solid in [true, false, true] { + controller.applyContent(solid: solid) + try render(host) + let retained = try #require( + descendants(of: host).compactMap { $0 as? NSTextField }.first { + $0.placeholderString == "15, 30, 60" + }) + #expect(retained.stringValue == draft) + #expect(model.settings.presets == originalPresets) + #expect(controller.hostingViewForTesting === host) + #expect(!window.isVisible) + } + } + + private func descendants(of root: NSView) -> [NSView] { + [root] + root.subviews.flatMap { descendants(of: $0) } + } + + private func render(_ view: NSView) throws { + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + } + } +#endif From 397bef309ad6b5ad3a634e93c1fcd3d9f60ed27e Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:25:55 -0400 Subject: [PATCH 3/3] fix(settings): preserve identical errors from other settings saves --- Sources/DilatedUI/AppModel.swift | 14 ++++++------- Tests/DilatedUITests/AppModelTests.swift | 26 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Sources/DilatedUI/AppModel.swift b/Sources/DilatedUI/AppModel.swift index 7c26222..6fd8391 100644 --- a/Sources/DilatedUI/AppModel.swift +++ b/Sources/DilatedUI/AppModel.swift @@ -191,7 +191,9 @@ public struct AppSettings: Codable, Equatable, Sendable { @ObservationIgnored public var onShortcutAssignment: (([ShortcutAction: AwakeShortcut]) -> String?)? public var shortcutRecording = false { didSet { onChange?() } } - public var message: String? + public var message: String? { + didSet { messageIsPresetSaveError = false } + } public private(set) var now = Date() @ObservationIgnored public var onChange: (() -> Void)? @ObservationIgnored public var onBatteryOverrideNeeded: ((TimeInterval?) -> Void)? @@ -1152,7 +1154,7 @@ public struct AppSettings: Codable, Equatable, Sendable { presets.map(String.init).joined(separator: ", ") } private static let presetsError = "Use up to 10 durations between 1 and 10080 minutes." - @ObservationIgnored private var presetsSaveError: String? + @ObservationIgnored private var messageIsPresetSaveError = false func clearPresetsError() { if message == Self.presetsError { message = nil } } @@ -1171,14 +1173,12 @@ public struct AppSettings: Codable, Equatable, Sendable { value.defaultMinutes = 0 } do { try applySettings(value) } catch { - let issue = "Couldn't save settings: \(error.localizedDescription)" - presetsSaveError = issue - message = issue + message = "Couldn't save settings: \(error.localizedDescription)" + messageIsPresetSaveError = true onChange?() return false } - if message == presetsSaveError { message = nil } - presetsSaveError = nil + if messageIsPresetSaveError { message = nil } clearPresetsError() onChange?() return true diff --git a/Tests/DilatedUITests/AppModelTests.swift b/Tests/DilatedUITests/AppModelTests.swift index a7cb01e..8216405 100644 --- a/Tests/DilatedUITests/AppModelTests.swift +++ b/Tests/DilatedUITests/AppModelTests.swift @@ -113,6 +113,32 @@ import Testing #expect(lastNotification == expectedMessage) } + @Test func presetRetryPreservesAnIdenticalErrorFromAnotherSettingsSave() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let model = AppModel(endpoint: .init(stateDirectory: directory), power: { .init() }) + defer { + model.shutdown() + try? FileManager.default.setAttributes( + [.posixPermissions: 0o700], ofItemAtPath: directory.path) + try? FileManager.default.removeItem(at: directory) + } + try #require(model.applyPresets(text: "15, 30, 60")) + let originalDefault = model.settings.defaultMinutes + try FileManager.default.setAttributes( + [.posixPermissions: 0o500], ofItemAtPath: directory.path) + try #require(!model.applyPresets(text: "10, 20")) + let presetError = try #require(model.message) + model.settings.defaultMinutes = 30 + try #require(model.message == presetError) + try #require(model.settings.defaultMinutes == originalDefault) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], ofItemAtPath: directory.path) + try #require(model.applyPresets(text: "10, 20")) + #expect(model.message == presetError) + #expect(model.settings.defaultMinutes == originalDefault) + #expect(model.settings.presets == [10, 20]) + } + @Test func plannedUpdateRestoresOnlyTimersWithTheirOriginalDeadlines() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) }