diff --git a/Package.swift b/Package.swift index b64c3b4..e43e6b4 100644 --- a/Package.swift +++ b/Package.swift @@ -7,6 +7,7 @@ let package = Package( products: [ .executable(name: "bestasr", targets: ["bestasr"]), .executable(name: "bestasr-mcp", targets: ["bestasr-mcp"]), + .executable(name: "bestasr-gui", targets: ["bestasr-gui"]), .library(name: "BestASRKit", targets: ["BestASRKit"]), ], dependencies: [ @@ -50,11 +51,24 @@ let package = Package( dependencies: ["BestASRMCPCore"], swiftSettings: [.swiftLanguageMode(.v5)] ), + // #87 GUI dual-track: logic in a library target (mirrors BestASRMCPCore) + // so the session state machine is testable without SwiftUI. + .target( + name: "BestASRGUICore", + dependencies: ["BestASRKit"], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .executableTarget( + name: "bestasr-gui", + dependencies: ["BestASRGUICore"], + swiftSettings: [.swiftLanguageMode(.v5)] + ), .testTarget( name: "BestASRKitTests", dependencies: [ "BestASRKit", "BestASRMCPCore", + "BestASRGUICore", .product(name: "WhisperKit", package: "WhisperKit"), ], swiftSettings: [.swiftLanguageMode(.v5)] diff --git a/README.md b/README.md index a494baa..8449592 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,31 @@ claude mcp add bestasr -- ~/bin/bestasr-mcp # Claude Desktop: add ~/bin/bestasr-mcp under mcpServers in the app config ``` -One project, three consumption surfaces sharing one benchmark store: -**CLI** (terminal / scripts), **agent skills** (this plugin), and the -**MCP server** (bundled in the plugin, or standalone). +### The macOS app (GUI, dual-track bundle) + +For humans who want "drop a file → get an SRT" with no terminal: `bestASR.app` +is a SwiftUI GUI (file picker / drag-and-drop, language + effort + format, +elapsed-time progress, result preview + reveal-in-Finder) that calls the same +engine core as the CLI. The bundle is **dual-track** — `Contents/MacOS/` also +carries the MCP helper and the CLI (`bestasr-cli`; the `-cli` suffix exists +because default macOS filesystems are case-insensitive, where a `bestasr` +entry would collide with the `bestASR` GUI executable): + +```bash +scripts/release-app.sh --assemble-only # unsigned local bundle in dist/ +# maintainers: scripts/release-app.sh # sign + notarize + STAPLE + zip +# agents can use the bundled helper directly: +claude mcp add bestasr -- /Applications/bestASR.app/Contents/MacOS/bestasr-mcp +``` + +Unlike the bare `~/bin` binaries, the stapled bundle verifies offline with +Gatekeeper. Models still download on first use to the same cache. Progress is +stage + elapsed (the engines expose no percentage — no fake progress bars). + +One project, four consumption surfaces sharing one benchmark store: +**CLI** (terminal / scripts), **agent skills** (this plugin), the +**MCP server** (bundled in the plugin, or standalone), and the +**macOS app** (GUI for humans, with the MCP helper and CLI riding inside). ## Quick start diff --git a/Sources/BestASRGUICore/GUIOptions.swift b/Sources/BestASRGUICore/GUIOptions.swift new file mode 100644 index 0000000..4e1a260 --- /dev/null +++ b/Sources/BestASRGUICore/GUIOptions.swift @@ -0,0 +1,39 @@ +import Foundation + +import BestASRKit + +/// Picker vocabularies + the production runner wiring for the GUI (#87). +/// Derived from the same core types the CLI uses so the surfaces cannot drift. +public enum GUIOptions { + /// Benchmark-backed languages plus auto-detect (design D3; free-form codes later). + public static let languages = ["auto", "zh", "ja", "en"] + + /// "auto" resolves per machine state in CommandCore; ordinals come straight + /// from RouterProfile so a new profile appears here without GUI edits. + public static var efforts: [String] { ["auto"] + RouterProfile.allCases.map(\.rawValue) } + + /// The formats TranscriptWriter already renders. + public static var formats: [String] { OutputFormat.allNames } + + public static func requestedLanguage(fromSelection selection: String) -> String? { + selection == "auto" ? nil : selection + } +} + +public enum GUITranscribe { + /// Production seam: one warm CommandCore per app process so engine pipeline + /// caches survive across GUI runs (same reuse rationale as the MCP server). + public static func coreRunner(core: CommandCore = .live()) -> TranscribeRunner { + { request in + try await core.transcribe( + audioPath: request.audioPath, + selection: SelectionRequest( + profileName: request.profileName, + backendOverride: nil, + modelOverride: nil, + requestedLanguage: request.requestedLanguage), + formatName: request.formatName, + outputPath: request.outputPath) + } + } +} diff --git a/Sources/BestASRGUICore/TranscribeSession.swift b/Sources/BestASRGUICore/TranscribeSession.swift new file mode 100644 index 0000000..e75c293 --- /dev/null +++ b/Sources/BestASRGUICore/TranscribeSession.swift @@ -0,0 +1,142 @@ +import Foundation +import Observation + +import BestASRKit + +/// One GUI transcription request (spec gui-app, #87). Value type so the view +/// layer can assemble it from pickers without touching core types. +public struct TranscribeRequest: Equatable, Sendable { + public var audioPath: String + /// nil = auto-detect (AudioProber decides). + public var requestedLanguage: String? + /// "auto" or a RouterProfile rawValue — resolved by CommandCore. + public var profileName: String + public var formatName: String + /// nil lets the core derive the path next to the input. + public var outputPath: String? + + public init( + audioPath: String, requestedLanguage: String?, profileName: String, + formatName: String, outputPath: String? = nil + ) { + self.audioPath = audioPath + self.requestedLanguage = requestedLanguage + self.profileName = profileName + self.formatName = formatName + self.outputPath = outputPath + } +} + +/// The seam between the GUI and the engine world (design D4): the app injects +/// CommandCore, tests inject a fake. +public typealias TranscribeRunner = @Sendable (TranscribeRequest) async throws -> TranscribeOutcome + +/// GUI transcription state machine. Phase moves idle → running → done/failed; +/// `start` is single-flight and `cancel` returns to idle (the underlying engine +/// inference may keep running to completion — a documented v1 limitation, the +/// cancelled run's result is discarded via the generation guard). +@MainActor +@Observable +public final class TranscribeSession { + public enum Phase: Equatable { + case idle + case running(startedAt: Date) + case done(Completion) + case failed(String) + } + + /// What the result view renders (narrow, Equatable — swiftui-specialist). + public struct Completion: Equatable, Sendable { + public let outputPath: String + public let formatName: String + public let explanation: String + public let preview: String + + public init(outputPath: String, formatName: String, explanation: String, preview: String) { + self.outputPath = outputPath + self.formatName = formatName + self.explanation = explanation + self.preview = preview + } + } + + public private(set) var phase: Phase = .idle + + private let runner: TranscribeRunner + private let previewLimit: Int + private var task: Task? + /// Bumped on every start/cancel so a stale in-flight completion (from a + /// cancelled run) can never overwrite the current phase. + private var generation = 0 + + public init(runner: @escaping TranscribeRunner, previewLimit: Int = 20_000) { + self.runner = runner + self.previewLimit = previewLimit + } + + public var isRunning: Bool { + if case .running = phase { return true } + return false + } + + /// Single-flight: a start while running is a no-op (spec gui-app). + public func start(_ request: TranscribeRequest) { + guard !isRunning else { return } + generation += 1 + let gen = generation + phase = .running(startedAt: Date()) + let runner = self.runner + let limit = previewLimit + task = Task { [weak self] in + do { + let outcome = try await runner(request) + let preview = await Self.loadPreview(path: outcome.outputPath, limit: limit) + guard let self, self.generation == gen, self.isRunning else { return } + self.phase = .done( + Completion( + outputPath: outcome.outputPath, formatName: outcome.format, + explanation: outcome.explanation, preview: preview)) + } catch { + guard let self, self.generation == gen, self.isRunning else { return } + if error is CancellationError { + self.phase = .idle + } else { + self.phase = .failed(Self.message(for: error)) + } + } + } + } + + /// Returns the UI to idle. The awaiting task is cancelled; if the engine + /// does not observe cancellation its eventual result is dropped by the + /// generation guard rather than resurrecting a stale phase. + public func cancel() { + guard isRunning else { return } + generation += 1 + task?.cancel() + task = nil + phase = .idle + } + + /// Clears a terminal phase so the user can start over. + public func reset() { + guard !isRunning else { return } + phase = .idle + } + + static func message(for error: Error) -> String { + if let localized = (error as? LocalizedError)?.errorDescription { return localized } + return String(describing: error) + } + + /// Off-main-thread read of the written transcript for the preview pane. + nonisolated static func loadPreview(path: String, limit: Int) async -> String { + await Task.detached(priority: .utility) { + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + return "" + } + guard content.count > limit else { return content } + return String(content.prefix(limit)) + "\n…(preview truncated — full output on disk)" + }.value + } +} diff --git a/Sources/bestasr-gui/BestASRApp.swift b/Sources/bestasr-gui/BestASRApp.swift new file mode 100644 index 0000000..2edad03 --- /dev/null +++ b/Sources/bestasr-gui/BestASRApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +import BestASRGUICore + +@main +struct BestASRApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Sources/bestasr-gui/ContentView.swift b/Sources/bestasr-gui/ContentView.swift new file mode 100644 index 0000000..956c144 --- /dev/null +++ b/Sources/bestasr-gui/ContentView.swift @@ -0,0 +1,218 @@ +import SwiftUI +import UniformTypeIdentifiers + +import BestASRGUICore + +/// Thin composer (swiftui-specialist structure rule): every section is its own +/// View struct with narrow inputs; this parent only wires state together. +struct ContentView: View { + @State private var session = TranscribeSession(runner: GUITranscribe.coreRunner()) + @State private var audioURL: URL? + @State private var showingImporter = false + + // Persisted defaults (spec gui-app: selections survive relaunch). + @AppStorage("gui.language") private var language = "auto" + @AppStorage("gui.effort") private var effort = "auto" + @AppStorage("gui.format") private var format = "srt" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + DropZoneSection( + fileName: audioURL?.lastPathComponent, + isRunning: session.isRunning, + onChoose: { showingImporter = true } + ) + OptionsBar( + language: $language, effort: $effort, format: $format, + disabled: session.isRunning) + RunControls( + canStart: audioURL != nil && !session.isRunning, + isRunning: session.isRunning, + onStart: start, + onCancel: { session.cancel() }) + PhaseSection(phase: session.phase, onReset: { session.reset() }) + Spacer(minLength: 0) + } + .padding(20) + .frame(minWidth: 560, minHeight: 480) + .fileImporter( + isPresented: $showingImporter, allowedContentTypes: [.audio] + ) { result in + if case .success(let url) = result { audioURL = url } + } + .dropDestination(for: URL.self) { urls, _ in + guard !session.isRunning, let first = urls.first else { return false } + audioURL = first + return true + } + } + + private func start() { + guard let url = audioURL else { return } + session.start( + TranscribeRequest( + audioPath: url.path, + requestedLanguage: GUIOptions.requestedLanguage(fromSelection: language), + profileName: effort, + formatName: format)) + } +} + +/// Where the chosen file shows up; doubles as the drop target's visual. +struct DropZoneSection: View { + let fileName: String? + let isRunning: Bool + let onChoose: () -> Void + + var body: some View { + VStack(spacing: 8) { + Image(systemName: "waveform.badge.plus") + .font(.system(size: 34)) + .foregroundStyle(.secondary) + Text(fileName ?? "Drop an audio file here") + .font(.headline) + Button("Choose File…", action: onChoose) + .disabled(isRunning) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + .background( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [6])) + .foregroundStyle(.tertiary)) + } +} + +/// Language / effort / format pickers. Vocabularies come from GUIOptions so +/// they track the core types. +struct OptionsBar: View { + @Binding var language: String + @Binding var effort: String + @Binding var format: String + let disabled: Bool + + var body: some View { + HStack(spacing: 16) { + Picker("Language", selection: $language) { + ForEach(GUIOptions.languages, id: \.self) { Text($0) } + } + Picker("Effort", selection: $effort) { + ForEach(GUIOptions.efforts, id: \.self) { Text($0) } + } + Picker("Format", selection: $format) { + ForEach(GUIOptions.formats, id: \.self) { Text($0) } + } + } + .pickerStyle(.menu) + .disabled(disabled) + } +} + +struct RunControls: View { + let canStart: Bool + let isRunning: Bool + let onStart: () -> Void + let onCancel: () -> Void + + var body: some View { + HStack { + Button("Transcribe", action: onStart) + .keyboardShortcut(.defaultAction) + .disabled(!canStart) + if isRunning { + Button("Cancel", role: .cancel, action: onCancel) + } + } + } +} + +/// Renders whichever phase the session is in. Split per state so a running +/// clock tick only invalidates the progress view. +struct PhaseSection: View { + let phase: TranscribeSession.Phase + let onReset: () -> Void + + var body: some View { + switch phase { + case .idle: + EmptyView() + case .running(let startedAt): + ProgressSection(startedAt: startedAt) + case .done(let completion): + ResultSection(completion: completion, onReset: onReset) + case .failed(let message): + FailureSection(message: message, onReset: onReset) + } + } +} + +/// Stage + live elapsed clock (design D2 — honest indeterminate progress; the +/// engine exposes no percentage). TimelineView scopes the 1 Hz invalidation to +/// this view only. +struct ProgressSection: View { + let startedAt: Date + + var body: some View { + HStack(spacing: 12) { + ProgressView() + .controlSize(.small) + Text("Transcribing…") + TimelineView(.periodic(from: startedAt, by: 1)) { context in + Text(elapsedText(now: context.date)) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + } + + private func elapsedText(now: Date) -> String { + let seconds = max(0, Int(now.timeIntervalSince(startedAt))) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } +} + +struct ResultSection: View { + let completion: TranscribeSession.Completion + let onReset: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label(completion.outputPath, systemImage: "doc.text") + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button("Reveal in Finder") { + NSWorkspace.shared.activateFileViewerSelecting( + [URL(fileURLWithPath: completion.outputPath)]) + } + Button("New Run", action: onReset) + } + Text(completion.explanation) + .font(.caption) + .foregroundStyle(.secondary) + ScrollView { + Text(completion.preview) + .font(.body.monospaced()) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(minHeight: 160) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + } + } +} + +struct FailureSection: View { + let message: String + let onReset: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label(message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + .textSelection(.enabled) + Button("Start Over", action: onReset) + } + } +} diff --git a/Tests/BestASRKitTests/BundleAssemblyTests.swift b/Tests/BestASRKitTests/BundleAssemblyTests.swift new file mode 100644 index 0000000..18bcac2 --- /dev/null +++ b/Tests/BestASRKitTests/BundleAssemblyTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing + +@testable import BestASRKit + +/// spec gui-app (#87): the release script's assemble-only mode produces the +/// dual-track bundle contract — three executables, correct Info.plist, version +/// pinned to BestASRVersion.current — with stub binaries and no credentials. +struct BundleAssemblyTests { + static let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // BestASRKitTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + + @Test func `assemble-only builds the dual-track bundle contract`() throws { + let fm = FileManager.default + let scratch = fm.temporaryDirectory + .appendingPathComponent("bundle-assembly-\(UUID().uuidString)") + let binDir = scratch.appendingPathComponent("bin") + let outDir = scratch.appendingPathComponent("out") + try fm.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: scratch) } + + // Stub executables: the assemble stage only copies + sets exec bits. + for name in ["bestasr-gui", "bestasr-mcp", "bestasr"] { + let stub = binDir.appendingPathComponent(name) + try "#!/bin/sh\nexit 0\n".write(to: stub, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stub.path) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [ + Self.repoRoot.appendingPathComponent("scripts/release-app.sh").path, + "--assemble-only", + ] + process.currentDirectoryURL = Self.repoRoot + var env = ProcessInfo.processInfo.environment + env["BIN_DIR"] = binDir.path + env["OUT_DIR"] = outDir.path + process.environment = env + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = stdout + try process.run() + process.waitUntilExit() + let log = String( + data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + #expect(process.terminationStatus == 0, "script failed:\n\(log)") + + let app = outDir.appendingPathComponent("bestASR.app") + let macOS = app.appendingPathComponent("Contents/MacOS") + + // Exactly the three dual-track executables, each with the exec bit. + // The CLI ships as bestasr-cli: on case-insensitive APFS a "bestasr" + // entry IS "bestASR", so the naive name overwrites the GUI (spec gui-app). + let contents = try fm.contentsOfDirectory(atPath: macOS.path).sorted() + #expect(contents == ["bestASR", "bestasr-cli", "bestasr-mcp"]) + for name in contents { + #expect( + fm.isExecutableFile(atPath: macOS.appendingPathComponent(name).path), + "\(name) lost its executable bit") + } + + // Info.plist parses and carries the bundle contract. + let plistURL = app.appendingPathComponent("Contents/Info.plist") + let plist = try #require( + try PropertyListSerialization.propertyList( + from: Data(contentsOf: plistURL), format: nil) as? [String: Any]) + #expect(plist["CFBundleIdentifier"] as? String == "com.psychquant.bestASR") + #expect(plist["CFBundleExecutable"] as? String == "bestASR") + #expect(plist["LSMinimumSystemVersion"] as? String == "14.0") + // Version comes from the same constant the app reports — no drift. + #expect(plist["CFBundleShortVersionString"] as? String == BestASRVersion.current) + #expect(plist["CFBundleVersion"] as? String == BestASRVersion.current) + } +} diff --git a/Tests/BestASRKitTests/TranscribeSessionTests.swift b/Tests/BestASRKitTests/TranscribeSessionTests.swift new file mode 100644 index 0000000..4a96a33 --- /dev/null +++ b/Tests/BestASRKitTests/TranscribeSessionTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing + +@testable import BestASRGUICore +import BestASRKit + +/// spec gui-app (#87): the GUI session state machine, driven by injected fake +/// runners — no engines, no audio, no SwiftUI. Completion ordering is gated +/// (never real sleeps racing the scheduler — the #86 CI lesson). +@MainActor +struct TranscribeSessionTests { + private actor Gate { + private var opened = false + private var waiters: [CheckedContinuation] = [] + func open() { + opened = true + for waiter in waiters { waiter.resume() } + waiters.removeAll() + } + func wait() async { + if opened { return } + await withCheckedContinuation { waiters.append($0) } + } + } + + private static func request(path: String = "/tmp/in.wav") -> TranscribeRequest { + TranscribeRequest( + audioPath: path, requestedLanguage: nil, profileName: "auto", formatName: "srt") + } + + /// Polls the MainActor phase until it leaves .running (bounded, gate-driven + /// completions make the wait short and deterministic). + private func awaitTerminal(_ session: TranscribeSession) async { + for _ in 0..<2_000 { + if !session.isRunning { return } + await Task.yield() + } + } + + @Test func `Happy path lands in done with the outcome and preview`() async throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("gui-session-\(UUID().uuidString).srt") + try "1\n00:00:00,000 --> 00:00:01,000\nhello\n".write( + to: out, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: out) } + + let session = TranscribeSession(runner: { request in + TranscribeOutcome( + outputPath: out.path, format: request.formatName, explanation: "fake route") + }) + session.start(Self.request()) + #expect(session.isRunning) + await awaitTerminal(session) + + guard case .done(let completion) = session.phase else { + Issue.record("expected done, got \(session.phase)") + return + } + #expect(completion.outputPath == out.path) + #expect(completion.formatName == "srt") + #expect(completion.explanation == "fake route") + #expect(completion.preview.contains("hello")) + } + + @Test func `A thrown core error surfaces verbatim in the failed phase`() async { + let session = TranscribeSession(runner: { _ in + throw BestASRError.usage("audio file not found: /tmp/in.wav") + }) + session.start(Self.request()) + await awaitTerminal(session) + #expect(session.phase == .failed("audio file not found: /tmp/in.wav")) + } + + @Test func `Start is single-flight while running`() async { + let gate = Gate() + let session = TranscribeSession(runner: { request in + await gate.wait() + return TranscribeOutcome(outputPath: request.audioPath, format: "srt", explanation: "") + }) + session.start(Self.request(path: "/tmp/first.wav")) + guard case .running(let startedAt) = session.phase else { + Issue.record("expected running"); return + } + session.start(Self.request(path: "/tmp/second.wav")) // must be a no-op + #expect(session.phase == .running(startedAt: startedAt)) + await gate.open() + await awaitTerminal(session) + guard case .done(let completion) = session.phase else { + Issue.record("expected done, got \(session.phase)"); return + } + // The first request's run finished; the second never started. + #expect(completion.outputPath == "/tmp/first.wav") + } + + @Test func `Cancel returns to idle and drops the stale completion`() async { + let gate = Gate() + let session = TranscribeSession(runner: { request in + await gate.wait() // parked past the cancel + return TranscribeOutcome(outputPath: request.audioPath, format: "srt", explanation: "") + }) + session.start(Self.request()) + #expect(session.isRunning) + session.cancel() + #expect(session.phase == .idle) + // Un-park the orphaned run: its completion must NOT resurrect a phase. + await gate.open() + for _ in 0..<200 { await Task.yield() } + #expect(session.phase == .idle) + } + + @Test func `Reset clears a terminal phase but never an active run`() async { + let session = TranscribeSession(runner: { _ in + throw BestASRError.runtime("boom") + }) + session.start(Self.request()) + await awaitTerminal(session) + guard case .failed = session.phase else { + Issue.record("expected failed"); return + } + session.reset() + #expect(session.phase == .idle) + + let gate = Gate() + let running = TranscribeSession(runner: { request in + await gate.wait() + return TranscribeOutcome(outputPath: request.audioPath, format: "srt", explanation: "") + }) + running.start(Self.request()) + running.reset() // no-op while running + #expect(running.isRunning) + await gate.open() + } + + @Test func `Options vocabularies track the core types`() { + #expect(GUIOptions.efforts.first == "auto") + #expect(GUIOptions.efforts.contains("max")) + #expect(GUIOptions.efforts.count == 1 + RouterProfile.allCases.count) + #expect(GUIOptions.formats.contains("srt")) + #expect(GUIOptions.languages.first == "auto") + #expect(GUIOptions.requestedLanguage(fromSelection: "auto") == nil) + #expect(GUIOptions.requestedLanguage(fromSelection: "zh") == "zh") + } +} diff --git a/openspec/changes/gui-app-dual-track/.openspec.yaml b/openspec/changes/gui-app-dual-track/.openspec.yaml new file mode 100644 index 0000000..3dc98ce --- /dev/null +++ b/openspec/changes/gui-app-dual-track/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-07-10 +created_by: che cheng diff --git a/openspec/changes/gui-app-dual-track/design.md b/openspec/changes/gui-app-dual-track/design.md new file mode 100644 index 0000000..17d5732 --- /dev/null +++ b/openspec/changes/gui-app-dual-track/design.md @@ -0,0 +1,36 @@ +# Design: gui-app-dual-track + +## Decisions + +**D1 — SwiftPM-built SwiftUI executable, hand-assembled bundle (no `.xcodeproj`).** +The repo is pure SwiftPM (CI included); adding an Xcode project would create a second build system to keep in sync. A SwiftPM `executableTarget` builds a Mach-O that runs as a real app once placed in a scripted `bestASR.app` skeleton with an `Info.plist`. Trade-off accepted: no asset catalogs / Xcode preview infra; v1 ships without a custom icon (generic icon; `CFBundleIconFile` omitted — recorded in Residue). + +**D2 — Progress = stage + elapsed, never a fake percent.** +`CommandCore.transcribe` is a single async call with no progress callback; plumbing per-segment progress through every engine is out of scope. The GUI shows an indeterminate indicator, the active stage ("Transcribing…"), and a live elapsed clock. Honest limitation, documented in the UI copy. (The agent-side long-wait is already solved by #86's async jobs.) + +**D3 — v1 flow: one file at a time.** +Picker (`fileImporter`) + drag-and-drop; queueing/batch is future work. Export formats = the existing `OutputFormat` set the core already writes (srt/vtt/txt/json); GUI defaults to SRT. Language picker: auto/zh/ja/en (matches the benchmark languages; free-form codes can come later). Effort picker: `auto` + `RouterProfile` ordinals (low/medium/high/xhigh/max). Defaults persist via `@AppStorage`. + +**D4 — Reuse `CommandCore` verbatim; inject a runner closure into the view model.** +The GUI calls `core.transcribe(audioPath:selection:formatName:outputPath:)` (diarize stays at its `false` default — no v1 GUI control) and displays the written file's content plus the outcome explanation. `TranscribeSession` (`@MainActor @Observable`, per swiftui-specialist dataflow guidance) takes a `@Sendable` runner closure so tests drive the full state machine with a fake — no engines, no audio. + +**D5 — Bundle identity & contents.** +`Contents/MacOS/` carries `bestASR` (GUI, `CFBundleExecutable`), `bestasr-mcp`, and `bestasr-cli`. The CLI copy is renamed: APFS is case-insensitive by default, so `bestasr` and `bestASR` are the same directory entry — the naive #87 diagram layout silently overwrites the GUI (caught by the bundle smoke test). Bundle id `com.psychquant.bestASR`; `CFBundleShortVersionString` = `BestASRVersion.current` (read at assemble time from `swift run bestasr --version` output or the source constant); `LSMinimumSystemVersion` 14.0 (the package's platform floor); `NSHumanReadableCopyright` PsychQuant. Agents use the helper via `claude mcp add bestasr -- /Applications/bestASR.app/Contents/MacOS/bestasr-mcp`. + +**D6 — Signing pipeline mirrors `release-mcp.sh` (#85), extended for bundles.** +Stages: universal build (arm64 + x86_64) → assemble → codesign nested executables first, then the bundle (`--options runtime --timestamp`, Developer ID `$DEVELOPER_ID`) → `ditto`-zip → `notarytool submit --wait` (`$NOTARY_PROFILE`) → **`stapler staple` + `stapler validate`** (the bundle-only capability that motivated this surface) → final zip artifact. `--assemble-only` stops before signing so CI/tests can validate structure without credentials. Signing/notarization run only on the maintainer machine (same posture as release-mcp.sh). + +## Implementation Contract + +- `TranscribeSession` phases: `idle → running(startedAt) → done(TranscribeOutcome, preview) | failed(message)`; `start` is a no-op while running; `cancel` cancels the awaiting `Task` and returns to idle **and is documented as not necessarily aborting engine inference mid-flight** (engine limitation). +- The GUI never blocks the main thread: the runner executes in a `Task`, UI state mutations happen on `@MainActor`. +- View structure follows swiftui-specialist: one `View` struct per section (drop zone / options / progress / result), narrow inputs, `private @State`, `@Observable` model with `Equatable` stored types. +- `release-app.sh --assemble-only` produces a bundle whose structure a test can assert: three executables present and executable-bit set, `Info.plist` parses, `CFBundleIdentifier`/`CFBundleShortVersionString`/`LSMinimumSystemVersion` correct, GUI binary is `CFBundleExecutable`. +- Failure modes: missing input file → typed core error surfaces in the failed phase verbatim; output directory not writable → failed phase; unknown format impossible (picker constrained). +- Out of scope (v1): batch queue, true percent progress, custom icon, App Store distribution, localization (English UI; strings kept `LocalizedStringKey`-compatible for later). + +## Alternatives Considered + +- **Xcode project target** — richer app tooling (assets, previews), but a second build system + CI divergence; rejected for v1. +- **`.mcpb` Desktop extension** — already parked in #87's framing (blocking-chat UX for long jobs). +- **Percent progress via engine callbacks** — requires touching every `Engine` conformer + WhisperKit segment callbacks; deferred, revisit with a dedicated issue if users ask. diff --git a/openspec/changes/gui-app-dual-track/proposal.md b/openspec/changes/gui-app-dual-track/proposal.md new file mode 100644 index 0000000..ec67d31 --- /dev/null +++ b/openspec/changes/gui-app-dual-track/proposal.md @@ -0,0 +1,22 @@ +# Proposal: macOS GUI .app dual-track bundle + +## Why + +bestASR reaches agents well (CLI, Claude Code plugin, MCP server with async jobs #86) but has no human-facing surface: a person who wants "drag an audio file → get an SRT" must use a terminal or sit in a blocking chat. A Claude Desktop `.mcpb` was evaluated and parked for exactly that UX reason (#87). A signed macOS GUI app is the better human vehicle — and the same bundle can carry the MCP helper and CLI, making one notarized, **stapled** artifact serve humans and agents alike (bare `~/bin` binaries cannot be stapled; a real `.app` bundle can, enabling offline Gatekeeper verification). + +## What Changes + +- New SwiftPM executable target `bestasr-gui`: a SwiftUI macOS app (file picker + drag-and-drop, language/effort/format selection, stage+elapsed progress, result view with export and reveal-in-Finder, persisted defaults) that calls the same `CommandCore` as the CLI/MCP. +- New `scripts/release-app.sh`: universal build → hand-assembled `bestASR.app` bundle (GUI + `bestasr-mcp` + `bestasr-cli` in `Contents/MacOS/`; the CLI is suffixed to dodge the case-insensitive-APFS collision with `bestASR`) → Developer ID sign (nested-first, hardened runtime) → notarize (`che-mcps-notary`) → **staple** → zip artifact. An unsigned assemble mode keeps bundle structure testable without credentials. +- Bundle-structure smoke test + GUI view-model state-machine tests. +- README gains the .app install track. + +**Additive**: the CLI, MCP server, and Claude Code plugin (`~/bin` auto-download) are untouched. + +## Impact + +- Affected specs: new capability `gui-app` (ADDED requirements only). +- Affected code: `Package.swift` (+1 target), new `Sources/bestasr-gui/`, new `scripts/release-app.sh`, `Tests/` additions, README. +- Humans get a no-terminal install (drag to /Applications); agents may point `claude mcp add` at the bundled `bestasr-mcp` path; models still download at first use (~GBs to the existing cache) — the bundle stays small. + +Refs #87. diff --git a/openspec/changes/gui-app-dual-track/specs/gui-app/spec.md b/openspec/changes/gui-app-dual-track/specs/gui-app/spec.md new file mode 100644 index 0000000..8cd8734 --- /dev/null +++ b/openspec/changes/gui-app-dual-track/specs/gui-app/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: SwiftUI GUI transcribes a chosen audio file + +The system SHALL provide a SwiftUI macOS app (`bestasr-gui` target) through which a person selects one audio file (file picker or drag-and-drop), chooses language (auto/zh/ja/en), effort profile (auto plus the RouterProfile ordinals), and output format (the existing OutputFormat set), and runs a transcription through the same `CommandCore` the CLI uses. While running, the app SHALL show an indeterminate progress indicator with the active stage and a live elapsed clock (never a fabricated percentage). On completion the app SHALL show the transcript content, the output file path, and the routing explanation, and SHALL offer reveal-in-Finder. Failures SHALL surface the typed core error message in the UI, never silently. Selected defaults (language, effort, format) SHALL persist across launches. + +#### Scenario: Happy-path transcription + +- **WHEN** a user drops an audio file and starts transcription with default settings +- **THEN** the UI enters a running state (stage + elapsed visible), and on success shows the transcript preview, output path, and explanation + +#### Scenario: Failure is loud + +- **WHEN** the core throws (e.g. the input file disappeared before start) +- **THEN** the UI shows the typed error message in a failed state and allows starting over + +#### Scenario: Start is single-flight + +- **WHEN** a transcription is already running +- **THEN** a second start request is a no-op until the current run finishes or is cancelled + +### Requirement: Dual-track bundle carries GUI, MCP helper, and CLI + +The release artifact SHALL be a single `bestASR.app` bundle whose `Contents/MacOS/` contains exactly three executables: `bestASR` (the GUI, the bundle's `CFBundleExecutable`), `bestasr-mcp`, and `bestasr-cli` (the CLI — named with the `-cli` suffix because the default macOS filesystem is case-insensitive, so `bestasr` would collide with the GUI executable `bestASR` in the same directory). The bundle SHALL declare `CFBundleIdentifier` `com.psychquant.bestASR`, a `CFBundleShortVersionString` equal to `BestASRVersion.current`, and `LSMinimumSystemVersion` 14.0. The bundled `bestasr-mcp` SHALL be usable directly by MCP clients via its bundle path. + +#### Scenario: Bundle structure is assembled + +- **WHEN** the release script assembles the bundle (unsigned assemble mode included) +- **THEN** the three executables are present with the executable bit set, and the Info.plist parses with the required identifier, version, and minimum-system values + +#### Scenario: Version tracks the app version + +- **WHEN** the bundle is assembled +- **THEN** its `CFBundleShortVersionString` equals `BestASRVersion.current` (no drift) + +### Requirement: Release pipeline signs, notarizes, and staples the bundle + +The release script SHALL build universal (arm64 + x86_64) binaries, sign every nested executable and then the bundle with Developer ID and hardened runtime, submit for notarization, and **staple the ticket to the bundle**, validating the staple before producing the final zip artifact. The script SHALL provide an assemble-only mode that stops before signing so bundle structure remains testable without signing credentials. Signing and notarization failures SHALL abort loudly (no unsigned artifact published). + +#### Scenario: Stapled artifact verifies offline + +- **WHEN** the full release pipeline completes +- **THEN** `stapler validate` succeeds on the bundle before the artifact is zipped + +#### Scenario: Assemble-only mode needs no credentials + +- **WHEN** the script runs in assemble-only mode on a machine without the Developer ID identity +- **THEN** it produces an unsigned, structurally complete bundle and exits success + +### Requirement: The GUI surface is additive + +Shipping the GUI bundle SHALL NOT change the behavior of the existing CLI, the MCP stdio server, or the Claude Code plugin's `~/bin` auto-download distribution. + +#### Scenario: Existing surfaces unchanged + +- **WHEN** the GUI target and release script land +- **THEN** existing CLI/MCP/plugin tests pass unchanged (no behavioral diff in those surfaces) diff --git a/openspec/changes/gui-app-dual-track/tasks.md b/openspec/changes/gui-app-dual-track/tasks.md new file mode 100644 index 0000000..3e2a582 --- /dev/null +++ b/openspec/changes/gui-app-dual-track/tasks.md @@ -0,0 +1,24 @@ +# Tasks: gui-app-dual-track + +## 1. Target scaffolding + +- [x] 1.1 `Package.swift`: add `bestasr-gui` executableTarget (depends on BestASRKit); builds on macOS 14 floor + +## 2. GUI (swiftui-specialist loaded — @Observable/@MainActor, per-section View structs, narrow inputs) + +- [x] 2.1 `TranscribeSession` (@MainActor @Observable): phase machine idle/running/done/failed with Equatable payloads, injected @Sendable runner closure over `CommandCore.transcribe`, single-flight start, cancel (documented as not aborting engine inference mid-flight) +- [x] 2.2 App entry + `ContentView` composing per-section views: drop zone/picker (fileImporter + onDrop), options bar (language, effort auto+RouterProfile, format), progress section (stage + elapsed via TimelineView), result section (transcript preview, output path, explanation, reveal-in-Finder) +- [x] 2.3 Defaults persistence via @AppStorage (language/effort/format); failure state renders typed core error verbatim + +## 3. Bundle + signing pipeline + +- [x] 3.1 `scripts/release-app.sh`: universal build → assemble bestASR.app (Info.plist: com.psychquant.bestASR, version = BestASRVersion.current, LSMinimumSystemVersion 14.0; three executables in Contents/MacOS — CLI copied as bestasr-cli, case-insensitive-APFS collision with bestASR) → sign nested-first (hardened runtime, $DEVELOPER_ID) → notarize ($NOTARY_PROFILE) → staple + `stapler validate` → zip; `--assemble-only` unsigned mode +- [x] 3.2 Bundle smoke test: assemble-only run asserts structure (3 executables + exec bits, Info.plist keys, GUI is CFBundleExecutable, version matches BestASRVersion.current) + +## 4. Tests + +- [x] 4.1 `TranscribeSessionTests`: fake-runner state machine (success → done with preview; thrown core error → failed with message; single-flight while running; cancel returns to idle) + +## 5. Docs + +- [x] 5.1 README: the .app human-facing track (install, agent use of bundled bestasr-mcp path, model-download note); doc-sync per repo discipline diff --git a/scripts/install.sh b/scripts/install.sh index ea4c6af..0037495 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -37,7 +37,11 @@ if command -v swift >/dev/null && [[ "$(command -v swift)" == *".swiftly"* ]]; t fi echo "== building release binary (first build takes a few minutes) ==" -"${BUILD_ENV[@]}" swift build -c release +# Bare build compiles every product — since #87 that includes the bestasr-gui +# SwiftUI app (a ride-along; only bestasr/bestasr-mcp are installed below). +# ${arr[@]+...}: /bin/bash 3.2 aborts on expanding an EMPTY array under set -u +# (verify #87 HIGH), which would kill exactly the non-swiftly default path. +${BUILD_ENV[@]+"${BUILD_ENV[@]}"} swift build -c release PREFIX="${PREFIX:-$HOME/bin}" mkdir -p "$PREFIX" diff --git a/scripts/release-app.sh b/scripts/release-app.sh new file mode 100755 index 0000000..e483081 --- /dev/null +++ b/scripts/release-app.sh @@ -0,0 +1,163 @@ +#!/bin/bash +# Build, assemble, sign, notarize, STAPLE, and zip the dual-track bestASR.app +# bundle (#87, spec gui-app): GUI (bestASR) + MCP helper (bestasr-mcp) + CLI +# (bestasr) in one Developer ID bundle. Stapling is the bundle-only capability +# that motivated this surface — offline Gatekeeper verification. +# +# scripts/release-app.sh # full pipeline (maintainer machine) +# scripts/release-app.sh --assemble-only # unsigned bundle, no credentials +# +# Env overrides: +# DEVELOPER_ID / NOTARY_PROFILE signing handles (reference names, not secrets) +# BIN_DIR directory holding prebuilt bestASR/bestasr-mcp/bestasr — skips the +# build stage (used by the bundle smoke test with stub binaries) +# OUT_DIR where bestASR.app (and the final zip) land; default dist/ +set -euo pipefail + +ASSEMBLE_ONLY=0 +[ "${1:-}" = "--assemble-only" ] && ASSEMBLE_ONLY=1 + +DEVELOPER_ID="${DEVELOPER_ID:-F2523DCF6D02BE99B67C7D27F633119292DA4934}" +NOTARY_PROFILE="${NOTARY_PROFILE:-che-mcps-notary}" +BUNDLE_ID="com.psychquant.bestASR" +MIN_OS="14.0" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +# Single source of truth for the version: BestASRVersion.current (the bundle +# smoke test asserts the plist matches this constant — no drift). Scoped to the +# enum block so another type's `static let current` can never be picked up +# (verify #87 MEDIUM), and shape-asserted like release-mcp.sh. +VERSION="$(awk '/enum BestASRVersion/,/^}/' Sources/BestASRKit/Models/DataModels.swift \ + | sed -n 's/.*static let current = "\([0-9][0-9.]*\)".*/\1/p' | head -1)" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "x could not parse a semver BestASRVersion.current (got: '$VERSION')" >&2; exit 1; } + +OUT_DIR="${OUT_DIR:-dist}" +APP="$OUT_DIR/bestASR.app" +MACOS_DIR="$APP/Contents/MacOS" + +if [ -z "${BIN_DIR:-}" ]; then + echo "== [1/7] build release binaries (universal) ==" + command -v swift >/dev/null 2>&1 || { echo "x swift not found" >&2; exit 1; } + # Same swiftly-6.2.4 release-crash workaround as release-mcp.sh. + BUILD_ENV=() + if [[ "$(command -v swift)" == *".swiftly"* ]] && swift --version 2>&1 | grep -q "6.2.4"; then + echo "! swiftly swift-6.2.4 detected — building with the Xcode toolchain (/usr/bin/swift)." + BUILD_ENV=(env PATH="/usr/bin:$PATH") + fi + # One invocation per product: swift build accepts a single --product + # (repeats silently keep only the last one — caught by the real-build check). + # The ${arr[@]+...} guard matters: /bin/bash is 3.2, where expanding an EMPTY + # array under `set -u` aborts with "unbound variable" — which would kill the + # build exactly on the recommended Xcode-toolchain path (verify #87 HIGH). + for product in bestasr-gui bestasr-mcp bestasr; do + ${BUILD_ENV[@]+"${BUILD_ENV[@]}"} swift build -c release --arch arm64 --arch x86_64 --product "$product" + done + BIN_DIR=".build/apple/Products/Release" +else + echo "== [1/7] using prebuilt binaries from BIN_DIR=$BIN_DIR ==" +fi + +for bin in bestasr-gui bestasr-mcp bestasr; do + [ -x "$BIN_DIR/$bin" ] || { echo "x missing executable: $BIN_DIR/$bin" >&2; exit 1; } +done + +echo "== [2/7] assemble $APP (v$VERSION) ==" +rm -rf "$APP" +mkdir -p "$MACOS_DIR" +# The GUI executable takes the bundle's display name (CFBundleExecutable). +# The CLI copy is bestasr-cli: default APFS is case-insensitive, so a file +# named "bestasr" IS "bestASR" — copying it would overwrite the GUI (spec +# gui-app; caught by BundleAssemblyTests). +cp "$BIN_DIR/bestasr-gui" "$MACOS_DIR/bestASR" +cp "$BIN_DIR/bestasr-mcp" "$MACOS_DIR/bestasr-mcp" +cp "$BIN_DIR/bestasr" "$MACOS_DIR/bestasr-cli" +chmod +x "$MACOS_DIR"/* + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + bestASR + CFBundleIdentifier + $BUNDLE_ID + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + bestASR + CFBundlePackageType + APPL + CFBundleShortVersionString + $VERSION + CFBundleVersion + $VERSION + LSMinimumSystemVersion + $MIN_OS + LSApplicationCategoryType + public.app-category.productivity + NSHumanReadableCopyright + © PsychQuant + + +PLIST +plutil -lint "$APP/Contents/Info.plist" >/dev/null +echo " ✓ bundle assembled" + +if [ "$ASSEMBLE_ONLY" = 1 ]; then + echo "✓ assemble-only: unsigned bundle at $APP (no signing credentials touched)" + exit 0 +fi + +echo "== [3/7] codesign (nested executables first, then the bundle) ==" +SIGN_ARGS=(--force --options runtime --timestamp --sign "$DEVELOPER_ID") +[ -n "${ENTITLEMENTS:-}" ] && SIGN_ARGS+=(--entitlements "$ENTITLEMENTS") +# Helpers are standalone Mach-Os inside the bundle; sign them before the app +# seal so the bundle signature covers their final bits. +codesign "${SIGN_ARGS[@]}" "$MACOS_DIR/bestasr-mcp" +codesign "${SIGN_ARGS[@]}" "$MACOS_DIR/bestasr-cli" +codesign "${SIGN_ARGS[@]}" "$APP" +codesign --verify --strict --deep --verbose=2 "$APP" +echo " ✓ signed" + +echo "== [4/7] smoke-test the SIGNED helper under hardened runtime ==" +# The GUI can't be exercised headless, but the bundled MCP helper can: a real +# stdio round-trip proves hardened runtime didn't break the embedded binaries. +SMOKE_IN="$(mktemp)"; trap 'rm -f "$SMOKE_IN"' EXIT +{ + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"release-smoke","version":"0"}}}' + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}' + printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +} > "$SMOKE_IN" +SMOKE_OUT="$({ cat "$SMOKE_IN"; sleep 3; } | "$MACOS_DIR/bestasr-mcp" 2>/dev/null || true)" +echo "$SMOKE_OUT" | grep -q '"transcribe"' || { + echo "x smoke test FAILED — signed bundled bestasr-mcp did not answer tools/list" >&2 + exit 1 +} +echo " ✓ bundled helper answers tools/list" + +echo "== [5/7] notarize (submit + wait) ==" +NOTARIZE_ZIP="$(mktemp -d)/bestASR.app.zip" +ditto -c -k --keepParent "$APP" "$NOTARIZE_ZIP" +xcrun notarytool submit "$NOTARIZE_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait + +echo "== [6/7] staple + validate (the bundle-only offline-verification win) ==" +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" +echo " ✓ stapled" + +echo "== [7/7] final artifact ==" +FINAL_ZIP="$OUT_DIR/bestASR-$VERSION.zip" +ditto -c -k --keepParent "$APP" "$FINAL_ZIP" +( cd "$OUT_DIR" && shasum -a 256 "bestASR-$VERSION.zip" | tee "bestASR-$VERSION.zip.sha256" ) + +echo "" +echo "✓ stapled bundle: $APP" +echo "✓ artifact: $FINAL_ZIP" +echo " publish: gh release upload v$VERSION $FINAL_ZIP $FINAL_ZIP.sha256 --repo PsychQuant/bestASR" +echo " agents: claude mcp add bestasr -- /Applications/bestASR.app/Contents/MacOS/bestasr-mcp" diff --git a/scripts/release-mcp.sh b/scripts/release-mcp.sh index f2fb295..949f724 100755 --- a/scripts/release-mcp.sh +++ b/scripts/release-mcp.sh @@ -38,7 +38,9 @@ if [[ "$(command -v swift)" == *".swiftly"* ]] && swift --version 2>&1 | grep -q fi echo "== [1/6] build release bestasr-mcp ==" -"${BUILD_ENV[@]}" swift build -c release --product bestasr-mcp +# ${arr[@]+...}: /bin/bash 3.2 aborts on expanding an EMPTY array under set -u +# (verify #87 HIGH — the crash hits exactly the recommended Xcode-toolchain path). +${BUILD_ENV[@]+"${BUILD_ENV[@]}"} swift build -c release --product bestasr-mcp # SwiftPM maintains .build/release as a symlink to the active release bin dir. BIN=".build/release/bestasr-mcp" [ -x "$BIN" ] || { echo "x built binary not found at $BIN" >&2; exit 1; }