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
4 changes: 4 additions & 0 deletions App/Blurt/Blurt/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
62 changes: 26 additions & 36 deletions App/Blurt/Blurt/Overlay/OverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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:
Expand Down
15 changes: 0 additions & 15 deletions App/Blurt/Blurt/Shared/LiquidGlass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
35 changes: 35 additions & 0 deletions App/Blurt/Blurt/UITestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions App/Blurt/Blurt/Update/UpdateCheckModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 33 additions & 4 deletions App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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),
Expand Down
23 changes: 23 additions & 0 deletions App/Blurt/BlurtUITests/SettingsUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions App/Blurt/Shared/UITestIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down