Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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")]),
]
)
9 changes: 7 additions & 2 deletions Sources/DilatedUI/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand Down Expand Up @@ -1152,6 +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 messageIsPresetSaveError = false
func clearPresetsError() {
if message == Self.presetsError { message = nil }
}
Expand All @@ -1171,11 +1174,13 @@ public struct AppSettings: Codable, Equatable, Sendable {
}
do { try applySettings(value) } catch {
message = "Couldn't save settings: \(error.localizedDescription)"
messageIsPresetSaveError = true
onChange?()
return false
}
onChange?()
if messageIsPresetSaveError { message = nil }
clearPresetsError()
onChange?()
return true
}
func setTriggersEnabled(_ enabled: Bool) throws {
Expand Down
14 changes: 13 additions & 1 deletion Sources/DilatedUI/Settings/SettingsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
}
Expand Down
48 changes: 44 additions & 4 deletions Tests/DilatedUITests/AppModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -91,12 +92,51 @@ 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 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 {
Expand Down
75 changes: 75 additions & 0 deletions Tests/DilatedUITests/SettingsContentInteractionTests.swift
Original file line number Diff line number Diff line change
@@ -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<SettingsContent>)
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
2 changes: 1 addition & 1 deletion docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading