diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 885bae9..a42d081 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -119,6 +119,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { apiKey: APIKeyModel( keyStore: InMemoryAPIKeyStore(), validateKey: { UITestKeyValidation.result(for: $0) })) + // Offline update check so the Settings "Check for Updates" button shows a + // stable "up to date" result without reaching GitHub. Assigned before the + // `lazy` default is ever read (first check), so it replaces it cleanly. + updateCheckModel = .uiTest() } else { coord = AppCoordinator(onMissingAPIKey: onMissingAPIKey) } diff --git a/App/Blurt/Blurt/Overlay/OverlayView.swift b/App/Blurt/Blurt/Overlay/OverlayView.swift index 3dfe975..1f6cdb5 100644 --- a/App/Blurt/Blurt/Overlay/OverlayView.swift +++ b/App/Blurt/Blurt/Overlay/OverlayView.swift @@ -8,7 +8,7 @@ private enum OverlayBrandPalette { struct OverlayView: View { // The single source of pill state. The live mic level is *not* read in this - // body: that would rebuild the whole pill (glass, shadow, tint, REC tag) on + // body: that would rebuild the whole pill (capsule, shadow, REC tag) on // every ~30 Hz meter tick. Only `bridge.state` is read here (via `state` // below); the leaf bar view (`WaveformBarsLevel`) reads `bridge.level`, so // @Observable confines the per-tick invalidation to the bars — the rest of @@ -27,47 +27,37 @@ struct OverlayView: View { private var pillWidth: CGFloat { OverlayWindowController.pillSize.width } private var pillHeight: CGFloat { OverlayWindowController.pillSize.height } - // A dark tint infused into the Liquid Glass capsule. `.regular` glass passes - // more of the backdrop through than the old frosted-material-plus-fill stack, - // so these run deeper than the material-era values (0.5–0.58) to keep the - // white bars and 10 pt status text legible over a bright desktop — tune - // on-device if the pill reads too heavy over dark wallpapers. - private var tintColor: Color { + // The pill body is a solid, opaque capsule — not Liquid Glass. `.regular` glass + // is an adaptive material that samples the backdrop to decide its light/dark + // rendering, and can't do so until it's composited on screen; fading in from + // alpha 0 it flashed white for a frame over a dark desktop before settling. + // A fixed fill never adapts, so it fades in the same dark gray on any backdrop. + // Every state is the same dark gray except `.error`, which keeps a red body as + // an alarm cue (the ↻ "Try again" content alone shouldn't have to carry it). + private var fillColor: Color { switch state { case .error: - return .red.opacity(0.6) - case .recording, .pasted, .noTarget: - // "Pasted"/"Copied" share recording's cyan-tinted frame: same brand --ice - // language, reading as an informational notice rather than the red error flash. - return Color(red: 0.08, green: 0.2, blue: 0.24).opacity(0.68) - case .processing: - return Color(red: 0.18, green: 0.12, blue: 0.24).opacity(0.7) - case .idle: - return .black.opacity(0.6) + return Color(red: 0.62, green: 0.13, blue: 0.13) + case .recording, .processing, .pasted, .noTarget, .idle: + return Color(white: 0.16) } } var body: some View { content .frame(width: pillWidth, height: pillHeight) - // Real Liquid Glass, not the pre-Tahoe imitation stack (frosted material + - // tint overlay + stroke): the system draws the refractive edge highlights, - // so the per-state story is carried by the tint alone. Deliberately not - // `.interactive()` — the pill is a passive status surface, and interactive - // glass hit-testing could swallow the mouse-down that starts the panel's - // `isMovableByWindowBackground` drag, the only way to reposition the pill. - // No `GlassEffectContainer`/`glassEffectID` either: there is exactly one - // glass element and its capsule never changes shape or presence, so the - // morph machinery would be inert scaffolding. - .glassCapsuleCompat(tint: tintColor) - // Flatten the glass into one layer before shadowing so the drop shadow - // takes the capsule's rounded alpha, not the rectangular layer bounds - // (which renders as a boxy halo, most visible on a white backdrop). The - // explicit shadow keeps the dark pill separated from light content — - // whatever ambient shadow the system gives glass is subtle and outside - // our control. The soft falloff spreads to ~2x the radius plus the offset; - // OverlayWindowController.shadowMargin is sized to contain that so it fades - // fully to nothing before the panel edge rather than clipping into a line. + // Solid, opaque capsule rather than Liquid Glass: a fixed fill never adapts + // to the backdrop, so the pill fades in the same dark gray everywhere with + // no first-frame flash (see `fillColor`). `.animation` below cross-fades the + // fill on a state change (e.g. into the red error body). + .background(Capsule().fill(fillColor)) + // Flatten into one layer before shadowing so the drop shadow takes the + // capsule's rounded alpha, not the rectangular layer bounds (which renders + // as a boxy halo, most visible on a white backdrop). The explicit shadow + // keeps the dark pill separated from light content. The soft falloff + // spreads to ~2x the radius plus the offset; OverlayWindowController.shadowMargin + // is sized to contain that so it fades fully to nothing before the panel + // edge rather than clipping into a line. .compositingGroup() .shadow(color: .black.opacity(0.25), radius: 10, y: 3) .animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: state) @@ -88,8 +78,8 @@ struct OverlayView: View { // Never actually on screen: dismissal keeps the last real state through // the fade-out and only settles to idle once the panel is ordered out // (OverlayWindowController.dismissPanel). `Color.clear` (not `EmptyView`, - // which renders nothing — modifiers on it produce no view, so the glass - // capsule would collapse with it) keeps the pill's shape intact for + // which renders nothing — modifiers on it produce no view, so the capsule + // background would collapse with it) keeps the pill's shape intact for // `hide()`'s pre-hide reset. Color.clear case .recording: diff --git a/App/Blurt/Blurt/Shared/LiquidGlass.swift b/App/Blurt/Blurt/Shared/LiquidGlass.swift index 09c0e0b..f514d56 100644 --- a/App/Blurt/Blurt/Shared/LiquidGlass.swift +++ b/App/Blurt/Blurt/Shared/LiquidGlass.swift @@ -26,19 +26,4 @@ extension View { } } } - - /// Liquid Glass capsule surface on macOS 26+, tinted; falls back to the - /// pre-Tahoe imitation stack (frosted material + tint overlay, capsule-clipped) - /// on macOS 15–25 so the overlay pill still reads correctly. - @ViewBuilder - func glassCapsuleCompat(tint: Color) -> some View { - if #available(macOS 26.0, *) { - glassEffect(.regular.tint(tint), in: .capsule) - } else { - background { - Capsule().fill(.regularMaterial) - Capsule().fill(tint) - } - } - } } diff --git a/App/Blurt/Blurt/UITestSupport.swift b/App/Blurt/Blurt/UITestSupport.swift index e4b6d4a..d305639 100644 --- a/App/Blurt/Blurt/UITestSupport.swift +++ b/App/Blurt/Blurt/UITestSupport.swift @@ -121,6 +121,41 @@ } } + /// Offline update check for UI testing: a transport that always returns a + /// release older than any real version, so `UpdateChecker` reports + /// "up to date" without reaching GitHub. `UpdateChecker` ignores the response, + /// so a bare `URLResponse` (no force-unwrapped `HTTPURLResponse`) is enough. + nonisolated struct UITestUpdateTransport: HTTPTransport { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let json = Data(#"{"tag_name":"0.0.0","assets":[]}"#.utf8) + // UpdateChecker ignores the response, so any non-nil URL will do; the + // request always carries one (UpdateChecker builds URLRequest(url:)). + let url = request.url ?? URL(fileURLWithPath: "/") + let response = URLResponse( + url: url, mimeType: "application/json", + expectedContentLength: json.count, textEncodingName: nil) + return (json, response) + } + + // The update check only ever GETs; upload is never called. + func upload( + for request: URLRequest, from bodyData: Data, delegate: (any URLSessionTaskDelegate)? + ) async throws -> (Data, URLResponse) { + throw URLError(.unsupportedURL) + } + } + + extension UpdateCheckModel { + /// The update controller used under UI testing: a fixed running version and a + /// transport that always reports "up to date", so clicking "Check for Updates" + /// deterministically shows the up-to-date result alert with no network. + static func uiTest() -> UpdateCheckModel { + UpdateCheckModel( + checker: UpdateChecker(transport: UITestUpdateTransport()), + currentVersion: SemanticVersion("1.0.0")) + } + } + extension DictationComponents { /// The all-stub pipeline used under UI testing: no mic, no network, no /// Accessibility paste. `UITestMic` inherits `MicCaptureProtocol`'s default diff --git a/App/Blurt/Blurt/Update/UpdateCheckModel.swift b/App/Blurt/Blurt/Update/UpdateCheckModel.swift index 9712361..3845197 100644 --- a/App/Blurt/Blurt/Update/UpdateCheckModel.swift +++ b/App/Blurt/Blurt/Update/UpdateCheckModel.swift @@ -20,6 +20,10 @@ final class UpdateCheckModel { /// button and menu both fired), so we never stack two result alerts. private var isChecking = false + /// The running app version for display in the Settings "Updates" section + /// (nil when the bundle version can't be parsed), e.g. "0.1.31". + var currentVersionText: String? { currentVersion.map { "\($0)" } } + /// `currentVersion`, `openURL`, and `presentingWindow` are injected (with /// sensible production defaults) so this stays exercisable without a real /// bundle, a live browser, or an on-screen window. diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index c4761d6..ac20955 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -27,7 +27,7 @@ struct SettingsWindowRoot: View { GeneralSettingsTab(coordinator: coordinator) .tabItem { Label("General", systemImage: "gearshape") } .tag(Tab.general) - AdvancedSettingsTab() + AdvancedSettingsTab(updateModel: appDelegate.updateCheckModel) .tabItem { Label("Advanced", systemImage: "gearshape.2") } .tag(Tab.advanced) } @@ -56,12 +56,14 @@ private struct GeneralSettingsTab: View { } } -/// The occasional stuff: the developer-mode log toggle. Kept out of General so -/// the common pane stays short. (Updates are checked from the app menu and the -/// menu-bar item — see `BlurtCommands` / `MenuBarContent` — not from here.) +/// The occasional stuff: checking for an update and the developer-mode log +/// toggle. Kept out of General so the common pane stays short. private struct AdvancedSettingsTab: View { + let updateModel: UpdateCheckModel + var body: some View { Form { + UpdateSection(model: updateModel) DeveloperSection() } .formStyle(.grouped) @@ -70,6 +72,33 @@ private struct AdvancedSettingsTab: View { } } +/// The Updates section of the Settings window: the running version and a +/// "Check for Updates" button that runs the check and reports the result in a +/// modal (see `UpdateCheckModel`). The same check is reachable from the +/// "Check for Updates…" app-menu command and the menu-bar item; all three share +/// the one `UpdateCheckModel` owned by `AppDelegate`, so a check from any place +/// runs through the same controller. +private struct UpdateSection: View { + let model: UpdateCheckModel + + var body: some View { + Section { + SettingRow(title: versionTitle, systemImage: "arrow.triangle.2.circlepath") { + Button("Check for Updates") { model.checkForUpdates() } + .accessibilityIdentifier(UITestIdentifiers.updateCheck) + } + } header: { + Text("Updates") + } + } + + /// "Blurt 0.1.31" when the version is known, otherwise a plain "Blurt" (the + /// button still works — the check reads the version itself). + private var versionTitle: String { + model.currentVersionText.map { "Blurt \($0)" } ?? "Blurt" + } +} + /// The Developer section of the Settings window: an opt-in switch for developer /// mode. While on, every completed dictation is appended to the local JSONL log /// (see `DictationLog` — its gate reads the same default this toggle writes), diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index ffd4798..7694405 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -104,6 +104,29 @@ final class SettingsUITests: BlurtUITestCase { XCTAssertEqual("\(toggle.value ?? "")", "1", "Clicking should switch developer mode on") } + /// The Advanced pane's "Check for Updates" button runs the check and reports + /// the result in a modal. Under UI testing the check is stubbed offline to + /// always report up-to-date, so clicking it surfaces the "You’re up to date" + /// result sheet deterministically (no network). + func testCheckForUpdatesShowsResultAlert() { + let settings = openSettingsWindow() + selectSettingsTab(settings, named: UITestIdentifiers.advancedSettingsTab) + + // Query app-wide: selecting the Advanced tab retitles the window, so a + // window-scoped proxy captured beforehand would go stale. + let button = app.descendants(matching: .any) + .matching(identifier: UITestIdentifiers.updateCheck).firstMatch + XCTAssertTrue(button.waitForExistence(timeout: 10), "Check for Updates button not found") + button.click() + + let alert = app.sheets.firstMatch + XCTAssertTrue(alert.waitForExistence(timeout: 10), "The check should present a result sheet") + XCTAssertTrue( + alert.staticTexts["You’re up to date"].exists, + "The stubbed check should report up to date") + alert.buttons["OK"].click() + } + /// After a key is saved, "Change" re-opens the editable field so the key can /// be rotated. func testChangeReopensEditableFieldAfterSaving() { diff --git a/App/Blurt/Shared/UITestIdentifiers.swift b/App/Blurt/Shared/UITestIdentifiers.swift index eabfab5..7f5e412 100644 --- a/App/Blurt/Shared/UITestIdentifiers.swift +++ b/App/Blurt/Shared/UITestIdentifiers.swift @@ -51,6 +51,7 @@ enum UITestIdentifiers { static let hotkeyPicker = "settings.hotkey.picker" static let soundPicker = "settings.sound.picker" static let developerToggle = "settings.developer.toggle" + static let updateCheck = "settings.update.check" /// The dictation overlay pill (`OverlayView`). static let overlayPill = "overlay.pill"