diff --git a/Sources/localvoxtral/Settings/SettingsPaneHeader.swift b/Sources/localvoxtral/Settings/SettingsPaneHeader.swift new file mode 100644 index 00000000..7ea073af --- /dev/null +++ b/Sources/localvoxtral/Settings/SettingsPaneHeader.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// Title + one-line purpose for the selected pane, above its scrolling content. +/// +/// The subtitle exists because the sidebar row labels are short nouns: "Where +/// speech recognition and polishing run" is the sentence that used to be missing +/// entirely from the tabbed layout. +struct SettingsPaneHeader: View { + let tab: SettingsTab + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(tab.title) + .font(.system(size: 20, weight: .semibold)) + + Text(tab.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + // Matches the sidebar's inset: the window draws its content full-size + // under a transparent titlebar, so the header needs the same clearance. + .padding(.top, SettingsSidebarMetrics.topInset) + .padding(.bottom, 12) + } +} diff --git a/Sources/localvoxtral/Settings/SettingsSidebarView.swift b/Sources/localvoxtral/Settings/SettingsSidebarView.swift new file mode 100644 index 00000000..c334dc3e --- /dev/null +++ b/Sources/localvoxtral/Settings/SettingsSidebarView.swift @@ -0,0 +1,235 @@ +import AppKit +import SwiftUI + +/// Chrome (display copy, symbol, tint, AX identity) for each Settings tab. +/// +/// The raw values are a contract with the AX drills — `scripts/ui-smoke.sh` and +/// `scripts/capture-readme-assets.sh` press `settings.tab.` and scope +/// their content asserts to `settings.pane.` — so the display names +/// here may change freely, the raw values may not. +extension SettingsTab { + /// Sidebar order, top group. Deliberately NOT the declaration order of the + /// enum: raw values are frozen for the scripts, presentation order is not. + static let primarySidebarItems: [SettingsTab] = [ + .general, .dictation, .endpoints, .textProcessing, + ] + + /// Pinned to the bottom of the sidebar, under the spacer. + static let metaSidebarItems: [SettingsTab] = [.about] + + var title: String { + switch self { + case .general: return "General" + case .dictation: return "Dictation" + case .endpoints: return "Endpoints" + case .textProcessing: return "Text Processing" + case .about: return "About" + } + } + + var subtitle: String { + switch self { + case .general: return "Permissions and app-level behavior." + case .dictation: return "How you start, stop, and see dictation." + case .endpoints: return "Where speech recognition and polishing run." + case .textProcessing: return "Replacements and LLM polishing of your transcript." + case .about: return "Version, project, and diagnostics." + } + } + + var systemImage: String { + switch self { + case .general: return "gearshape.fill" + case .dictation: return "mic.fill" + case .endpoints: return "cpu" + case .textProcessing: return "text.badge.checkmark" + case .about: return "info.circle.fill" + } + } + + var tint: Color { + switch self { + case .general: return Color(nsColor: .systemGray) + case .dictation: return Color(nsColor: .systemRed) + case .endpoints: return Color(nsColor: .systemBlue) + case .textProcessing: return Color(nsColor: .systemPurple) + case .about: return Color(nsColor: .systemGray) + } + } + + /// AX identity of the sidebar row that selects this tab. + var accessibilityIdentifier: String { "settings.tab.\(rawValue)" } + + /// AX identity of the scrolling pane content for this tab. The drills scope + /// their content asserts to this subtree, so a sidebar row's label can never + /// vacuously satisfy a pane assert. + var paneAccessibilityIdentifier: String { "settings.pane.\(rawValue)" } +} + +enum SettingsSidebarMetrics { + static let width: CGFloat = 208 + /// Clears the traffic lights: the window uses a transparent, full-size + /// content view, so the first row would otherwise sit under them. + static let topInset: CGFloat = 28 + static let rowHeight: CGFloat = 34 + static let rowCornerRadius: CGFloat = 8 + static let horizontalInset: CGFloat = 10 +} + +/// Hand-rolled sidebar: plain `Button` rows rather than `List`/`NavigationSplitView`. +/// +/// Deliberate (design decision, 2026-07-27): the split-view containers bring a +/// sidebar-collapse toolbar button that can only be removed with private-API +/// hacks, and their rows surface to accessibility as table cells. Plain buttons +/// keep the window chrome under our control and give the AX drills a stable +/// `AXButton` + identifier to press. +struct SettingsSidebarView: View { + @Binding var selection: SettingsTab + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(SettingsTab.primarySidebarItems, id: \.self) { tab in + SettingsSidebarRow(tab: tab, isSelected: selection == tab) { + selection = tab + } + } + + Spacer(minLength: 12) + + ForEach(SettingsTab.metaSidebarItems, id: \.self) { tab in + SettingsSidebarRow(tab: tab, isSelected: selection == tab) { + selection = tab + } + } + + SettingsSidebarVersionFooter() + } + .padding(.horizontal, SettingsSidebarMetrics.horizontalInset) + .padding(.top, SettingsSidebarMetrics.topInset) + .padding(.bottom, 12) + .frame(width: SettingsSidebarMetrics.width, alignment: .leading) + .frame(maxHeight: .infinity, alignment: .top) + .background(SettingsSidebarBackground()) + } +} + +private struct SettingsSidebarRow: View { + let tab: SettingsTab + let isSelected: Bool + let action: () -> Void + + @State private var isHovering = false + + private var fillStyle: Color { + if isSelected { + return Color(nsColor: .selectedContentBackgroundColor) + } + if isHovering { + return Color.primary.opacity(0.06) + } + return Color.clear + } + + private var labelStyle: Color { + isSelected ? Color(nsColor: .alternateSelectedControlTextColor) : Color.primary + } + + var body: some View { + Button(action: action) { + HStack(spacing: 9) { + SettingsSidebarIconTile(systemImage: tab.systemImage, tint: tab.tint) + + Text(tab.title) + .font(.system(size: 13, weight: isSelected ? .semibold : .medium)) + .foregroundStyle(labelStyle) + .lineLimit(1) + + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(height: SettingsSidebarMetrics.rowHeight) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle( + cornerRadius: SettingsSidebarMetrics.rowCornerRadius, + style: .continuous + ) + .fill(fillStyle) + } + .contentShape( + RoundedRectangle( + cornerRadius: SettingsSidebarMetrics.rowCornerRadius, + style: .continuous + ) + ) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .animation(.easeInOut(duration: 0.12), value: isSelected) + .help(tab.subtitle) + .accessibilityLabel(tab.title) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + .accessibilityIdentifier(tab.accessibilityIdentifier) + } +} + +private struct SettingsSidebarIconTile: View { + let systemImage: String + let tint: Color + + var body: some View { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint) + .frame(width: 22, height: 22) + .overlay { + Image(systemName: systemImage) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(.white) + } + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) + } + .shadow(color: Color.black.opacity(0.15), radius: 1, y: 0.5) + .accessibilityHidden(true) + } +} + +private struct SettingsSidebarVersionFooter: View { + private var version: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ?? "dev" + } + + private var build: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "dev" + } + + var body: some View { + Text("v\(version) (\(build))") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.top, 6) + } +} + +/// `.sidebar` material, blended WITHIN the window. +/// +/// `.behindWindow` is the standard macOS sidebar look and was the first choice, +/// but the hand-test on the field Mac (PR #199 review, finding 2) reported it +/// rendering as a flat solid fill in BOTH appearances — this window cannot +/// vibrate what sits behind it. `.withinWindow` is the documented fallback and +/// is the one that actually produces a material here, so it is what ships; the +/// swap is a one-line change either way, not a redesign. +private struct SettingsSidebarBackground: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = .sidebar + view.state = .followsWindowActiveState + view.blendingMode = .withinWindow + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} diff --git a/Sources/localvoxtral/Settings/SettingsWindowChrome.swift b/Sources/localvoxtral/Settings/SettingsWindowChrome.swift new file mode 100644 index 00000000..28a7d418 --- /dev/null +++ b/Sources/localvoxtral/Settings/SettingsWindowChrome.swift @@ -0,0 +1,64 @@ +import AppKit +import SwiftUI + +/// Makes the Settings window's titlebar transparent and full-size, so the +/// sidebar material runs to the top edge. +/// +/// Applied twice on purpose: once when the view is placed in a window, and again +/// on every `didBecomeKey`. SwiftUI re-asserts its own titlebar configuration +/// when it rebuilds the scene's window (observed with the `Settings` scene: +/// close, reopen, and the title reappears), and there is no notification for +/// "SwiftUI just reconfigured you" — becoming key is the reliable moment we get. +struct SettingsWindowChrome: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + SettingsWindowChromeView() + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} + +private final class SettingsWindowChromeView: NSView { + private var observedWindow: NSWindow? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + + if observedWindow !== window { + if let observedWindow { + NotificationCenter.default.removeObserver( + self, + name: NSWindow.didBecomeKeyNotification, + object: observedWindow + ) + } + observedWindow = window + if let window { + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidBecomeKey(_:)), + name: NSWindow.didBecomeKeyNotification, + object: window + ) + } + } + + guard let window else { return } + Self.applyChrome(to: window) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc + private func windowDidBecomeKey(_ notification: Notification) { + guard let window else { return } + Self.applyChrome(to: window) + } + + private static func applyChrome(to window: NSWindow) { + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.styleMask.insert(.fullSizeContentView) + } +} diff --git a/Sources/localvoxtral/SettingsView.swift b/Sources/localvoxtral/SettingsView.swift index f681dbd7..5ef359fb 100644 --- a/Sources/localvoxtral/SettingsView.swift +++ b/Sources/localvoxtral/SettingsView.swift @@ -79,54 +79,61 @@ struct SettingsView: View { } var body: some View { - TabView(selection: $navigator.selectedTab) { - GeneralSettingsPane(settings: settings, viewModel: viewModel) - .tabItem { - Label("General", systemImage: "gearshape") - } - .tag(SettingsTab.general) - - ConnectionSettingsPane( - settings: settings, - viewModel: viewModel, - backendManager: backendManager, - endpointBinding: endpointBinding, - modelBinding: modelBinding - ) - .tabItem { - Label("Endpoints", systemImage: "network") - } - .tag(SettingsTab.endpoints) - - DictationSettingsPane( - settings: settings, - viewModel: viewModel, - dictationShortcutBinding: dictationShortcutBinding, - overlayBufferShortcutBinding: overlayBufferShortcutBinding, - livePasteShortcutBinding: livePasteShortcutBinding, - shortcutValidationError: $shortcutValidationError - ) - .tabItem { - Label("Dictation", systemImage: "mic") - } - .tag(SettingsTab.dictation) + HStack(spacing: 0) { + SettingsSidebarView(selection: $navigator.selectedTab) - TextProcessingSettingsPane( - settings: settings, - viewModel: viewModel - ) - .tabItem { - Label("Text Processing", systemImage: "text.badge.checkmark") - } - .tag(SettingsTab.textProcessing) + // The sidebar's trailing hairline. One divider, drawn by the layout + // rather than by both columns, so it cannot double up. + Divider() - AboutSettingsPane(settings: settings, viewModel: viewModel) - .tabItem { - Label("About", systemImage: "info.circle") - } - .tag(SettingsTab.about) + detailColumn } .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + SettingsWindowChrome() + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + } + + /// Header + the selected pane. No transition/animation on the swap: pane + /// content is dense, and cross-fading it reads as a flicker. + private var detailColumn: some View { + VStack(spacing: 0) { + SettingsPaneHeader(tab: navigator.selectedTab) + + Divider() + + switch navigator.selectedTab { + case .general: + GeneralSettingsPane(settings: settings, viewModel: viewModel) + case .endpoints: + ConnectionSettingsPane( + settings: settings, + viewModel: viewModel, + backendManager: backendManager, + endpointBinding: endpointBinding, + modelBinding: modelBinding + ) + case .dictation: + DictationSettingsPane( + settings: settings, + viewModel: viewModel, + dictationShortcutBinding: dictationShortcutBinding, + overlayBufferShortcutBinding: overlayBufferShortcutBinding, + livePasteShortcutBinding: livePasteShortcutBinding, + shortcutValidationError: $shortcutValidationError + ) + case .textProcessing: + TextProcessingSettingsPane( + settings: settings, + viewModel: viewModel + ) + case .about: + AboutSettingsPane(settings: settings, viewModel: viewModel) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } @@ -135,7 +142,7 @@ private struct GeneralSettingsPane: View { let viewModel: DictationViewModel var body: some View { - SettingsPage { + SettingsPage(tab: .general) { SettingsGroup(title: "Permissions") { PermissionRowsView(viewModel: viewModel) } @@ -234,7 +241,7 @@ private struct ConnectionSettingsPane: View { } var body: some View { - SettingsPage { + SettingsPage(tab: .endpoints) { SettingsGroup(title: "Dictation") { SettingsFieldRow(title: "Mode") { VStack(alignment: .leading, spacing: 6) { @@ -502,7 +509,7 @@ private struct DictationSettingsPane: View { @State private var livePasteValidationError: String? var body: some View { - SettingsPage { + SettingsPage(tab: .dictation) { SettingsGroup(title: "Start dictation with") { SettingsFieldRow(title: "Trigger") { Picker("", selection: Binding( @@ -708,7 +715,7 @@ private struct TextProcessingSettingsPane: View { } var body: some View { - SettingsPage { + SettingsPage(tab: .textProcessing) { SettingsGroup(title: "Replacements") { SettingsFieldRow(title: "Exact match") { Toggle("", isOn: $settings.replacementDictionaryEnabled) @@ -1496,7 +1503,7 @@ private struct AboutSettingsPane: View { } var body: some View { - SettingsPage { + SettingsPage(tab: .about) { SettingsGroup(title: "Application") { SettingsFieldRow(title: "Name") { Text(appName) @@ -1555,6 +1562,10 @@ private struct AboutSettingsPane: View { } private struct SettingsPage: View { + /// Identifies the pane's content subtree to the AX drills + /// (`settings.pane.`), which scope their content assertions to it + /// so a sidebar row's label can never satisfy a pane assertion. + let tab: SettingsTab @ViewBuilder var content: Content var body: some View { @@ -1567,6 +1578,8 @@ private struct SettingsPage: View { } .background(Color(nsColor: .windowBackgroundColor)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .contain) + .accessibilityIdentifier(tab.paneAccessibilityIdentifier) } } diff --git a/Sources/localvoxtral/localvoxtralApp.swift b/Sources/localvoxtral/localvoxtralApp.swift index bcd4db44..9e35e4ec 100644 --- a/Sources/localvoxtral/localvoxtralApp.swift +++ b/Sources/localvoxtral/localvoxtralApp.swift @@ -107,9 +107,18 @@ struct localvoxtralApp: App { backendManager: appDelegate.backendManager, navigator: appDelegate.settingsNavigator ) - .frame(minWidth: 560, idealWidth: 580, minHeight: 380, idealHeight: 420) + // Fixed width, resizable height: the two-column layout has a fixed + // 208pt sidebar and dense right-hand rows, so horizontal resizing + // only ever makes the panes worse. Height stays free because pane + // content differs by hundreds of points. + // + // Deliberately NOT `.windowResizability(.contentSize)` — that makes + // the window snap to whichever pane is showing, so switching tabs + // resizes the window under the pointer. + .frame(width: 780) + .frame(minHeight: 480, idealHeight: 560, maxHeight: .infinity) } - .defaultSize(width: 580, height: 420) + .defaultSize(width: 780, height: 560) .restorationBehavior(.disabled) } } diff --git a/Tests/localvoxtralTests/SettingsTabTests.swift b/Tests/localvoxtralTests/SettingsTabTests.swift new file mode 100644 index 00000000..c1a969d5 --- /dev/null +++ b/Tests/localvoxtralTests/SettingsTabTests.swift @@ -0,0 +1,86 @@ +import XCTest + +@testable import localvoxtral + +/// The sidebar is data-driven: a tab that is in `allCases` but in neither +/// sidebar array is unreachable in the UI, and a tab in both would render twice. +/// The AX identifiers are a contract with `scripts/ui-smoke.sh` and +/// `scripts/capture-readme-assets.sh`, which press and scope by literal string. +final class SettingsTabTests: XCTestCase { + private var sidebarItems: [SettingsTab] { + SettingsTab.primarySidebarItems + SettingsTab.metaSidebarItems + } + + func testSidebarArraysCoverEveryTabExactlyOnce() { + XCTAssertEqual( + Set(sidebarItems), Set(SettingsTab.allCases), + "every SettingsTab must appear in the sidebar" + ) + XCTAssertEqual( + sidebarItems.count, SettingsTab.allCases.count, + "sidebar arrays must not list a tab twice" + ) + } + + func testSidebarArraysDoNotOverlap() { + let primary = Set(SettingsTab.primarySidebarItems) + let meta = Set(SettingsTab.metaSidebarItems) + XCTAssertTrue( + primary.isDisjoint(with: meta), + "a tab pinned to the bottom must not also be in the primary group" + ) + XCTAssertEqual( + SettingsTab.primarySidebarItems.count, primary.count, + "primary sidebar items must be unique" + ) + XCTAssertEqual( + SettingsTab.metaSidebarItems.count, meta.count, + "meta sidebar items must be unique" + ) + } + + func testEveryTabHasCompleteChrome() { + for tab in SettingsTab.allCases { + XCTAssertFalse(tab.title.isEmpty, "\(tab.rawValue) has no title") + XCTAssertFalse(tab.subtitle.isEmpty, "\(tab.rawValue) has no subtitle") + XCTAssertFalse(tab.systemImage.isEmpty, "\(tab.rawValue) has no SF Symbol") + XCTAssertFalse( + tab.accessibilityIdentifier.isEmpty, + "\(tab.rawValue) has no accessibility identifier" + ) + XCTAssertFalse( + tab.paneAccessibilityIdentifier.isEmpty, + "\(tab.rawValue) has no pane accessibility identifier" + ) + } + } + + func testSubtitlesAreOneLineSentences() { + for tab in SettingsTab.allCases { + XCTAssertFalse( + tab.subtitle.contains("\n"), + "\(tab.rawValue) subtitle must be a single line" + ) + XCTAssertTrue( + tab.subtitle.hasSuffix("."), + "\(tab.rawValue) subtitle must read as a sentence" + ) + } + } + + func testAccessibilityIdentifiersUseTheDrillScheme() { + for tab in SettingsTab.allCases { + XCTAssertEqual(tab.accessibilityIdentifier, "settings.tab.\(tab.rawValue)") + XCTAssertEqual(tab.paneAccessibilityIdentifier, "settings.pane.\(tab.rawValue)") + } + } + + /// The scripts hardcode these strings; renaming a case silently breaks the + /// AX drills, which is exactly the failure this pins. + func testRawValuesAreStable() { + XCTAssertEqual( + Set(SettingsTab.allCases.map(\.rawValue)), + ["general", "endpoints", "dictation", "textProcessing", "about"] + ) + } +} diff --git a/scripts/capture-readme-assets.sh b/scripts/capture-readme-assets.sh index 1f723336..3c263535 100755 --- a/scripts/capture-readme-assets.sh +++ b/scripts/capture-readme-assets.sh @@ -35,7 +35,23 @@ PERSISTENT_DEFAULTS_BACKUP="${HOME}/.localvoxtral-capture-assets.pre.plist" PERSISTENT_DEFAULTS_BACKUP_HAD_DOMAIN="${PERSISTENT_DEFAULTS_BACKUP}.had-domain" ASSETS_DIR="assets" TAB_NAMES=("General" "Endpoints" "Dictation" "Text Processing") +# SettingsTab raw values — the sidebar rows carry them as AXIdentifiers +# (settings.tab.). SettingsTabTests pins both the raw values and the +# identifier scheme. +TAB_IDS=("general" "endpoints" "dictation" "textProcessing") TAB_FILES=("settings-general.png" "settings-endpoints.png" "settings-dictation.png" "settings-text-processing.png") +# The three arrays are indexed together below; a mismatch would silently capture +# one tab's window into another tab's file. +if (( ${#TAB_NAMES[@]} != ${#TAB_IDS[@]} || ${#TAB_NAMES[@]} != ${#TAB_FILES[@]} )); then + echo "Tab tables disagree: ${#TAB_NAMES[@]} names, ${#TAB_IDS[@]} ids, ${#TAB_FILES[@]} files. Fix them together." >&2 + exit 1 +fi +# Resolved from this script's location, not the cwd. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AX_PROBE="${SCRIPT_DIR}/lib/ax-probe.swift" +# Pins the probe's shape-matched fallbacks to the Settings window; the app has +# other windows the wrong AXButton/AXScrollArea could be found in. +SETTINGS_WINDOW_TITLE="Settings" [[ -d "$APP_PATH" ]] || { echo "App bundle not found: $APP_PATH (build with ./scripts/package_app.sh)" >&2; exit 1; } [[ -d "$ASSETS_DIR" ]] || { echo "Run from the repo root ($ASSETS_DIR/ not found)." >&2; exit 1; } @@ -271,15 +287,22 @@ OSA SETTINGS_ID="$(wait_for_window "$APP_PID" 0 10)" || { echo "Settings window never appeared." >&2; exit 1; } sleep 1 +# Tab selection presses the sidebar row by AXIdentifier. The old selector +# (`button "" of toolbar 1 of window 1`) died with the TabView — the +# hand-rolled sidebar has no toolbar. The display name is passed as an AXTitle +# fallback, and the probe prints which route it used. +[[ -f "$AX_PROBE" ]] || { echo "AX probe helper not found: $AX_PROBE" >&2; exit 1; } + for i in "${!TAB_NAMES[@]}"; do tab="${TAB_NAMES[$i]}" + tab_id="${TAB_IDS[$i]}" out="$ASSETS_DIR/${TAB_FILES[$i]}" echo "Capturing $out" - osascript >/dev/null <&2; exit 1; } sleep 1 SETTINGS_ID="$(window_id "$APP_PID" 0)" || { echo "Lost the settings window." >&2; exit 1; } screencapture -o -x -l "$SETTINGS_ID" "$out" diff --git a/scripts/lib/ax-probe.swift b/scripts/lib/ax-probe.swift new file mode 100644 index 00000000..4ca19d96 --- /dev/null +++ b/scripts/lib/ax-probe.swift @@ -0,0 +1,323 @@ +// Shared AX probe for the macOS GUI drills (scripts/ui-smoke.sh and +// scripts/capture-readme-assets.sh). Run it with `swift scripts/lib/ax-probe.swift`. +// +// It talks to the accessibility C API directly rather than through System +// Events: the AppleScript walk never compiled against this SwiftUI window +// (error -2741, issue #72) and `entire contents of window 1` returns 0 elements +// on the macOS 26 runner while the AX API sees the whole tree. +// +// Usage: +// ax-probe.swift --find [--scope ] +// [--window ] +// [--timeout ] [--dump-on-fail] +// ax-probe.swift --press [--title ] +// [--window ] +// [--timeout ] [--dump-on-fail] +// ax-probe.swift --dump [--window ] +// +// --find polls the app's windows until appears in a text-bearing +// element, optionally restricted to the subtree of the element whose +// AXIdentifier is . Scoping is not cosmetic: sidebar row labels +// are AXStaticText, so an unscoped needle could be satisfied by the +// navigation chrome without the pane ever rendering. When the +// identifier does not surface, the scope falls back to the window's +// first AXScrollArea (still outside the navigation chrome) and the +// success line says which route matched. +// --press finds the element whose AXIdentifier is and sends it +// AXPress. With --title, an AXButton whose title/description matches is +// accepted as a fallback when SwiftUI did not surface the identifier; +// the route actually taken is printed, so the log answers the question. +// --window restricts BOTH searches to windows whose AXTitle contains the given +// substring. Both fallbacks (AXTitle for --press, AXScrollArea for +// --scope) are matched by shape rather than identity, so a second window +// could satisfy them instead of the one under test — the enrollment +// sheet alone contains two scroll views. When the hint matches no +// window the probe searches all of them and says so, so a hint that +// stops matching degrades to the old behavior loudly instead of +// silently finding nothing. +// +// Exit codes: 0 success, 1 the probe failed, 2 usage error. + +import ApplicationServices +import Foundation + +let arguments = CommandLine.arguments + +func fail(_ message: String, code: Int32) -> Never { + FileHandle.standardError.write(Data((message + "\n").utf8)) + exit(code) +} + +func usage() -> Never { + fail( + """ + usage: + ax-probe.swift --find [--scope ] [--window ] [--timeout ] [--dump-on-fail] + ax-probe.swift --press [--title ] [--window ] [--timeout ] [--dump-on-fail] + ax-probe.swift --dump [--window ] + """, + code: 2 + ) +} + +guard arguments.count >= 3, let pid = Int32(arguments[1]) else { usage() } + +var needle: String? +var pressIdentifier: String? +var fallbackTitle: String? +var scopeIdentifier: String? +var windowTitleHint: String? +var timeoutSeconds: Double = 10 +var dumpOnFail = false +var dumpOnly = false + +var index = 2 +while index < arguments.count { + let flag = arguments[index] + switch flag { + case "--dump-on-fail": + dumpOnFail = true + case "--dump": + dumpOnly = true + default: + guard index + 1 < arguments.count else { usage() } + let value = arguments[index + 1] + index += 1 + switch flag { + case "--find": needle = value + case "--press": pressIdentifier = value + case "--title": fallbackTitle = value + case "--scope": scopeIdentifier = value + case "--window": windowTitleHint = value + case "--timeout": + guard let parsed = Double(value) else { usage() } + timeoutSeconds = parsed + default: usage() + } + } + index += 1 +} + +let application = AXUIElementCreateApplication(pid) + +func copyAttribute(_ element: AXUIElement, _ name: String) -> AnyObject? { + var value: AnyObject? + return AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success + ? value : nil +} + +func windows() -> [AXUIElement] { + (copyAttribute(application, kAXWindowsAttribute) as? [AXUIElement]) ?? [] +} + +func children(_ element: AXUIElement) -> [AXUIElement] { + (copyAttribute(element, kAXChildrenAttribute) as? [AXUIElement]) ?? [] +} + +struct ElementText { + let role: String + let title: String + let value: String + let desc: String + let identifier: String +} + +func texts(_ element: AXUIElement) -> ElementText { + let role = copyAttribute(element, kAXRoleAttribute) as? String ?? "?" + let title = copyAttribute(element, kAXTitleAttribute) as? String ?? "" + let desc = copyAttribute(element, kAXDescriptionAttribute) as? String ?? "" + // Spelled literally: the AXIdentifier constant is not exported by every + // SDK generation this drill has to build against. + let identifier = copyAttribute(element, "AXIdentifier") as? String ?? "" + var value = "" + if let raw = copyAttribute(element, kAXValueAttribute) { value = String(describing: raw) } + return ElementText(role: role, title: title, value: value, desc: desc, identifier: identifier) +} + +// Only text-bearing roles count as visible content: control titles (buttons, +// tab rows) exist regardless of which pane is rendered. +let textRoles: Set = ["AXStaticText", "AXTextField", "AXTextArea"] + +func containsNeedle( + _ element: AXUIElement, _ needle: String, depth: Int, budget: inout Int +) -> Bool { + if depth > 40 || budget <= 0 { return false } + budget -= 1 + let text = texts(element) + if textRoles.contains(text.role), + text.title.contains(needle) || text.value.contains(needle) + || text.desc.contains(needle) + { + return true + } + for child in children(element) { + if containsNeedle(child, needle, depth: depth + 1, budget: &budget) { return true } + } + return false +} + +func firstElement( + in element: AXUIElement, depth: Int, budget: inout Int, + where predicate: (ElementText) -> Bool +) -> AXUIElement? { + if depth > 40 || budget <= 0 { return nil } + budget -= 1 + if predicate(texts(element)) { return element } + for child in children(element) { + if let found = firstElement(in: child, depth: depth + 1, budget: &budget, where: predicate) + { + return found + } + } + return nil +} + +var warnedAboutUnmatchedWindowHint = false + +/// The windows a search may look at. +/// +/// Both fallbacks in this probe match by SHAPE (an AXButton with this title, the +/// first AXScrollArea) rather than by identity, so they must not be allowed to +/// wander into a second window: the enrollment sheet alone contains two scroll +/// views, and it is enumerated alongside the Settings window. A hint that matches +/// nothing degrades to searching everything, loudly and once — a silently empty +/// window set would turn every assertion into a 10s timeout with no explanation. +func searchWindows() -> [AXUIElement] { + let allWindows = windows() + guard let windowTitleHint else { return allWindows } + + let matching = allWindows.filter { texts($0).title.contains(windowTitleHint) } + if matching.isEmpty { + if !warnedAboutUnmatchedWindowHint { + warnedAboutUnmatchedWindowHint = true + print( + "AXPROBE: no window title contains \"\(windowTitleHint)\"; " + + "searching all \(allWindows.count) window(s) instead." + ) + } + return allWindows + } + return matching +} + +func findInWindows(where predicate: (ElementText) -> Bool) -> AXUIElement? { + for window in searchWindows() { + var budget = 20000 + if let found = firstElement(in: window, depth: 0, budget: &budget, where: predicate) { + return found + } + } + return nil +} + +func dump(_ element: AXUIElement, depth: Int, budget: inout Int) { + if depth > 40 || budget <= 0 { return } + budget -= 1 + let text = texts(element) + let indent = String(repeating: " ", count: depth) + print( + "\(indent)\(text.role) id=\(text.identifier.prefix(60)) " + + "title=\(text.title.prefix(60)) value=\(text.value.prefix(100)) " + + "desc=\(text.desc.prefix(60))" + ) + for child in children(element) { + dump(child, depth: depth + 1, budget: &budget) + } +} + +/// Dumps the windows a search would have looked at (see `searchWindows`), so the +/// log shows the tree the assertion actually ran against — not a neighbouring +/// window that was never eligible. +func dumpWindows(_ headline: String) { + let searched = searchWindows() + print( + "AXPROBE: \(headline); windows=\(windows().count) searched=\(searched.count)" + + (windowTitleHint.map { " (title contains \"\($0)\")" } ?? "") + ) + for (index, window) in searched.enumerated() { + print("AXPROBE: === window \(index) title=\(texts(window).title.prefix(60)) ===") + var budget = 8000 + dump(window, depth: 0, budget: &budget) + } +} + +if dumpOnly { + dumpWindows("tree dump requested") + exit(0) +} + +let deadline = Date().addingTimeInterval(timeoutSeconds) + +if let pressIdentifier { + repeat { + if let element = findInWindows(where: { $0.identifier == pressIdentifier }) { + if AXUIElementPerformAction(element, kAXPressAction as CFString) == .success { + print("AXPROBE: pressed \"\(pressIdentifier)\" via AXIdentifier.") + exit(0) + } + } + if let fallbackTitle, + let element = findInWindows(where: { + $0.role == "AXButton" && ($0.title == fallbackTitle || $0.desc == fallbackTitle) + }) + { + if AXUIElementPerformAction(element, kAXPressAction as CFString) == .success { + print( + "AXPROBE: pressed \"\(fallbackTitle)\" via AXTitle fallback " + + "(AXIdentifier \"\(pressIdentifier)\" did not surface)." + ) + exit(0) + } + } + usleep(250_000) + } while Date() < deadline + + if dumpOnFail { + dumpWindows( + "could not press \"\(pressIdentifier)\" " + + "(fallback title: \(fallbackTitle ?? "none")) after \(timeoutSeconds)s") + } + exit(1) +} + +guard let needle else { usage() } + +func subtreeContains(_ element: AXUIElement, _ needle: String) -> Bool { + var budget = 20000 + return containsNeedle(element, needle, depth: 0, budget: &budget) +} + +repeat { + if let scopeIdentifier { + if let scope = findInWindows(where: { $0.identifier == scopeIdentifier }) { + if subtreeContains(scope, needle) { + print("AXPROBE: \"\(needle)\" found inside AXIdentifier \"\(scopeIdentifier)\".") + exit(0) + } + } else if let scrollArea = findInWindows(where: { $0.role == "AXScrollArea" }) { + // The identifier did not surface. Fall back to the window's first + // AXScrollArea, which keeps the assertion honest: the pane is the + // only scrolling region in the Settings window, and the navigation + // chrome (sidebar rows, pane header) sits OUTSIDE it, so a sidebar + // label still cannot satisfy a pane assertion. + if subtreeContains(scrollArea, needle) { + print( + "AXPROBE: \"\(needle)\" found inside the window's first AXScrollArea " + + "(AXIdentifier \"\(scopeIdentifier)\" did not surface)." + ) + exit(0) + } + } + } else { + for window in searchWindows() where subtreeContains(window, needle) { + exit(0) + } + } + usleep(250_000) +} while Date() < deadline + +if dumpOnFail { + let scopeNote = scopeIdentifier.map { " within scope \"\($0)\"" } ?? "" + dumpWindows("needle \"\(needle)\"\(scopeNote) not visible after \(timeoutSeconds)s") +} +exit(1) diff --git a/scripts/ui-smoke.sh b/scripts/ui-smoke.sh index 402e2d70..ec22dc33 100755 --- a/scripts/ui-smoke.sh +++ b/scripts/ui-smoke.sh @@ -17,7 +17,10 @@ BUNDLE_ID="com.localvoxtral.app" PERSISTENT_DEFAULTS_BACKUP="${HOME}/.localvoxtral-ui-smoke.pre.plist" PERSISTENT_DEFAULTS_BACKUP_HAD_DOMAIN="${PERSISTENT_DEFAULTS_BACKUP}.had-domain" PREFLIGHT_HELPER="" -AX_PROBE_HELPER="" +# Resolved from this script's location, not the cwd: the drill is invoked both +# from the repo root and by CI steps with their own working directory. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AX_PROBE="${SCRIPT_DIR}/lib/ax-probe.swift" APP_PID="" FAILED=0 CLEANED_UP=0 @@ -172,7 +175,6 @@ cleanup() { printf 'WARNING: failed to restore defaults backup at %s; leaving it in place for the next run.\n' "$PERSISTENT_DEFAULTS_BACKUP" >&2 fi [[ -n "$PREFLIGHT_HELPER" ]] && rm -f "$PREFLIGHT_HELPER" - [[ -n "$AX_PROBE_HELPER" ]] && rm -f "$AX_PROBE_HELPER" [[ -n "$BACKEND_SAMPLE_FILE" ]] && rm -f "$BACKEND_SAMPLE_FILE" } @@ -418,150 +420,98 @@ fi # returns 0 elements on the macOS 26 runner) while the AX API sees the full # tree, so the AppleScript approach is dropped rather than fixed. # -# The helper polls the app's windows until appears in any element's -# title/value/description or the deadline passes; with --dump-on-fail it prints -# the AX tree so the uploaded log shows what was actually on screen. -write_ax_probe_helper() { - local stem - stem="$(mktemp -t localvoxtral-ax-probe)" || return 1 - AX_PROBE_HELPER="${stem}.swift" - mv "$stem" "$AX_PROBE_HELPER" || return 1 - cat >"$AX_PROBE_HELPER" <<'SWIFT' -import ApplicationServices -import Foundation - -let args = CommandLine.arguments -guard args.count >= 4, let pid = Int32(args[1]), let timeoutSeconds = Double(args[3]) else { - print("usage: ax-probe [--dump-on-fail]") - exit(2) -} -let needle = args[2] -let dumpOnFail = args.contains("--dump-on-fail") -let app = AXUIElementCreateApplication(pid) - -func copyAttr(_ element: AXUIElement, _ name: String) -> AnyObject? { - var value: AnyObject? - return AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success ? value : nil -} - -func windows() -> [AXUIElement] { - (copyAttr(app, kAXWindowsAttribute) as? [AXUIElement]) ?? [] -} - -func texts(_ element: AXUIElement) -> (role: String, title: String, value: String, desc: String) { - let role = copyAttr(element, kAXRoleAttribute) as? String ?? "?" - let title = copyAttr(element, kAXTitleAttribute) as? String ?? "" - let desc = copyAttr(element, kAXDescriptionAttribute) as? String ?? "" - var value = "" - if let raw = copyAttr(element, kAXValueAttribute) { value = String(describing: raw) } - return (role, title, value, desc) -} - -// Only text-bearing roles count as visible pane content. The toolbar tab -// AXButtons (titled "General", "Endpoints", "Dictation", ...) exist on every -// pane, so an unscoped match would let e.g. the Endpoints check pass off the -// "Dictation" tab button without the pane ever rendering. -let textRoles: Set = ["AXStaticText", "AXTextField", "AXTextArea"] - -func containsNeedle(_ element: AXUIElement, depth: Int, budget: inout Int) -> Bool { - if depth > 40 || budget <= 0 { return false } - budget -= 1 - let t = texts(element) - if textRoles.contains(t.role), - t.title.contains(needle) || t.value.contains(needle) || t.desc.contains(needle) { - return true - } - for child in (copyAttr(element, kAXChildrenAttribute) as? [AXUIElement]) ?? [] { - if containsNeedle(child, depth: depth + 1, budget: &budget) { return true } - } - return false -} - -func dump(_ element: AXUIElement, depth: Int, budget: inout Int) { - if depth > 40 || budget <= 0 { return } - budget -= 1 - let t = texts(element) - let indent = String(repeating: " ", count: depth) - print("\(indent)\(t.role) title=\(t.title.prefix(60)) value=\(t.value.prefix(100)) desc=\(t.desc.prefix(60))") - for child in (copyAttr(element, kAXChildrenAttribute) as? [AXUIElement]) ?? [] { - dump(child, depth: depth + 1, budget: &budget) - } -} - -let deadline = Date().addingTimeInterval(timeoutSeconds) -repeat { - for window in windows() { - var budget = 20000 - if containsNeedle(window, depth: 0, budget: &budget) { exit(0) } - } - usleep(250_000) -} while Date() < deadline - -if dumpOnFail { - let wins = windows() - print("AXPROBE: needle \"\(needle)\" not visible after \(timeoutSeconds)s; windows=\(wins.count)") - for (index, window) in wins.enumerated() { - print("AXPROBE: === window \(index) ===") - var budget = 8000 - dump(window, depth: 0, budget: &budget) - } -} -exit(1) -SWIFT -} - -window_shows_text() { - local expected="$1" timeout_seconds="${2:-10}" - shift 2 || true - swift "$AX_PROBE_HELPER" "$APP_PID" "$expected" "$timeout_seconds" "$@" -} +# The probe now lives in scripts/lib/ax-probe.swift, shared with +# capture-readme-assets.sh. See its header for the flags. +if [[ ! -f "$AX_PROBE" ]]; then + record_fail "AX probe helper not found: $AX_PROBE" + print_summary + exit 1 +fi +# Every probe call is pinned to the Settings window. Both of the probe's +# fallbacks match by shape (an AXButton with this title; the first AXScrollArea), +# and the app has other windows — the enrollment sheet alone contains two scroll +# views — so an unpinned fallback could answer from the wrong window. +SETTINGS_WINDOW_TITLE="Settings" + +# Asserts inside the subtree identified by , so a +# sidebar row label (AXStaticText, present on every pane) can never satisfy a +# pane assertion. +pane_shows_text() { + local scope="$1" expected="$2" timeout_seconds="${3:-10}" + swift "$AX_PROBE" "$APP_PID" \ + --find "$expected" --scope "$scope" \ + --window "$SETTINGS_WINDOW_TITLE" \ + --timeout "$timeout_seconds" --dump-on-fail +} + +# Presses the sidebar row by its AXIdentifier. The old selector +# (`button "" of toolbar 1 of window 1`) died with the TabView: the +# hand-rolled sidebar has no toolbar. The display name is passed as an AXTitle +# fallback so the drill still selects the tab (and says so in the log) if +# SwiftUI ever stops surfacing identifiers on plain-styled buttons. select_tab() { - local tab_name="$1" - run_osascript >/dev/null <&1)"; then + record_fail "Could not dump the Settings window AX tree." + return + fi + + if grep -q "id=settings\.pane\." <<<"$tree"; then + record_pass "Settings panes expose settings.pane.* AXIdentifiers; scoped assertions use them." + elif grep -q "AXScrollArea" <<<"$tree"; then + record_pass "Settings panes expose no AXIdentifier, but an AXScrollArea is present; scoped assertions use the scroll-area fallback." + else + record_fail "Settings window exposes neither a settings.pane.* AXIdentifier nor an AXScrollArea; scoped pane assertions cannot run." + printf '%s\n' "$tree" + fi } assert_tab() { - local tab_name="$1" - local expected_text="$2" + local tab_id="$1" + local tab_name="$2" + local expected_text="$3" - if ! select_tab "$tab_name"; then - record_fail "Could not select Settings tab: $tab_name." + if ! select_tab "$tab_id" "$tab_name"; then + record_fail "Could not select Settings tab: $tab_name (settings.tab.$tab_id)." return fi - if window_shows_text "$expected_text" 10 --dump-on-fail; then + if pane_shows_text "settings.pane.${tab_id}" "$expected_text" 10; then record_pass "Settings tab shows expected content: $tab_name -> $expected_text." else - record_fail "Settings tab selected but expected text was not visible: $tab_name -> $expected_text." + record_fail "Settings tab selected but expected text was not visible in settings.pane.$tab_id: $tab_name -> $expected_text." fi } -if ! write_ax_probe_helper; then - record_fail "Could not write the AX text-probe helper." - print_summary - exit 1 -fi +assert_pane_scope_is_reachable -assert_tab "General" "Permissions" -assert_tab "Endpoints" "Dictation" -assert_tab "Dictation" "Start dictation with" -assert_tab "Text Processing" "Replacements" +assert_tab "general" "General" "Permissions" +assert_tab "endpoints" "Endpoints" "Dictation" +assert_tab "dictation" "Dictation" "Start dictation with" +assert_tab "textProcessing" "Text Processing" "Replacements" # The polish feature toggles live on Text Processing (moved from Endpoints). -assert_tab "Text Processing" "Polishing" -assert_tab "About" "Diagnostics" +assert_tab "textProcessing" "Text Processing" "Polishing" +assert_tab "about" "About" "Diagnostics" # The launch phase forces external URL modes (managed mode now eagerly spawns # at launch), so the Endpoints pane renders endpoint configuration fields, not # the managed status rows. Managed-row AX coverage would need a second launch # that tolerates the eager spawn. -select_tab "Endpoints" >/dev/null 2>&1 || true -if window_shows_text "Endpoint" 10 --dump-on-fail \ - && window_shows_text "API key" 10 --dump-on-fail; then +select_tab "endpoints" "Endpoints" >/dev/null 2>&1 || true +if pane_shows_text "settings.pane.endpoints" "Endpoint" 10 \ + && pane_shows_text "settings.pane.endpoints" "API key" 10; then record_pass "External-mode Endpoints pane shows endpoint configuration fields." else record_fail "External-mode Endpoints pane did not show endpoint configuration fields."