diff --git a/build_log.txt b/build_log.txt
new file mode 100644
index 0000000..095dbe0
--- /dev/null
+++ b/build_log.txt
@@ -0,0 +1,21 @@
+2026-01-08 00:49:15.446 xcodebuild[53214:2330497] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.
+--- xcodebuild: WARNING: Using the first of multiple matching destinations:
+{ platform:macOS, arch:arm64, id:00008122-000104641ADA001C, name:My Mac }
+{ platform:macOS, arch:x86_64, id:00008122-000104641ADA001C, name:My Mac }
+/Users/orm/speaktype/speaktype/Views/HistoryView.swift:9:13: error: referencing initializer 'init(_:selection:rowContent:)' on 'List' requires that 'HistoryItem' conform to 'Hashable'
+ List(historyService.items, selection: $selectedItem) { item in
+ ^
+SwiftUI.List:2:11: note: where 'SelectionValue' = 'HistoryItem'
+extension List {
+ ^
+/Users/orm/speaktype/speaktype/Views/HistoryView.swift:10:17: error: initializer 'init(value:label:)' requires that 'HistoryItem' conform to 'Hashable'
+ NavigationLink(value: item) {
+ ^
+SwiftUI.NavigationLink.init:2:20: note: where 'P' = 'HistoryItem'
+nonisolated public init
(value: P?, @ViewBuilder label: () -> Label) where P : Decodable, P : Encodable, P : Hashable}
+ ^
+Failed frontend command:
+/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift-frontend -frontend -c /Users/orm/speaktype/speaktype/App/speaktypeApp.swift /Users/orm/speaktype/speaktype/Models/AppSettings.swift /Users/orm/speaktype/speaktype/Models/AudioDevice.swift /Users/orm/speaktype/speaktype/Models/HotkeyConfiguration.swift /Users/orm/speaktype/speaktype/Models/RecordingSession.swift /Users/orm/speaktype/speaktype/Models/TranscriptionResult.swift /Users/orm/speaktype/speaktype/Models/TranscriptionState.swift /Users/orm/speaktype/speaktype/Services/AudioRecordingService.swift /Users/orm/speaktype/speaktype/Services/ClipboardService.swift /Users/orm/speaktype/speaktype/Services/HistoryService.swift /Users/orm/speaktype/speaktype/Services/HotkeyService.swift /Users/orm/speaktype/speaktype/Services/WhisperService.swift /Users/orm/speaktype/speaktype/Utilities/Extensions/Color+Extensions.swift /Users/orm/speaktype/speaktype/Utilities/Extensions/View+Extensions.swift /Users/orm/speaktype/speaktype/Utilities/AppColors.swift /Users/orm/speaktype/speaktype/Utilities/Constants.swift /Users/orm/speaktype/speaktype/Views/AIModelsView.swift /Users/orm/speaktype/speaktype/Views/AudioInputView.swift /Users/orm/speaktype/speaktype/Views/ContentView.swift /Users/orm/speaktype/speaktype/Views/DashboardView.swift -primary-file /Users/orm/speaktype/speaktype/Views/HistoryView.swift /Users/orm/speaktype/speaktype/Views/MainView.swift /Users/orm/speaktype/speaktype/Views/MiniRecorderView.swift /Users/orm/speaktype/speaktype/Views/OnboardingView.swift /Users/orm/speaktype/speaktype/Views/PermissionsView.swift /Users/orm/speaktype/speaktype/Views/SettingsView.swift /Users/orm/speaktype/speaktype/Views/ShortcutRecorderView.swift /Users/orm/speaktype/speaktype/Views/SidebarView.swift /Users/orm/speaktype/speaktype/Views/TranscribeAudioView.swift /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources/GeneratedAssetSymbols.swift -emit-dependencies-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.d -emit-const-values-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.swiftconstvalues -emit-reference-dependencies-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.swiftdeps -serialize-diagnostics-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.dia -emit-localized-strings -emit-localized-strings-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64 -target arm64-apple-macos26.1 -module-can-import-version AppKit 2685.20.119 2685.20.119 -module-can-import-version DeveloperToolsSupport 23.0.4 23.0.4 -module-can-import-version SwiftUI 7.1.13.1 7.1.13 -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libFoundationMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#FoundationMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libObservationMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#ObservationMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libPreviewsMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#PreviewsMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libSwiftMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#SwiftMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libSwiftUIMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#SwiftUIMacros -disable-implicit-swift-modules -Xcc -fno-implicit-modules -Xcc -fno-implicit-module-maps -explicit-swift-module-map-file /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/speaktype-dependencies-1.json -Xllvm -aarch64-use-tbi -enable-objc-interop -stack-check -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.1.sdk -I /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug -no-color-diagnostics -Xcc -fno-color-diagnostics -enable-testing -g -debug-info-format\=dwarf -dwarf-version\=5 -module-cache-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -profile-generate -profile-coverage-mapping -swift-version 5 -enforce-exclusivity\=checked -Onone -D DEBUG -serialize-debugging-options -const-gather-protocols-file /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/speaktype_const_extract_protocols.json -enable-upcoming-feature DisableOutwardActorInference -enable-upcoming-feature InferSendableFromCaptures -enable-upcoming-feature GlobalActorIsolatedTypesUsability -enable-upcoming-feature MemberImportVisibility -enable-upcoming-feature InferIsolatedConformances -enable-upcoming-feature NonisolatedNonsendingByDefault -enable-experimental-feature DebugDescriptionMacro -enable-bare-slash-regex -default-isolation\=MainActor -empty-abi-descriptor -validate-clang-modules-once -clang-build-session-file /Users/orm/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -working-directory -Xcc /Users/orm/speaktype -enable-anonymous-context-mangled-names -file-compilation-dir /Users/orm/speaktype -Xcc -D_LIBCPP_HARDENING_MODE\=_LIBCPP_HARDENING_MODE_DEBUG -Xcc -ivfsstatcache -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/macosx26.1-25B74-3ae1261907bdadc328169391a47051a6.sdkstatcache -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/swift-overrides.hmap -Xcc -iquote -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-generated-files.hmap -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-own-target-headers.hmap -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-all-non-framework-target-headers.hmap -Xcc -ivfsoverlay -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype-a09c45879bccb08166620f197fcee798-VFS/all-product-headers.yaml -Xcc -iquote -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-project-headers.hmap -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/include -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources-normal/arm64 -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources/arm64 -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources -Xcc -DDEBUG\=1 -no-auto-bridging-header-chaining -module-name speaktype -frontend-parseable-output -disable-clang-spi -target-sdk-version 26.1 -target-sdk-name macosx26.1 -clang-target arm64-apple-macos26.1 -in-process-plugin-server-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/libSwiftInProcPluginServer.dylib -o /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.o -index-unit-output-path /speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.o -index-store-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Index.noindex/DataStore -index-system-modules
+/Users/orm/speaktype/speaktype.xcodeproj: warning: The Copy Bundle Resources build phase contains this target's Info.plist file '/Users/orm/speaktype/speaktype/Resources/Info.plist'. (in target 'speaktype' from project 'speaktype')
+** BUILD FAILED **
+
diff --git a/speaktype.xcodeproj/project.pbxproj b/speaktype.xcodeproj/project.pbxproj
index 42f9d86..5483be6 100644
--- a/speaktype.xcodeproj/project.pbxproj
+++ b/speaktype.xcodeproj/project.pbxproj
@@ -8,6 +8,8 @@
/* Begin PBXBuildFile section */
1C547F2B2F0E7DC0008120A6 /* WhisperKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1C547F2A2F0E7DC0008120A6 /* WhisperKit */; };
+ 1CE4CB3F2F0EF6AF00C01C62 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = 1CE4CB3E2F0EF6AF00C01C62 /* KeyboardShortcuts */; };
+ 1CE4CB412F0EF86B00C01C62 /* whisperkit-cli in Frameworks */ = {isa = PBXBuildFile; productRef = 1CE4CB402F0EF86B00C01C62 /* whisperkit-cli */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -57,6 +59,8 @@
buildActionMask = 2147483647;
files = (
1C547F2B2F0E7DC0008120A6 /* WhisperKit in Frameworks */,
+ 1CE4CB3F2F0EF6AF00C01C62 /* KeyboardShortcuts in Frameworks */,
+ 1CE4CB412F0EF86B00C01C62 /* whisperkit-cli in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -126,6 +130,8 @@
name = speaktype;
packageProductDependencies = (
1C547F2A2F0E7DC0008120A6 /* WhisperKit */,
+ 1CE4CB3E2F0EF6AF00C01C62 /* KeyboardShortcuts */,
+ 1CE4CB402F0EF86B00C01C62 /* whisperkit-cli */,
);
productName = speaktype;
productReference = 362947222F0DBC4E00BE895D /* speaktype.app */;
@@ -211,6 +217,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
1C547F222F0E7D07008120A6 /* XCRemoteSwiftPackageReference "WhisperKit" */,
+ 1CE4CB3D2F0EF6AF00C01C62 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 362947232F0DBC4E00BE895D /* Products */;
@@ -409,11 +416,11 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
- CODE_SIGN_STYLE = Automatic;
CODE_SIGN_ENTITLEMENTS = speaktype/Resources/speaktype.entitlements;
+ CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
- ENABLE_APP_SANDBOX = YES;
+ ENABLE_APP_SANDBOX = NO;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = NO;
@@ -441,11 +448,11 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
- CODE_SIGN_STYLE = Automatic;
CODE_SIGN_ENTITLEMENTS = speaktype/Resources/speaktype.entitlements;
+ CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
- ENABLE_APP_SANDBOX = YES;
+ ENABLE_APP_SANDBOX = NO;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = NO;
@@ -594,6 +601,14 @@
version = 0.9.4;
};
};
+ 1CE4CB3D2F0EF6AF00C01C62 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 2.4.0;
+ };
+ };
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -602,6 +617,16 @@
package = 1C547F222F0E7D07008120A6 /* XCRemoteSwiftPackageReference "WhisperKit" */;
productName = WhisperKit;
};
+ 1CE4CB3E2F0EF6AF00C01C62 /* KeyboardShortcuts */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 1CE4CB3D2F0EF6AF00C01C62 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */;
+ productName = KeyboardShortcuts;
+ };
+ 1CE4CB402F0EF86B00C01C62 /* whisperkit-cli */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 1C547F222F0E7D07008120A6 /* XCRemoteSwiftPackageReference "WhisperKit" */;
+ productName = "whisperkit-cli";
+ };
/* End XCSwiftPackageProductDependency section */
};
rootObject = 3629471A2F0DBC4E00BE895D /* Project object */;
diff --git a/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 66ae653..f0af80d 100644
--- a/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,6 +1,15 @@
{
- "originHash" : "e843284a09b9d7fb8d0032fe1a3fd1fbd38f28ea54d42a39ccbe396af16d225d",
+ "originHash" : "75b62446b67bb6f01804d7cd072c92b0b43ff0d18ff0f3b558df7fac283c7dec",
"pins" : [
+ {
+ "identity" : "keyboardshortcuts",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sindresorhus/KeyboardShortcuts",
+ "state" : {
+ "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27",
+ "version" : "2.4.0"
+ }
+ },
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
diff --git a/speaktype/App/AppDelegate.swift b/speaktype/App/AppDelegate.swift
new file mode 100644
index 0000000..5e7b0c6
--- /dev/null
+++ b/speaktype/App/AppDelegate.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+import KeyboardShortcuts
+
+class AppDelegate: NSObject, NSApplicationDelegate {
+ private var miniRecorderController: MiniRecorderWindowController?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ // Initialize the controller
+ miniRecorderController = MiniRecorderWindowController()
+
+ // Setup global hotkey listener
+ KeyboardShortcuts.onKeyUp(for: .toggleRecord) { [weak self] in
+ // Direct toggle via controller.
+ // Bypasses URL schemes and SwiftUI Scene routing completely.
+ self?.miniRecorderController?.toggle()
+ }
+ }
+
+ // Critical: Prevent the app from quitting when the Mini Recorder panel closes.
+ // Since we are a Menu Bar app (mostly), we must stay alive.
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+ return false
+ }
+}
diff --git a/speaktype/App/speaktypeApp.swift b/speaktype/App/speaktypeApp.swift
index 206106e..ae27243 100644
--- a/speaktype/App/speaktypeApp.swift
+++ b/speaktype/App/speaktypeApp.swift
@@ -6,12 +6,57 @@
//
import SwiftUI
+import SwiftData
+import KeyboardShortcuts
@main
struct speaktypeApp: App {
+ @Environment(\.openWindow) var openWindow
+ @Environment(\.dismissWindow) var dismissWindow
+
+ @AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false
+
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ init() {
+ // KeyboardShortcuts init moved to AppDelegate to ensure early binding?
+ // Or keep here.
+ }
+
var body: some Scene {
- WindowGroup {
- ContentView()
+ // Main Dashboard Window (Hidden by default, opened via Menu Bar or Dock)
+ WindowGroup(id: "main-dashboard") {
+ Group {
+ if hasCompletedOnboarding {
+ MainView()
+ } else {
+ OnboardingView()
+ }
+ }
+ .preferredColorScheme(.dark)
+ .tint(.appRed)
+ }
+ .handlesExternalEvents(matching: ["main-dashboard", "open"]) // Only open for matching IDs
+ .commands {
+ SidebarCommands()
+ }
+
+ // Note: Mini Recorder is now managed manually by AppDelegate -> MiniRecorderWindowController
+ // to prevent SwiftUI from auto-opening the main dashboard on activation.
+
+ // Menu Bar Extra (Always running listener)
+ MenuBarExtra("SpeakType", systemImage: "mic.fill") {
+ Button("Open Dashboard") {
+ // Ensure we open the main dashboard via consistent ID or URL
+ // Using URL forces the specific window group to handle it
+ if let url = URL(string: "speaktype://open") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+ Divider()
+ Button("Quit") {
+ NSApplication.shared.terminate(nil)
+ }
}
}
}
diff --git a/speaktype/Assets.xcassets/AccentColor.colorset/Contents.json b/speaktype/Assets.xcassets/AccentColor.colorset/Contents.json
index eb87897..848b2bf 100644
--- a/speaktype/Assets.xcassets/AccentColor.colorset/Contents.json
+++ b/speaktype/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -1,7 +1,16 @@
{
"colors" : [
{
- "idiom" : "universal"
+ "idiom" : "universal",
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "red" : "0.913",
+ "alpha" : "1.000",
+ "blue" : "0.352",
+ "green" : "0.293"
+ }
+ }
}
],
"info" : {
diff --git a/speaktype/Constants/Shortcuts+Extensions.swift b/speaktype/Constants/Shortcuts+Extensions.swift
new file mode 100644
index 0000000..6e3198c
--- /dev/null
+++ b/speaktype/Constants/Shortcuts+Extensions.swift
@@ -0,0 +1,11 @@
+import Foundation
+import KeyboardShortcuts
+import AppKit
+
+extension KeyboardShortcuts.Name {
+ static let toggleRecord = Self("toggleRecord", default: .init(.space, modifiers: [.control, .option]))
+}
+
+extension Notification.Name {
+ static let hotkeyTriggered = Notification.Name("hotkeyTriggered")
+}
diff --git a/speaktype/Controllers/MiniRecorderWindowController.swift b/speaktype/Controllers/MiniRecorderWindowController.swift
new file mode 100644
index 0000000..8544ef8
--- /dev/null
+++ b/speaktype/Controllers/MiniRecorderWindowController.swift
@@ -0,0 +1,115 @@
+
+import Cocoa
+import SwiftUI
+
+class MiniRecorderWindowController: NSObject {
+ private var panel: NSPanel?
+ private var hostingController: NSHostingController?
+
+ // Toggle visibility
+ func toggle() {
+ if panel == nil {
+ setupPanel()
+ }
+
+ guard let panel = panel else { return }
+
+ if panel.isVisible {
+ print("Hiding Mini Recorder Panel")
+ panel.orderOut(nil)
+ } else {
+ print("Showing Mini Recorder Panel")
+ // Center closely to mouse or screen center? Center of screen is safer.
+ // But let's respect previous floating behavior.
+ panel.center()
+ // panel.makeKeyAndOrderFront(nil) -> Activates app. We want specific behavior.
+ // We want it to float but NOT activate the app if possible?
+ // Actually, for a recorder, we usually want it visible.
+
+ // To prevent activating the Main App (and thus showing Main Dashboard if it was key),
+ // we show the panel without activating the app?
+ // "panel.orderFront(nil)" shows it.
+
+ // However, to type into it (if needed) or interact, we might need activation.
+ // But our Mini Recorder is button-based.
+
+ // Critical: If we use NSApp.activate, it might switch to the main dashboard scene if that's "primary".
+ // By managing this panel separately, we avoid SwiftUI triggering the main scene.
+
+ // orderFrontRegardless allows showing the window without activating the app
+ panel.orderFrontRegardless()
+ // panel.orderFrontDuringApplicationMakeKey() // Does not exist
+ // panel.makeKeyAndOrderFront(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+ }
+
+ private func setupPanel() {
+ // Initialize View with callbacks
+ let recorderView = MiniRecorderView(
+ onCommit: { [weak self] text in
+ self?.handleCommit(text: text)
+ },
+ onCancel: { [weak self] in
+ self?.panel?.orderOut(nil)
+ }
+ )
+
+ hostingController = NSHostingController(rootView: recorderView)
+
+ let p = NSPanel(
+ contentRect: NSRect(x: 0, y: 0, width: 300, height: 60),
+ styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView, .hudWindow],
+ backing: .buffered,
+ defer: false
+ )
+
+ p.contentViewController = hostingController
+ p.titleVisibility = .hidden
+ p.titlebarAppearsTransparent = true
+ p.isMovableByWindowBackground = true
+
+ // Window Behavior
+ p.level = .floating
+ p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ p.isReleasedWhenClosed = false
+ p.hidesOnDeactivate = false // Keep floating even if focus lost? Yes.
+ p.standardWindowButton(.closeButton)?.isHidden = true
+ p.standardWindowButton(.miniaturizeButton)?.isHidden = true
+ p.standardWindowButton(.zoomButton)?.isHidden = true
+
+ self.panel = p
+ }
+
+ private func handleCommit(text: String) {
+ Task {
+ // 1. Copy to clipboard
+ ClipboardService.shared.copy(text: text)
+
+ // 2. Hide window to return focus
+ await MainActor.run {
+ print("Closing recorder panel...")
+ self.panel?.orderOut(nil)
+ // Also force deactivation just in case
+ NSApp.hide(nil)
+ }
+
+ // 3. Wait for focus switch
+ print("Waiting for focus switch...")
+ try? await Task.sleep(nanoseconds: 800_000_000) // 0.8s
+
+ // 4. Paste
+ if ClipboardService.shared.isAccessibilityTrusted {
+ print("Simulating Paste (Accessibility Trusted)...")
+ ClipboardService.shared.paste()
+ print("Paste command sent.")
+ } else {
+ print("⚠️ Accessibility Permission Missing! Cannot paste. Requesting system prompt...")
+ // Trigger native system prompt which is more reliable for re-association
+ await MainActor.run {
+ ClipboardService.shared.requestAccessibilityPermission()
+ }
+ }
+ }
+ }
+}
diff --git a/speaktype/Resources/Info.plist b/speaktype/Resources/Info.plist
index 2267fd8..c82ced8 100644
--- a/speaktype/Resources/Info.plist
+++ b/speaktype/Resources/Info.plist
@@ -20,6 +20,17 @@
1
LSMinimumSystemVersion
$(MACOSX_DEPLOYMENT_TARGET)
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ com.karansinghgit.speaktype
+ CFBundleURLSchemes
+
+ speaktype
+
+
+
LSUIElement
NSMicrophoneUsageDescription
diff --git a/speaktype/Resources/speaktype.entitlements b/speaktype/Resources/speaktype.entitlements
index d019de2..80e0c50 100644
--- a/speaktype/Resources/speaktype.entitlements
+++ b/speaktype/Resources/speaktype.entitlements
@@ -3,7 +3,7 @@
com.apple.security.app-sandbox
-
+
com.apple.security.device.audio-input
com.apple.security.automation.apple-events
diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift
index bb7c94f..4859ab8 100644
--- a/speaktype/Services/AudioRecordingService.swift
+++ b/speaktype/Services/AudioRecordingService.swift
@@ -4,7 +4,9 @@ import Combine
class AudioRecordingService: NSObject, ObservableObject {
private var audioRecorder: AVAudioRecorder?
+ private var timer: Timer?
@Published var isRecording = false
+ @Published var audioLevel: Float = 0.0
// Request microphone permission
func requestPermission() {
@@ -12,7 +14,7 @@ class AudioRecordingService: NSObject, ObservableObject {
AVCaptureDevice.requestAccess(for: .audio) { _ in }
}
- // Start recording audio to a temporary file
+ // Start recording audio
func startRecording() {
let status = AVCaptureDevice.authorizationStatus(for: .audio)
@@ -49,16 +51,38 @@ class AudioRecordingService: NSObject, ObservableObject {
do {
audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
+ audioRecorder?.isMeteringEnabled = true // Enable metering
audioRecorder?.record()
isRecording = true
+ startMetering() // Start timer
print("Recording started to \(audioFilename)")
} catch {
print("Could not start recording: \(error.localizedDescription)")
}
}
+ private func startMetering() {
+ timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
+ guard let self = self, let recorder = self.audioRecorder else { return }
+ recorder.updateMeters()
+ let power = recorder.averagePower(forChannel: 0) // -160 to 0 dB
+ // Normalize: -60dB to 0dB -> 0.0 to 1.0
+ let normalized = max(0.0, (power + 60) / 60)
+ DispatchQueue.main.async {
+ self.audioLevel = normalized
+ }
+ }
+ }
+
+ private func stopMetering() {
+ timer?.invalidate()
+ timer = nil
+ audioLevel = 0.0
+ }
+
// Stop recording and return the file URL
func stopRecording() -> URL? {
+ stopMetering()
audioRecorder?.stop()
isRecording = false
print("Recording stopped")
diff --git a/speaktype/Services/ClipboardService.swift b/speaktype/Services/ClipboardService.swift
index 7a6e066..5a3a0bb 100644
--- a/speaktype/Services/ClipboardService.swift
+++ b/speaktype/Services/ClipboardService.swift
@@ -43,6 +43,18 @@ class ClipboardService {
cmdUp?.post(tap: .cghidEventTap)
print("Simulated Cmd+V")
+ print("Simulated Cmd+V")
}
}
+
+ // Check if we have permission to send keystrokes
+ var isAccessibilityTrusted: Bool {
+ return AXIsProcessTrusted()
+ }
+
+ // Request permission via system prompt
+ func requestAccessibilityPermission() {
+ let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
+ _ = AXIsProcessTrustedWithOptions(options)
+ }
}
diff --git a/speaktype/Services/HistoryService.swift b/speaktype/Services/HistoryService.swift
new file mode 100644
index 0000000..01e66c8
--- /dev/null
+++ b/speaktype/Services/HistoryService.swift
@@ -0,0 +1,51 @@
+import Foundation
+import Combine
+import SwiftUI // For IndexSet operations if needed, though Foundation usually covers it, but error says missing import.
+
+struct HistoryItem: Identifiable, Codable, Hashable {
+ let id: UUID
+ let date: Date
+ let transcript: String
+ let duration: TimeInterval
+}
+
+class HistoryService: ObservableObject {
+ static let shared = HistoryService()
+
+ @Published var items: [HistoryItem] = []
+
+ private let saveKey = "history_items"
+
+ private init() {
+ loadHistory()
+ }
+
+ func addItem(transcript: String, duration: TimeInterval) {
+ let newItem = HistoryItem(id: UUID(), date: Date(), transcript: transcript, duration: duration)
+ items.insert(newItem, at: 0) // Newest first
+ saveHistory()
+ }
+
+ func deleteItem(at offsets: IndexSet) {
+ items.remove(atOffsets: offsets)
+ saveHistory()
+ }
+
+ func deleteItem(id: UUID) {
+ items.removeAll { $0.id == id }
+ saveHistory()
+ }
+
+ private func saveHistory() {
+ if let encoded = try? JSONEncoder().encode(items) {
+ UserDefaults.standard.set(encoded, forKey: saveKey)
+ }
+ }
+
+ private func loadHistory() {
+ if let data = UserDefaults.standard.data(forKey: saveKey),
+ let decoded = try? JSONDecoder().decode([HistoryItem].self, from: data) {
+ items = decoded
+ }
+ }
+}
diff --git a/speaktype/Services/HotkeyService.swift b/speaktype/Services/HotkeyService.swift
deleted file mode 100644
index feea04f..0000000
--- a/speaktype/Services/HotkeyService.swift
+++ /dev/null
@@ -1,73 +0,0 @@
-import Cocoa
-import Carbon
-import Combine
-
-class HotkeyService: ObservableObject {
- static let shared = HotkeyService()
-
- // Default hotkey: Option + Shift + Space
- // We can make this configurable later
- private var hotKeyRef: EventHotKeyRef?
-
- @Published var isPressed = false
-
- private init() {
- registerHotkey()
- }
-
- func registerHotkey() {
- var eventType = EventTypeSpec(
- eventClass: OSType(kEventClassKeyboard),
- eventKind: UInt32(kEventHotKeyPressed)
- )
-
- // Install handler for Key Pressed
- InstallEventHandler(GetApplicationEventTarget(), { (nextHandler, theEvent, userData) -> OSStatus in
- // Handle Hotkey Pressed
- print("Hotkey Pressed")
- NotificationCenter.default.post(name: .hotkeyPressed, object: nil)
- return noErr
- }, 1, &eventType, nil, nil)
-
- // Install handler for Key Released
- var releaseEventType = EventTypeSpec(
- eventClass: OSType(kEventClassKeyboard),
- eventKind: UInt32(kEventHotKeyReleased)
- )
-
- InstallEventHandler(GetApplicationEventTarget(), { (nextHandler, theEvent, userData) -> OSStatus in
- // Handle Hotkey Released
- print("Hotkey Released")
- NotificationCenter.default.post(name: .hotkeyReleased, object: nil)
- return noErr
- }, 1, &releaseEventType, nil, nil)
-
- // Register the actual hotkey (Option + Shift + Space)
- // 49 is Space
- // modifiers: optionKey + shiftKey
- let hotKeyID = EventHotKeyID(signature: OSType(0x5357), id: 1) // Signature 'SW', ID 1
- var gMyHotKeyRef: EventHotKeyRef? = nil
-
- // modifiers: cmdKey = 256, shiftKey = 512, optionKey = 2048, controlKey = 4096
- // preferring Control + Shift + Space to avoid conflicts?
- // User requested "press-and-hold", picking Option+Space or similar is common.
- // Let's go with Option + Space (common for launchers) or Shift+Option+Space.
- // Let's use Option + Space (49 + 2048) for now.
-
- let modifiers = optionKey
-
- RegisterEventHotKey(UInt32(kVK_Space), UInt32(modifiers), hotKeyID, GetApplicationEventTarget(), 0, &gMyHotKeyRef)
- hotKeyRef = gMyHotKeyRef
- }
-
- func unregister() {
- if let ref = hotKeyRef {
- UnregisterEventHotKey(ref)
- }
- }
-}
-
-extension Notification.Name {
- static let hotkeyPressed = Notification.Name("hotkeyPressed")
- static let hotkeyReleased = Notification.Name("hotkeyReleased")
-}
diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift
new file mode 100644
index 0000000..dd5acd1
--- /dev/null
+++ b/speaktype/Services/ModelDownloadService.swift
@@ -0,0 +1,64 @@
+import Foundation
+import Combine
+import WhisperKit
+
+class ModelDownloadService: ObservableObject {
+ static let shared = ModelDownloadService()
+
+ @Published var downloadProgress: [String: Double] = [:] // Map Model Variant (String) to progress
+ @Published var isDownloading: [String: Bool] = [:]
+
+ private init() {}
+
+ // Asynchronous download using WhisperKit
+ func downloadModel(variant: String) {
+ guard isDownloading[variant] != true else { return }
+
+ isDownloading[variant] = true
+ downloadProgress[variant] = 0.0
+ print("Starting WhisperKit download for: \(variant)")
+
+ Task {
+ do {
+ // Determine model variant enum/string
+ // Note: WhisperKit.download(variant:from:) is the likely API.
+ // We use the "variant" string to fetch.
+ // Assuming `WhisperKit.download(variant: variant)` acts as the fetcher.
+ // Progress callback mock (since we might not have exact API signature yet):
+
+ // Actual API (hypothetical based on search):
+ // let model = try await WhisperKit(model: variant)
+ // OR
+ // try await WhisperKit.download(variant: variant) { progress in ... }
+
+ // likely: download(variant:progressCallback:) - 'from' usually has a default
+ let path = try await WhisperKit.download(variant: variant, progressCallback: { progress in
+ DispatchQueue.main.async {
+ self.downloadProgress[variant] = progress.fractionCompleted
+ }
+ })
+
+ print("Model downloaded to: \(path)")
+
+ DispatchQueue.main.async {
+ self.isDownloading[variant] = false
+ self.downloadProgress[variant] = 1.0
+ }
+ } catch {
+ print("WhisperKit download error: \(error)")
+ DispatchQueue.main.async {
+ self.isDownloading[variant] = false
+ self.downloadProgress[variant] = 0.0
+ }
+ }
+ }
+ }
+
+ func cancelDownload(for variant: String) {
+ // WhisperKit might not support cancellation easily via this simple wrapper
+ // For now, just reset state
+ isDownloading[variant] = false
+ downloadProgress[variant] = 0.0
+ }
+}
+
diff --git a/speaktype/Services/PermissionService.swift b/speaktype/Services/PermissionService.swift
new file mode 100644
index 0000000..67e39cf
--- /dev/null
+++ b/speaktype/Services/PermissionService.swift
@@ -0,0 +1,23 @@
+import Foundation
+import AVFoundation
+import ApplicationServices
+
+class PermissionService {
+ static let shared = PermissionService()
+
+ // Checks if both Microphone and Accessibility permissions are currently granted
+ var arePermissionsGranted: Bool {
+ let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
+ let accessibilityStatus = AXIsProcessTrusted()
+ return micStatus && accessibilityStatus
+ }
+
+ // Checks specific permissions individually
+ var isMicGranted: Bool {
+ AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
+ }
+
+ var isAccessibilityGranted: Bool {
+ AXIsProcessTrusted()
+ }
+}
diff --git a/speaktype/Services/WhisperService.swift b/speaktype/Services/WhisperService.swift
index ded70b2..66ae71d 100644
--- a/speaktype/Services/WhisperService.swift
+++ b/speaktype/Services/WhisperService.swift
@@ -7,25 +7,41 @@ class WhisperService {
var isInitialized = false
var isTranscribing = false
+ var currentModelVariant: String = "openai_whisper-base" // Default
+
enum TranscriptionError: Error {
case notInitialized
case fileNotFound
}
+ // Default initialization (loads default or saved model)
func initialize() async throws {
- // Initialize WhisperKit with the base model (good balance of speed/accuracy)
- // We'll use "tiny.en" for MVP speed, can be configured later
- print("Initializing WhisperKit...")
+ // You might want to pull from UserDefaults here if you want persistence in Service
+ // For now, allow the View to drive the variant selection via loadModel
+ try await loadModel(variant: currentModelVariant)
+ }
+
+ // Dynamic model loading
+ func loadModel(variant: String) async throws {
+ if isInitialized && variant == currentModelVariant && pipe != nil {
+ return // Already loaded
+ }
+
+ print("Initializing WhisperKit with model: \(variant)...")
+ isInitialized = false
+
do {
- // "tiny.en" is a small, English-only model perfect for offline dictation
- // Explicitly request the tiny English model to minimize download size (~39MB)
- pipe = try await WhisperKit(model: "tiny.en")
- // Note: In a real app we might want to check for model availability or download it.
- // For now, we rely on WhisperKit's default behavior or pre-downloaded models.
+ // WhisperKit.download(variant:) logic handles checking if consistent
+ // But here we want the PIPE, so we init WhisperKit(model: variant)
+ // Note: If model isn't downloaded, this might fail or trigger download depending on library version.
+ // Ideally, we ensure it's downloaded first via ModelDownloadService, but WhisperKit init often handles it.
+
+ pipe = try await WhisperKit(model: variant)
+ currentModelVariant = variant
isInitialized = true
- print("WhisperKit initialized successfully")
+ print("WhisperKit initialized successfully with \(variant)")
} catch {
- print("Failed to initialize WhisperKit: \(error.localizedDescription)")
+ print("Failed to initialize WhisperKit with \(variant): \(error.localizedDescription)")
throw error
}
}
diff --git a/speaktype/Utilities/AppColors.swift b/speaktype/Utilities/AppColors.swift
new file mode 100644
index 0000000..7f2dbeb
--- /dev/null
+++ b/speaktype/Utilities/AppColors.swift
@@ -0,0 +1,7 @@
+import SwiftUI
+
+extension Color {
+ static let appRed = Color(red: 0.913, green: 0.293, blue: 0.352)
+ static let sidebarBackground = Color(red: 0.1, green: 0.1, blue: 0.1) // Approximate dark sidebar
+ static let contentBackground = Color(red: 0.12, green: 0.12, blue: 0.12) // Approximate dark content
+}
diff --git a/speaktype/Views/AIModelsView.swift b/speaktype/Views/AIModelsView.swift
new file mode 100644
index 0000000..8137515
--- /dev/null
+++ b/speaktype/Views/AIModelsView.swift
@@ -0,0 +1,197 @@
+import SwiftUI
+
+struct AIModelsView: View {
+ @StateObject private var downloadService = ModelDownloadService.shared
+ @AppStorage("selectedModelVariant") private var selectedModel: String = "openai_whisper-base"
+
+ // Using standard WhisperKit model variants
+ @State private var models = [
+ AIModel(
+ name: "Whisper Base",
+ variant: "openai_whisper-base",
+ details: "English-only • Optimized for Apple Silicon"
+ ),
+ AIModel(
+ name: "Whisper Small",
+ variant: "openai_whisper-small",
+ details: "English-only • Higher accuracy"
+ ),
+ AIModel(
+ name: "Whisper Tiny",
+ variant: "openai_whisper-tiny",
+ details: "Multilingual • Fastest"
+ )
+ ]
+
+ // Helper to get name of selected model
+ var selectedModelName: String {
+ models.first(where: { $0.variant == selectedModel })?.name ?? "Unknown Model"
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 30) {
+ // Header (Redesigned)
+ VStack(alignment: .leading, spacing: 10) {
+ Text("AI Models")
+ .font(.largeTitle)
+ .fontWeight(.bold)
+ .foregroundStyle(.white)
+ Text("Manage your local transcription models powered by WhisperKit.")
+ .font(.body)
+ .foregroundStyle(.gray)
+ }
+
+ VStack(alignment: .leading, spacing: 20) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Default Model")
+ .font(.headline)
+ .foregroundStyle(.gray)
+ Text(selectedModelName)
+ .font(.title2)
+ .fontWeight(.bold)
+ .foregroundStyle(.white)
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.contentBackground.opacity(0.5))
+ .cornerRadius(12)
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(Color.gray.opacity(0.3), lineWidth: 1)
+ )
+ }
+
+ HStack {
+ Text("Available Models")
+ .font(.title2)
+ .fontWeight(.bold)
+ .foregroundStyle(.white)
+ Spacer()
+ }
+
+ // Models List
+ VStack(spacing: 16) {
+ ForEach($models) { $model in
+ ModelRow(model: $model, selectedModel: $selectedModel)
+ }
+ }
+ }
+ .padding(30)
+ }
+ .background(Color.contentBackground)
+ }
+}
+
+struct AIModel: Identifiable {
+ let id = UUID()
+ let name: String
+ let variant: String
+ let details: String
+ // status is derived from service now
+}
+
+struct ModelRow: View {
+ @Binding var model: AIModel
+ @Binding var selectedModel: String // Bind to parent selection
+ @ObservedObject var downloadService = ModelDownloadService.shared
+
+ var progress: Double {
+ downloadService.downloadProgress[model.variant] ?? 0.0
+ }
+
+ var isDownloading: Bool {
+ downloadService.isDownloading[model.variant] ?? false
+ }
+
+ var isDownloaded: Bool {
+ // Placeholder check. In real app, check file existence or WhisperKit.isModelAvailable(variant)
+ return progress >= 1.0
+ }
+
+ var isActive: Bool {
+ selectedModel == model.variant
+ }
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(model.name)
+ .font(.headline)
+ .foregroundStyle(.white)
+ if isActive {
+ Text("(Active)")
+ .font(.caption)
+ .foregroundStyle(Color.green)
+ }
+ }
+ Text(model.details)
+ .font(.caption)
+ .foregroundStyle(.gray)
+ }
+
+ Spacer()
+
+ if isDownloaded {
+ // If downloaded, allow selection
+ if isActive {
+ HStack(spacing: 4) {
+ Image(systemName: "checkmark.circle.fill")
+ Text("Selected")
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .foregroundStyle(Color.green)
+ } else {
+ Button("Use") {
+ selectedModel = model.variant
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Color.white.opacity(0.1))
+ .foregroundStyle(.white)
+ .cornerRadius(16)
+ .buttonStyle(.plain)
+ }
+ } else if isDownloading {
+ HStack {
+ ProgressView(value: progress)
+ .progressViewStyle(.linear)
+ .frame(width: 80)
+ .tint(Color.appRed)
+ Text("\(Int(progress * 100))%")
+ .font(.caption)
+ .foregroundStyle(.gray)
+ .frame(width: 35, alignment: .trailing)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Color.black.opacity(0.2))
+ .cornerRadius(16)
+ } else {
+ Button(action: {
+ downloadService.downloadModel(variant: model.variant)
+ }) {
+ HStack(spacing: 4) {
+ Text("Download")
+ Image(systemName: "arrow.down.circle")
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Color.appRed)
+ .foregroundStyle(.white)
+ .cornerRadius(16)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding()
+ .background(Color.white.opacity(0.05))
+ .cornerRadius(12)
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(isActive ? Color.green.opacity(0.5) : Color.white.opacity(0.05), lineWidth: 1)
+ )
+ }
+}
diff --git a/speaktype/Views/AudioInputView.swift b/speaktype/Views/AudioInputView.swift
new file mode 100644
index 0000000..b572aa8
--- /dev/null
+++ b/speaktype/Views/AudioInputView.swift
@@ -0,0 +1,177 @@
+import SwiftUI
+
+struct AudioInputView: View {
+ @State private var selectedMode = "Custom Device"
+ @State private var selectedDevice = "MacBook Air Microphone"
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 30) {
+ // Header
+ VStack(spacing: 15) {
+ Image(systemName: "waveform")
+ .font(.system(size: 40))
+ .foregroundStyle(Color.appRed)
+
+ Text("Audio Input")
+ .font(.largeTitle)
+ .fontWeight(.bold)
+ .foregroundStyle(.white)
+
+ Text("Configure your microphone preferences")
+ .foregroundStyle(.gray)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.top, 40)
+
+ // Input Mode Section
+ VStack(alignment: .leading, spacing: 15) {
+ Text("Input Mode")
+ .font(.headline)
+ .foregroundStyle(.white)
+
+ HStack(spacing: 20) {
+ InputModeCard(
+ title: "Custom Device",
+ desc: "Select a specific input device",
+ icon: "mic.fill",
+ isSelected: selectedMode == "Custom Device",
+ color: .appRed
+ )
+ .onTapGesture { selectedMode = "Custom Device" }
+
+ InputModeCard(
+ title: "Prioritized",
+ desc: "Set up device priority order",
+ icon: "list.number",
+ isSelected: selectedMode == "Prioritized",
+ color: .gray
+ )
+ .onTapGesture { selectedMode = "Prioritized" }
+ }
+ }
+ .padding(.horizontal, 40)
+
+ // Available Devices Section
+ VStack(alignment: .leading, spacing: 15) {
+ HStack {
+ Text("Available Devices")
+ .font(.headline)
+ .foregroundStyle(.white)
+ Spacer()
+ Button(action: {}) {
+ HStack {
+ Image(systemName: "arrow.clockwise")
+ Text("Refresh")
+ }
+ .font(.subheadline)
+ .foregroundStyle(.gray)
+ }
+ .buttonStyle(.plain)
+ }
+
+ Text("Note: Selecting a device here will override your Mac's system-wide default microphone.")
+ .font(.caption)
+ .foregroundStyle(.gray)
+
+ VStack(spacing: 12) {
+ DeviceRow(
+ name: "MacBook Air Microphone",
+ isActive: true,
+ isSelected: selectedDevice == "MacBook Air Microphone"
+ )
+ .onTapGesture { selectedDevice = "MacBook Air Microphone" }
+
+ DeviceRow(
+ name: "Microsoft Teams Audio",
+ isActive: false,
+ isSelected: selectedDevice == "Microsoft Teams Audio"
+ )
+ .onTapGesture { selectedDevice = "Microsoft Teams Audio" }
+ }
+ }
+ .padding(.horizontal, 40)
+ }
+ .padding(.bottom, 40)
+ }
+ .background(Color.contentBackground)
+ }
+}
+
+struct InputModeCard: View {
+ let title: String
+ let desc: String
+ let icon: String
+ let isSelected: Bool
+ let color: Color
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 15) {
+ ZStack {
+ Circle()
+ .fill(isSelected ? color.opacity(0.2) : Color.white.opacity(0.1))
+ .frame(width: 36, height: 36)
+ Image(systemName: icon)
+ .foregroundStyle(isSelected ? color : .gray)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.headline)
+ .foregroundStyle(.white)
+ Text(desc)
+ .font(.caption)
+ .foregroundStyle(.gray)
+ }
+ Spacer()
+ }
+ .padding()
+ .frame(height: 100)
+ .background(Color.white.opacity(0.05))
+ .cornerRadius(12)
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(isSelected ? color : Color.white.opacity(0.1), lineWidth: 1)
+ )
+ }
+}
+
+struct DeviceRow: View {
+ let name: String
+ let isActive: Bool
+ let isSelected: Bool
+
+ var body: some View {
+ HStack {
+ Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(isSelected ? Color.appRed : .gray)
+ .font(.title3)
+
+ Text(name)
+ .font(.body)
+ .foregroundStyle(.white)
+
+ Spacer()
+
+ if isActive {
+ HStack(spacing: 4) {
+ Image(systemName: "waveform")
+ Text("Active")
+ }
+ .font(.caption)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Color.green.opacity(0.2))
+ .foregroundStyle(.green)
+ .cornerRadius(12)
+ }
+ }
+ .padding()
+ .background(isSelected ? Color.appRed.opacity(0.05) : Color.white.opacity(0.02))
+ .cornerRadius(12)
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(isSelected ? Color.appRed.opacity(0.5) : Color.white.opacity(0.1), lineWidth: 1)
+ )
+ }
+}
diff --git a/speaktype/Views/ContentView.swift b/speaktype/Views/ContentView.swift
index 65e3d16..d7414b2 100644
--- a/speaktype/Views/ContentView.swift
+++ b/speaktype/Views/ContentView.swift
@@ -87,7 +87,7 @@ struct ContentView: View {
.padding()
.frame(width: 400, height: 500)
.onAppear {
- _ = HotkeyService.shared // Initialize Hotkey Listener
+ // HotkeyService removed in favor of KeyboardShortcuts defined in App
}
}
}
diff --git a/speaktype/Views/DashboardView.swift b/speaktype/Views/DashboardView.swift
new file mode 100644
index 0000000..d29a623
--- /dev/null
+++ b/speaktype/Views/DashboardView.swift
@@ -0,0 +1,31 @@
+import SwiftUI
+
+struct DashboardView: View {
+ var body: some View {
+ VStack {
+ // Header removed for cleaner look
+
+ // Placeholder content
+ Spacer()
+
+ Image(systemName: "waveform")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 100, height: 100)
+ .foregroundStyle(Color.appRed)
+
+ Text("No Transcriptions Yet")
+ .font(.headline)
+ .foregroundStyle(.white)
+ .padding(.top)
+
+ Text("Start your first recording to unlock value insights.")
+ .font(.subheadline)
+ .foregroundStyle(.gray)
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.contentBackground)
+ }
+}
diff --git a/speaktype/Views/HistoryView.swift b/speaktype/Views/HistoryView.swift
new file mode 100644
index 0000000..b1d02ee
--- /dev/null
+++ b/speaktype/Views/HistoryView.swift
@@ -0,0 +1,82 @@
+import SwiftUI
+
+struct HistoryView: View {
+ @StateObject private var historyService = HistoryService.shared
+ @State private var selectedItem: HistoryItem?
+
+ var body: some View {
+ NavigationSplitView {
+ List(historyService.items, selection: $selectedItem) { item in
+ NavigationLink(value: item) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(item.transcript.prefix(50) + (item.transcript.count > 50 ? "..." : ""))
+ .font(.headline)
+ .foregroundStyle(.white)
+
+ HStack {
+ Text(item.date.formatted(date: .abbreviated, time: .shortened))
+ Spacer()
+ Text(formatDuration(item.duration))
+ }
+ .font(.caption)
+ .foregroundStyle(.gray)
+ }
+ .padding(.vertical, 4)
+ .contextMenu {
+ Button("Delete", role: .destructive) {
+ historyService.deleteItem(id: item.id)
+ if selectedItem?.id == item.id {
+ selectedItem = nil
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle("History")
+ .listStyle(.sidebar)
+ } detail: {
+ if let item = selectedItem {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ HStack {
+ Text(item.date.formatted(date: .long, time: .standard))
+ .font(.subheadline)
+ .foregroundStyle(.gray)
+
+ Spacer()
+
+ Button(action: {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(item.transcript, forType: .string)
+ }) {
+ Label("Copy", systemImage: "doc.on.doc")
+ }
+ }
+
+ Divider()
+
+ Text(item.transcript)
+ .font(.body)
+ .foregroundStyle(.white)
+ .textSelection(.enabled)
+ .lineSpacing(4)
+
+ Spacer()
+ }
+ .padding(30)
+ }
+ .background(Color.contentBackground)
+ } else {
+ Text("Select a recording to view transcript")
+ .foregroundStyle(.gray)
+ }
+ }
+ }
+
+ func formatDuration(_ duration: TimeInterval) -> String {
+ let formatter = DateComponentsFormatter()
+ formatter.allowedUnits = [.minute, .second]
+ formatter.unitsStyle = .abbreviated
+ return formatter.string(from: duration) ?? "0s"
+ }
+}
diff --git a/speaktype/Views/MainView.swift b/speaktype/Views/MainView.swift
new file mode 100644
index 0000000..a9cb5db
--- /dev/null
+++ b/speaktype/Views/MainView.swift
@@ -0,0 +1,35 @@
+import SwiftUI
+
+struct MainView: View {
+ @State private var selection: SidebarItem? = .dashboard
+
+ var body: some View {
+ NavigationSplitView {
+ SidebarView(selection: $selection)
+ } detail: {
+ switch selection {
+ case .dashboard:
+ DashboardView()
+ case .transcribeAudio:
+ TranscribeAudioView()
+ case .history:
+ HistoryView()
+ case .aiModels:
+ AIModelsView()
+ case .permissions:
+ PermissionsView()
+ case .audioInput:
+ AudioInputView()
+ case .settings:
+ SettingsView()
+ default:
+ Text("Content for \(selection?.rawValue ?? "Selection") coming soon")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.contentBackground)
+ .foregroundStyle(.white)
+ }
+ }
+ .navigationTitle("SpeakType")
+ .fontDesign(.rounded) // Global modern font design
+ }
+}
diff --git a/speaktype/Views/MiniRecorderView.swift b/speaktype/Views/MiniRecorderView.swift
new file mode 100644
index 0000000..513503c
--- /dev/null
+++ b/speaktype/Views/MiniRecorderView.swift
@@ -0,0 +1,196 @@
+import SwiftUI
+import Combine
+import AVFoundation
+import CoreMedia
+
+struct MiniRecorderView: View {
+ @StateObject private var audioRecorder = AudioRecordingService()
+ @State private var whisperService = WhisperService()
+ @State private var isListening = false
+ @State private var isProcessing = false
+ var onCommit: ((String) -> Void)?
+ var onCancel: (() -> Void)?
+
+ @AppStorage("selectedModelVariant") private var selectedModel: String = "openai_whisper-base"
+
+ // Default Init for Preview
+ init(onCommit: ((String) -> Void)? = nil, onCancel: (() -> Void)? = nil) {
+ self.onCommit = onCommit
+ self.onCancel = onCancel
+ }
+
+ var body: some View {
+ HStack(spacing: 16) {
+ // State Icon / Button
+ Button(action: {
+ handleHotkeyTrigger() // Use same toggle logic
+ }) {
+ if isProcessing {
+ ProgressView()
+ .controlSize(.small)
+ .tint(.white)
+ } else {
+ Image(systemName: isListening ? "stop.circle.fill" : "mic.circle.fill") // Explicit Stop/Record icons
+ .font(.system(size: 28)) // Larger icon
+ .foregroundStyle(isListening ? Color.appRed : Color.white) // High contrast
+ .shadow(color: isListening ? Color.appRed.opacity(0.6) : .clear, radius: 8)
+ }
+ }
+ .buttonStyle(.plain)
+
+ // Listening Visualizer (Dots)
+ HStack(spacing: 4) {
+ ForEach(0..<12) { index in
+ RoundedRectangle(cornerRadius: 2)
+ .fill(isListening ? Color.appRed : Color.white.opacity(0.3)) // Red when active
+ .frame(width: 4, height: 24) // Vertical bars instead of dots for better visibility
+ .scaleEffect(y: isListening ? CGFloat(1.0 + (Double(audioRecorder.audioLevel) * 5.0)) : 1.0) // Vertical scaling
+ .opacity(isListening ? 1.0 : 0.3)
+ .animation(
+ isListening ? .easeInOut(duration: 0.1) : .default,
+ value: audioRecorder.audioLevel
+ )
+ }
+ }
+ .frame(width: 120, height: 32) // Fixed frame
+
+ // Actions
+ HStack(spacing: 12) {
+ // Model Selector
+ Menu {
+ Picker("Model", selection: $selectedModel) {
+ Text("Base (Recommended)").tag("openai_whisper-base")
+ Text("Small (High Accuracy)").tag("openai_whisper-small")
+ Text("Tiny (Fastest)").tag("openai_whisper-tiny")
+ }
+ } label: {
+ Image(systemName: "cpu")
+ .font(.system(size: 14))
+ .foregroundStyle(.gray)
+ .padding(6)
+ .background(Color.white.opacity(0.1))
+ .clipShape(Circle())
+ }
+ .menuStyle(.borderlessButton)
+ .fixedSize()
+ .onChange(of: selectedModel) { newValue in
+ switchModel(to: newValue)
+ }
+
+ Button(action: {
+ // Cancel / Close
+ if isListening {
+ _ = audioRecorder.stopRecording()
+ isListening = false
+ }
+ // Notify controller to cancel/close
+ onCancel?()
+ }) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(Color.white.opacity(0.6))
+ .font(.system(size: 20))
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 12)
+ .background(
+ Capsule()
+ .fill(Color.black) // Pure black for high contrast
+ .shadow(color: Color.black.opacity(0.5), radius: 10, x: 0, y: 5)
+ )
+ .overlay(
+ Capsule()
+ .stroke(Color.white.opacity(0.15), lineWidth: 1) // Subtle border
+ )
+ .fontDesign(.rounded)
+ .onReceive(NotificationCenter.default.publisher(for: .hotkeyTriggered)) { _ in
+ handleHotkeyTrigger()
+ }
+ .onAppear {
+ initializedService()
+ // Window configuration is now handled by Controller
+ }
+ }
+
+ // Initialize Whisper
+ // Initialize Whisper
+ private func initializedService() {
+ Task {
+ // Load the persisted model
+ try? await whisperService.loadModel(variant: selectedModel)
+ }
+ }
+
+ private func switchModel(to variant: String) {
+ guard !isListening && !isProcessing else { return }
+ Task {
+ print("Switching model to: \(variant)")
+ try? await whisperService.loadModel(variant: variant)
+ }
+ }
+
+ // Main Workflow Logic
+ private func handleHotkeyTrigger() {
+ if isListening {
+ stopAndTranscribe()
+ } else {
+ startRecording()
+ }
+ }
+
+ private func startRecording() {
+ guard !isProcessing else { return }
+ audioRecorder.startRecording()
+ isListening = true
+ }
+
+ private func stopAndTranscribe() {
+ guard let url = audioRecorder.stopRecording() else {
+ isListening = false
+ return
+ }
+ isListening = false
+ isProcessing = true
+
+ Task {
+ // Transcribe
+ do {
+ if !whisperService.isInitialized {
+ try? await whisperService.loadModel(variant: selectedModel)
+ }
+ let text = try await whisperService.transcribe(audioFile: url)
+ print("Transcription Result: '\(text)'")
+
+ guard !text.isEmpty else {
+ print("Transcription was empty. Skipping paste.")
+ isProcessing = false
+ return
+ }
+
+ // Save to History
+ let duration = getAudioDuration(url: url)
+ HistoryService.shared.addItem(transcript: text, duration: duration)
+
+ // Delegate Commit to Controller (Copy, Hide, Paste happens there)
+ await MainActor.run {
+ onCommit?(text)
+ }
+
+ } catch {
+ print("Transcription error: \(error)")
+ }
+
+ // Reset state
+ await MainActor.run {
+ isProcessing = false
+ }
+ }
+ }
+
+ private func getAudioDuration(url: URL) -> TimeInterval {
+ let asset = AVURLAsset(url: url)
+ return CMTimeGetSeconds(asset.duration)
+ }
+}
diff --git a/speaktype/Views/OnboardingView.swift b/speaktype/Views/OnboardingView.swift
new file mode 100644
index 0000000..ac2f054
--- /dev/null
+++ b/speaktype/Views/OnboardingView.swift
@@ -0,0 +1,310 @@
+import SwiftUI
+import AVFoundation
+
+struct OnboardingView: View {
+ @AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false
+ @State private var currentPage = 0
+
+ var body: some View {
+ GeometryReader { geometry in
+ ZStack {
+ // Background - Solid Dark
+ Color.contentBackground
+ .ignoresSafeArea()
+
+ // Content ZStack
+ ScrollView {
+ ZStack {
+ if currentPage == 0 {
+ WelcomePage(action: {
+ withAnimation { currentPage = 1 }
+ })
+ .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
+ } else if currentPage == 1 {
+ PermissionsPage(finishAction: {
+ withAnimation { currentPage = 2 }
+ })
+ .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
+ } else {
+ // Tutorial / Final Step
+ WelcomePage(action: {
+ completeOnboarding()
+ })
+ .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
+ }
+ }
+ .padding(40)
+ .frame(maxWidth: .infinity, minHeight: 600) // Ensure layout has space but scrolls
+ }
+ }
+ }
+ .frame(minWidth: 600, minHeight: 500) // Lower minimum size
+ .fontDesign(.rounded) // Apply global rounded font for Onboarding
+ }
+
+ func completeOnboarding() {
+ withAnimation {
+ hasCompletedOnboarding = true
+ }
+ }
+}
+
+struct WelcomePage: View {
+ let action: () -> Void
+
+ var body: some View {
+ VStack(spacing: 40) {
+ // Icon / Hero
+ Image(systemName: "mic.circle.fill")
+ .font(.system(size: 100))
+ .foregroundStyle(Color.appRed)
+ .shadow(color: Color.appRed.opacity(0.5), radius: 20, x: 0, y: 0)
+
+ VStack(spacing: 16) {
+ Text("Welcome to SpeakType")
+ .font(.system(size: 42, weight: .bold, design: .rounded))
+ .foregroundStyle(.white)
+
+ Text("Experience the power of local AI transcription. Secure, fast, and completely offline.")
+ .font(.title3)
+ .multilineTextAlignment(.center)
+ .foregroundStyle(.gray)
+ .frame(maxWidth: 500)
+ }
+
+ VStack(spacing: 24) {
+ FeatureCard(icon: "lock.shield.fill", title: "Private by Design", description: "Your audio never leaves your device.")
+ FeatureCard(icon: "bolt.fill", title: "Lightning Fast", description: "Optimized for Apple Silicon.")
+ FeatureCard(icon: "keyboard.fill", title: "Global Injection", description: "Type with your voice anywhere.")
+ }
+
+ Button(action: action) {
+ Text("Get Started")
+ .font(.headline)
+ .fontWeight(.bold)
+ .foregroundStyle(.white)
+ .frame(width: 200, height: 50)
+ .background(Color.appRed)
+ .cornerRadius(25)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+}
+
+struct PermissionsPage: View {
+ var finishAction: () -> Void
+ @State private var micStatus: AVAuthorizationStatus = .notDetermined
+ @State private var accessibilityStatus: Bool = false
+ @State private var timer: Timer?
+
+ var body: some View {
+ VStack(spacing: 40) {
+ VStack(spacing: 10) {
+ Text("Permissions Setup")
+ .font(.system(size: 36, weight: .bold, design: .rounded))
+ .foregroundStyle(.white)
+ Text("SpeakType needs access to your microphone and accessibility features to function.")
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .padding(.top, 20)
+
+ VStack(spacing: 20) {
+ // Microphone
+ OnboardingPermissionRow(
+ icon: "mic.fill",
+ title: "Microphone Access",
+ description: "Required to record your voice.",
+ isGranted: micStatus == .authorized,
+ action: requestMicPermission
+ )
+
+ // Accessibility
+ OnboardingPermissionRow(
+ icon: "hand.raised.fill",
+ title: "Accessibility",
+ description: "Required to type text into other apps.",
+ isGranted: accessibilityStatus,
+ action: requestAccessibilityPermission
+ )
+ }
+ .frame(maxWidth: 600) // Readability constraint
+
+ Spacer()
+
+ Button(action: finishAction) {
+ HStack {
+ Text("Start Using SpeakType")
+ .font(.title3)
+ .fontWeight(.bold)
+ if micStatus == .authorized && accessibilityStatus {
+ Image(systemName: "checkmark")
+ }
+ }
+ .foregroundColor(.white)
+ .frame(width: 300, height: 60)
+ .background(
+ RoundedRectangle(cornerRadius: 30)
+ .fill((micStatus == .authorized && accessibilityStatus) ? Color.appRed : Color.gray.opacity(0.3))
+ .shadow(color: (micStatus == .authorized && accessibilityStatus) ? Color.appRed.opacity(0.4) : Color.clear, radius: 10, x: 0, y: 5)
+ )
+ .animation(.easeInOut, value: micStatus == .authorized && accessibilityStatus)
+ }
+ .buttonStyle(.plain)
+ .disabled(micStatus != .authorized || !accessibilityStatus)
+ .padding(.bottom, 40)
+ }
+ .onAppear {
+ checkPermissions()
+ startPolling()
+ }
+ .onDisappear {
+ timer?.invalidate()
+ }
+ }
+
+ // ... Copy-paste existing helpers (checkPermissions, request, polling) ...
+ // Note: Re-implementing them inline for the tool call
+
+ func startPolling() {
+ timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+ checkPermissions()
+ }
+ }
+
+ func checkPermissions() {
+ micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
+ accessibilityStatus = AXIsProcessTrusted()
+ }
+
+ func requestMicPermission() {
+ AVCaptureDevice.requestAccess(for: .audio) { _ in
+ DispatchQueue.main.async { checkPermissions() }
+ }
+ // Also open settings if denied?
+ if micStatus == .denied {
+ openSettings(for: "Privacy_Microphone")
+ }
+ }
+
+ func requestAccessibilityPermission() {
+ print("DEBUG: Requesting Accessibility Permission")
+ let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String : true]
+ let accessEnabled = AXIsProcessTrustedWithOptions(options)
+ accessibilityStatus = accessEnabled
+
+ if !accessEnabled {
+ print("DEBUG: Access not enabled, opening settings")
+ openSettings(for: "Privacy_Accessibility")
+ }
+ }
+
+ func openSettings(for pane: String) {
+ if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+}
+
+struct FeatureCard: View {
+ let icon: String
+ let title: String
+ let description: String
+
+ var body: some View {
+ VStack(spacing: 12) {
+ Image(systemName: icon)
+ .font(.title)
+ .foregroundStyle(Color.appRed)
+ .frame(width: 50, height: 50)
+ .background(Color.appRed.opacity(0.1))
+ .clipShape(Circle())
+
+ Text(title)
+ .font(.headline)
+ .foregroundStyle(.white)
+
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(.gray)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .frame(width: 140, height: 160)
+ .padding()
+ .background(Color.white.opacity(0.03))
+ .cornerRadius(20)
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(Color.white.opacity(0.05), lineWidth: 1)
+ )
+ }
+}
+
+struct OnboardingPermissionRow: View {
+ let icon: String
+ let title: String
+ let description: String
+ let isGranted: Bool
+ let action: () -> Void
+
+ var body: some View {
+ HStack {
+ Image(systemName: icon)
+ .font(.title2)
+ .foregroundStyle(Color.appRed)
+ .frame(width: 50, height: 50)
+ .background(Color.appRed.opacity(0.1))
+ .clipShape(Circle())
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.headline)
+ .foregroundStyle(.white)
+ Text(description)
+ .font(.subheadline)
+ .foregroundStyle(.gray)
+ }
+
+ Spacer()
+
+ if isGranted {
+ HStack {
+ Text("Granted")
+ .font(.subheadline)
+ .fontWeight(.semibold)
+ Image(systemName: "checkmark.circle.fill")
+ }
+ .foregroundStyle(.green)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(Color.green.opacity(0.1))
+ .clipShape(Capsule())
+ } else {
+ Button(action: action) {
+ Text("Allow Access")
+ .fontWeight(.semibold)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(Color.appRed)
+ .foregroundStyle(.white)
+ .clipShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(20)
+ .background(Color.white.opacity(0.05))
+ .cornerRadius(16)
+ .overlay(
+ RoundedRectangle(cornerRadius: 16)
+ .stroke(isGranted ? Color.green.opacity(0.3) : Color.white.opacity(0.1), lineWidth: 1)
+ )
+ }
+}
+
+#Preview {
+ OnboardingView()
+}
diff --git a/speaktype/Views/PermissionsView.swift b/speaktype/Views/PermissionsView.swift
new file mode 100644
index 0000000..197c55d
--- /dev/null
+++ b/speaktype/Views/PermissionsView.swift
@@ -0,0 +1,170 @@
+import SwiftUI
+import AVFoundation
+import KeyboardShortcuts
+
+struct PermissionsView: View {
+ @State private var micStatus: AVAuthorizationStatus = .notDetermined
+ @State private var accessibilityStatus: Bool = false
+ @State private var timer: Timer?
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 30) {
+ // Header
+ VStack(spacing: 15) {
+ Image(systemName: "shield.fill")
+ .font(.system(size: 40))
+ .foregroundStyle(Color.appRed)
+
+ Text("App Permissions")
+ .font(.largeTitle)
+ .fontWeight(.bold)
+ .foregroundStyle(.white)
+
+ Text("Manage permissions to ensure full functionality")
+ .foregroundStyle(.gray)
+ }
+ .padding(.top, 40)
+
+ // Permission Items
+ VStack(spacing: 16) {
+ // Microphone
+ PermissionRow(
+ icon: "mic.fill",
+ color: .green,
+ title: "Microphone Access",
+ desc: "Allow SpeakType to record your voice for transcription",
+ isGranted: micStatus == .authorized,
+ action: { openSettings(for: "Privacy_Microphone") }
+ )
+
+ // Accessibility
+ PermissionRow(
+ icon: "hand.raised.fill",
+ color: .green,
+ title: "Accessibility Access",
+ desc: "Allow SpeakType to paste transcribed text directly",
+ isGranted: accessibilityStatus,
+ action: {
+ ClipboardService.shared.requestAccessibilityPermission()
+ // Also open settings in case prompt doesn't appear (e.g. already denied)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ openSettings(for: "Privacy_Accessibility")
+ }
+ }
+ )
+
+ // Global Hotkey (New Customization Section)
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Global Shortcut")
+ .font(.headline)
+ .foregroundStyle(.white)
+
+ HStack {
+ Image(systemName: "keyboard")
+ .foregroundStyle(.orange)
+ .font(.title2)
+ .frame(width: 44, height: 44)
+ .background(Color.orange.opacity(0.1))
+ .clipShape(Circle())
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Toggle Recorder")
+ .font(.system(size: 16, weight: .medium))
+ .foregroundStyle(.white)
+ Text("Press to start/stop recording")
+ .font(.caption)
+ .foregroundStyle(.gray)
+ }
+
+ Spacer()
+
+ // The Recorder
+ KeyboardShortcuts.Recorder(for: .toggleRecord)
+ .padding(.vertical, 8)
+ }
+ .padding(16)
+ .background(Color.white.opacity(0.05))
+ .cornerRadius(12)
+ }
+ .padding(.top, 10)
+ }
+ .padding(.horizontal, 40)
+ }
+ .padding(.bottom, 40)
+ }
+ .background(Color.contentBackground)
+ .onAppear {
+ checkPermissions()
+ startPolling()
+ }
+ .onDisappear {
+ timer?.invalidate()
+ }
+ }
+
+ func startPolling() {
+ timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+ checkPermissions()
+ }
+ }
+
+ func checkPermissions() {
+ micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
+ accessibilityStatus = AXIsProcessTrusted()
+ }
+
+ func openSettings(for pane: String) {
+ if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+}
+
+struct PermissionRow: View {
+ let icon: String
+ let color: Color
+ let title: String
+ let desc: String
+ let isGranted: Bool
+ let action: () -> Void
+
+ var body: some View {
+ HStack(spacing: 20) {
+ ZStack {
+ Circle()
+ .fill(color.opacity(0.2))
+ .frame(width: 44, height: 44)
+ Image(systemName: icon)
+ .foregroundStyle(color)
+ .font(.title2)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.headline)
+ .foregroundStyle(.white)
+ Text(desc)
+ .font(.caption)
+ .foregroundStyle(.gray)
+ }
+
+ Spacer()
+
+ Button("Manage") {
+ action()
+ }
+ .buttonStyle(.bordered)
+ .tint(Color.gray)
+
+ if isGranted {
+ Image(systemName: "checkmark.seal.fill")
+ .foregroundStyle(.green)
+ .font(.title2)
+ }
+ }
+ .padding(16)
+ .background(Color.white.opacity(0.05))
+ .cornerRadius(12)
+ }
+}
diff --git a/speaktype/Views/SettingsView.swift b/speaktype/Views/SettingsView.swift
new file mode 100644
index 0000000..83ef439
--- /dev/null
+++ b/speaktype/Views/SettingsView.swift
@@ -0,0 +1,159 @@
+import SwiftUI
+import KeyboardShortcuts
+
+// import LaunchAtLogin // Uncomment when package added
+// import KeyboardShortcuts // Uncomment when package added
+
+struct SettingsView: View {
+ @State private var soundFeedback = true
+ @State private var muteSystemAudio = true
+ @State private var restoreClipboard = false
+ @State private var powerMode = false
+ @State private var experimentalFeatures = false
+ @State private var hideDockIcon = false
+ @State private var launchAtLogin = false // Will be replaced by LaunchAtLogin.isEnabled if package used
+ @State private var autoUpdate = true
+ @State private var showAnnouncements = true
+ @State private var customCancelShortcut = false
+ @State private var middleClickToggle = false
+ @State private var appleScriptPaste = false
+ @State private var recorderStyle: Int = 1 // 0: Notch, 1: Mini
+ @State private var hotkey1: String = "⌘ Space"
+
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 24) {
+ // Shortcuts Section
+ SettingsSection {
+ HStack {
+ Image(systemName: "command.circle")
+ .foregroundStyle(Color.appRed)
+ Text("SpeakType Shortcuts")
+ .font(.headline)
+ .foregroundStyle(.white)
+ Spacer()
+ }
+ .padding(.bottom, 8)
+
+ Text("Choose how you want to trigger SpeakType")
+ .font(.caption)
+ .foregroundStyle(.gray)
+
+ Divider().background(Color.gray.opacity(0.3))
+
+ HStack {
+ Text("Global Hotkey")
+ .foregroundStyle(.gray)
+ Spacer()
+ KeyboardShortcuts.Recorder(for: .toggleRecord)
+ }
+ .padding(.vertical, 8)
+
+ Text("Quick tap to start hands-free recording (tap again to stop). Press and hold for push-to-talk.")
+ .font(.caption2)
+ .foregroundStyle(.gray)
+ }
+
+ // General
+ SettingsSection {
+ HStack {
+ Image(systemName: "gear")
+ .foregroundStyle(Color.appRed)
+ VStack(alignment: .leading) {
+ Text("General")
+ .font(.headline)
+ .foregroundStyle(.white)
+ Text("Startup settings")
+ .font(.caption)
+ .foregroundStyle(.gray)
+ }
+ Spacer()
+ }
+
+ ToggleRow(title: "Launch at login", isOn: $launchAtLogin)
+ // Note: If using LaunchAtLogin package, replace the above line with:
+ // LaunchAtLogin.Toggle {
+ // Text("Launch at login")
+ // }
+ }
+ }
+ .padding()
+ }
+ .background(Color.contentBackground)
+ }
+}
+
+struct SettingsSection: View {
+ let content: Content
+
+ init(@ViewBuilder content: () -> Content) {
+ self.content = content()
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ content
+ }
+ .padding()
+ .background(Color.white.opacity(0.05))
+ .cornerRadius(12)
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(Color.white.opacity(0.1), lineWidth: 1)
+ )
+ }
+}
+
+struct SettingsRow: View {
+ let title: String
+ let value: String
+
+ var body: some View {
+ HStack {
+ Text(title)
+ .foregroundStyle(.gray)
+ Spacer()
+ Text(value)
+ .font(.caption)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Color.white.opacity(0.1))
+ .cornerRadius(4)
+ .foregroundStyle(.white)
+ }
+ }
+}
+
+struct ToggleRow: View {
+ let title: String
+ @Binding var isOn: Bool
+
+ var body: some View {
+ HStack {
+ Text(title)
+ .foregroundStyle(.white)
+ Spacer()
+ Toggle("", isOn: $isOn)
+ .labelsHidden()
+ }
+ }
+}
+
+struct RadioButton: View {
+ let title: String
+ let isSelected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 8) {
+ Image(systemName: isSelected ? "largecircle.fill.circle" : "circle")
+ .foregroundStyle(isSelected ? Color.appRed : .gray)
+ Text(title)
+ .foregroundStyle(.white)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/speaktype/Views/SidebarView.swift b/speaktype/Views/SidebarView.swift
new file mode 100644
index 0000000..b5bae31
--- /dev/null
+++ b/speaktype/Views/SidebarView.swift
@@ -0,0 +1,51 @@
+import SwiftUI
+
+enum SidebarItem: String, CaseIterable, Identifiable {
+ case dashboard = "Dashboard"
+ case transcribeAudio = "Transcribe Audio"
+ case history = "History"
+ case aiModels = "AI Models"
+ case permissions = "Permissions"
+ case audioInput = "Audio Input"
+ case settings = "Settings"
+
+ var id: String { rawValue }
+
+ var iconName: String {
+ switch self {
+ case .dashboard: return "gauge"
+ case .transcribeAudio: return "waveform"
+ case .history: return "doc.text"
+ case .aiModels: return "brain.head.profile"
+ case .permissions: return "shield"
+ case .audioInput: return "mic"
+ case .settings: return "gearshape"
+ }
+ }
+}
+
+struct SidebarView: View {
+ @Binding var selection: SidebarItem?
+
+ var body: some View {
+ List(selection: $selection) {
+ Section {
+ Label("SpeakType", systemImage: "mic.fill")
+ .font(.headline)
+ .foregroundStyle(.white)
+ .padding(.vertical, 10)
+ }
+
+ Section {
+ ForEach(SidebarItem.allCases) { item in
+ NavigationLink(value: item) {
+ Label(item.rawValue, systemImage: item.iconName)
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ }
+ .listStyle(.sidebar)
+ .preferredColorScheme(.dark)
+ }
+}
diff --git a/speaktype/Views/TranscribeAudioView.swift b/speaktype/Views/TranscribeAudioView.swift
new file mode 100644
index 0000000..66b63d0
--- /dev/null
+++ b/speaktype/Views/TranscribeAudioView.swift
@@ -0,0 +1,131 @@
+import SwiftUI
+import AVFoundation
+import CoreMedia
+
+struct TranscribeAudioView: View {
+ @StateObject private var audioRecorder = AudioRecordingService()
+ @State private var whisperService = WhisperService()
+ @State private var transcribedText: String = ""
+ @State private var isTranscribing = false
+
+ var body: some View {
+ VStack(spacing: 30) {
+ // Header removed for cleaner look
+ Spacer().frame(height: 20)
+
+ // Main Drop Zone / Action Area
+ ZStack {
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(style: StrokeStyle(lineWidth: 2, dash: [10]))
+ .foregroundStyle(Color.gray.opacity(0.3))
+ .frame(maxWidth: .infinity, maxHeight: 400)
+
+ VStack(spacing: 20) {
+ Image(systemName: "arrow.down.doc")
+ .font(.system(size: 50))
+ .foregroundStyle(.gray)
+
+ Text("Drop audio or video file here")
+ .font(.headline)
+ .foregroundStyle(.white)
+
+ Text("or")
+ .foregroundStyle(.gray)
+
+ if audioRecorder.isRecording {
+ Button(action: {
+ if let url = audioRecorder.stopRecording() {
+ startTranscription(url: url)
+ }
+ }) {
+ Text("Stop Recording")
+ .frame(minWidth: 120)
+ .padding()
+ .background(Color.appRed)
+ .foregroundStyle(.white)
+ .cornerRadius(8)
+ }
+ .buttonStyle(.plain)
+ } else if isTranscribing {
+ ProgressView("Transcribing...")
+ .tint(.white)
+ .foregroundStyle(.white)
+ } else {
+ Button(action: {
+ audioRecorder.startRecording()
+ }) {
+ Text("Start Recording")
+ .frame(minWidth: 120)
+ .padding()
+ .background(Color.gray.opacity(0.2))
+ .foregroundStyle(.white)
+ .cornerRadius(8)
+ }
+ .buttonStyle(.plain)
+ }
+
+ if !transcribedText.isEmpty {
+ ScrollView {
+ Text(transcribedText)
+ .padding()
+ .foregroundStyle(.white)
+ }
+ .frame(height: 100)
+ .background(Color.black.opacity(0.3))
+ .cornerRadius(8)
+
+ HStack {
+ Button("Copy Text") {
+ ClipboardService.shared.copy(text: transcribedText)
+ }
+ .buttonStyle(.plain)
+ .padding(8)
+ .background(Color.gray.opacity(0.2))
+ .cornerRadius(6)
+
+ Button("Paste (Simulate)") {
+ ClipboardService.shared.paste()
+ }
+ .buttonStyle(.plain)
+ .padding(8)
+ .background(Color.gray.opacity(0.2))
+ .cornerRadius(6)
+ }
+ }
+ }
+ }
+ .padding()
+
+ Spacer()
+ }
+ .background(Color.contentBackground)
+ .onAppear {
+ Task {
+ if !whisperService.isInitialized {
+ try? await whisperService.initialize()
+ }
+ }
+ }
+ }
+
+ private func startTranscription(url: URL) {
+ Task {
+ isTranscribing = true
+ do {
+ transcribedText = try await whisperService.transcribe(audioFile: url)
+ // Save to History
+ let duration = getAudioDuration(url: url)
+ HistoryService.shared.addItem(transcript: transcribedText, duration: duration)
+ } catch {
+ transcribedText = "Error: \(error.localizedDescription)"
+ }
+ isTranscribing = false
+ }
+ }
+
+ private func getAudioDuration(url: URL) -> TimeInterval {
+ // Simple duration check using AVURLAsset
+ let asset = AVURLAsset(url: url)
+ return CMTimeGetSeconds(asset.duration)
+ }
+}
diff --git a/speaktypeUITests/speaktypeUITests.swift b/speaktypeUITests/speaktypeUITests.swift
index 9513902..d6105ea 100644
--- a/speaktypeUITests/speaktypeUITests.swift
+++ b/speaktypeUITests/speaktypeUITests.swift
@@ -29,6 +29,8 @@ final class speaktypeUITests: XCTestCase {
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
+ let window = app.windows["SpeakType"]
+ XCTAssertTrue(window.exists, "The SpeakType window should exist")
}
@MainActor