diff --git a/Documentation/API.md b/Documentation/API.md new file mode 100644 index 0000000..b53940b --- /dev/null +++ b/Documentation/API.md @@ -0,0 +1,148 @@ +# FluidVoice Local API Documentation + +The FluidVoice Local API allows you to submit audio files for transcription, check their status, and retrieve results programmatically. The API runs locally on port `7086` by default. + +## Base URL +`http://127.0.0.1:7086` + +> **Note**: The API binds strictly to **IPv4 loopback** (`127.0.0.1`). It is **not** accessible via `::1` (IPv6) or other network interfaces. For best compatibility, use `127.0.0.1` instead of `localhost`. + +> **Port**: The default port is `7086`, but this can be changed in the app settings (File Transcription API > Port). + +## Endpoints + +### 1. List Available Models +Retrieves a list of available transcription models that are **downloaded and ready to use**. + +- **Endpoint**: `GET /models` +- **Response**: JSON object containing a list of model IDs. + +```json +{ + "models": ["whisper-base", "whisper-medium", "whisper-large-v3", ...] +} +``` + +--- + +### 2. Submit Transcription Job +Queues an audio file for transcription. + +- **Endpoint**: `POST /transcribe` +- **Content-Type**: `application/json` +- **Body Parameters**: + - `url` (string, required): The absolute URL of the local file to transcribe (e.g., `file:///Users/me/audio.wav`). + - `model` (string, optional): The ID of the model to use. If omitted, uses the application default. + +- **Response**: + - `200 OK`: Job accepted. + - `400 Bad Request`: Invalid JSON or missing URL. + +```json +{ + "id": "uuid-1234-5678", + "url": "file:///Users/me/audio.wav", + "status": "pending" +} +``` + +--- + +### 3. Check Job Status +Checks the status of a transcription job. + +- **Endpoint**: `GET /status` +- **Query Parameters**: + - `id` (string, optional): The UUID of the job (RECOMMENDED). Takes precedence over `url`. + - `url` (string, optional): The URL of the file. Legacy/fallback if `id` is not provided. + - `model` (string, optional): The model ID used. Required only if using `url` and multiple jobs exist for the same file. + +- **Response**: + - `200 OK`: Returns job details. + - `404 Not Found`: Job not found. + - `409 Conflict`: Multiple jobs found for this URL. Returns a list of choices to help you refine the request. + +**Success Response (200):** +```json +{ + "id": "UUID-STRING", + "status": "completed", // pending, processing, completed, failed + "created_at": 1700000000, + "model": "whisper-medium", + "processing_duration": 12.5, + "error": null +} +``` + +**Ambiguity Response (409):** +```json +{ + "error": "Ambiguous request...", + "choices": [ + {"id": "uuid-1", "model": "base", "status": "completed"}, + {"id": "uuid-2", "model": "medium", "status": "pending"} + ] +} +``` + +--- + +### 4. Get Transcription Result +Retrieves the transcribed text. + +- **Endpoint**: `GET /result` +- **Query Parameters**: + - `id` (string, optional): The UUID of the job (RECOMMENDED). + - `url` (string, optional): The URL of the file. + - `model` (string, optional): The model ID used. Required if using `url` and ambiguous. + - `format` (string, optional): Output format. `text` (default) or `vtt`. + +- **Response**: + - `200 OK`: Returns the transcription text (plain text or VTT). + - `400 Bad Request`: Job not completed or missing parameters. + - `404 Not Found`: Job not found. + - `409 Conflict`: Ambiguous request (see `/status`). + +--- + +### 5. List All Jobs +Returns a list of all current jobs in the backlog. + +- **Endpoint**: `GET /list` +- **Response**: JSON array of job summaries. + +```json +[ + { + "id": "uuid-1", + "url": "file:///path/to/a.wav", + "status": "completed", + "model": "base", + "processing_duration": 5.2 + }, + ... +] +``` + +--- + +### 6. Delete Job +Removes a job from the backlog. + +- **Endpoint**: `DELETE /backlog` +- **Query Parameters**: + - `id` (string, optional): The UUID of the job to delete (RECOMMENDED). + - `url` (string, optional): The URL of the file. + - `model` (string, optional): The model ID to delete. Required if using `url` and ambiguous. + +- **Response**: + - `200 OK`: Deleted. + - `404 Not Found`: Job not found. + - `409 Conflict`: Ambiguous request. + +## Error Handling + +- **400 Bad Request**: Missing parameters or invalid request format. +- **404 Not Found**: The specified job does not exist. +- **409 Conflict**: The request matched multiple jobs (same file, different models). precise the `model` parameter. +- **500 Internal Server Error**: Unexpected server error. diff --git a/Fluid.entitlements b/Fluid.entitlements index e0c7ade..24cbaa6 100644 --- a/Fluid.entitlements +++ b/Fluid.entitlements @@ -2,7 +2,9 @@ - com.apple.security.automation.apple-events + com.apple.security.network.client + + com.apple.security.network.server com.apple.security.temporary-exception.apple-events @@ -12,7 +14,9 @@ com.apple.loginitems - com.apple.security.cs.disable-library-validation - + com.apple.security.temporary-exception.files.absolute-path.read-only + + / + diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 8a85af7..8f18713 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; 7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; }; + 7CF7A1A530AA000100F00001 /* Sources/Fluid/Resources/FV_start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7CF7A1A730AA000100F00001 /* Sources/Fluid/Resources/FV_start.m4a */; }; + 95A3C0E92F31D9AE00829BE2 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = 95A3C0E82F31D9AE00829BE2 /* Swifter */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -36,11 +38,26 @@ 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 7CF7A1A730AA000100F00001 /* Sources/Fluid/Resources/FV_start.m4a */ = {isa = PBXFileReference; lastKnownFileType = audio.m4a; path = Sources/Fluid/Resources/FV_start.m4a; sourceTree = ""; }; + 7CF7A1A830AA000100F00001 /* Sources/Fluid/Resources/FV_start_2.m4a */ = {isa = PBXFileReference; lastKnownFileType = audio.m4a; path = Sources/Fluid/Resources/FV_start_2.m4a; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 95A3C0EA2F31DB0000829BE2 /* Exceptions for "Sources/Fluid" folder in "fluid" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Resources/FV_start.m4a, + ); + target = 7C078D8E2E3B339200FB7CAC /* fluid */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 7C078D912E3B339200FB7CAC /* Sources/Fluid */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 95A3C0EA2F31DB0000829BE2 /* Exceptions for "Sources/Fluid" folder in "fluid" target */, + ); path = Sources/Fluid; sourceTree = ""; }; @@ -51,6 +68,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 95A3C0E92F31D9AE00829BE2 /* Swifter in Frameworks */, 7C3697892ED70F9C005874CE /* DynamicNotchKit in Frameworks */, 7C5AF14B2F15041600DE21B0 /* MediaRemoteAdapter in Frameworks */, 7C3236542EAAD608007E4CB6 /* MCP in Frameworks */, @@ -77,6 +95,7 @@ 7C078D912E3B339200FB7CAC /* Sources/Fluid */, 7CDB0A242F3C4D5600FB7CAD /* Tests */, 7CDB0A282F3C4D5600FB7CAD /* Frameworks */, + 95A3C0162F31D48400829BE2 /* Recovered References */, ); sourceTree = ""; }; @@ -131,6 +150,15 @@ name = Frameworks; sourceTree = ""; }; + 95A3C0162F31D48400829BE2 /* Recovered References */ = { + isa = PBXGroup; + children = ( + 7CF7A1A730AA000100F00001 /* Sources/Fluid/Resources/FV_start.m4a */, + 7CF7A1A830AA000100F00001 /* Sources/Fluid/Resources/FV_start_2.m4a */, + ); + name = "Recovered References"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -157,6 +185,7 @@ 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */, 7C1C72F12EECBD1300E3BF4D /* SwiftWhisper */, 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */, + 95A3C0E82F31D9AE00829BE2 /* Swifter */, ); productName = FluidVoice; productReference = 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */; @@ -188,7 +217,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2600; - LastUpgradeCheck = 2600; + LastUpgradeCheck = 2620; TargetAttributes = { 7C078D8E2E3B339200FB7CAC = { CreatedOnToolsVersion = 26.0; @@ -214,6 +243,9 @@ 7C3697872ED70F9C005874CE /* XCRemoteSwiftPackageReference "DynamicNotchKit" */, 7C1C72F02EECBD1300E3BF4D /* XCRemoteSwiftPackageReference "SwiftWhisper" */, 7C5AF1492F15041600DE21B0 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */, + 95A3C0E52F31D77000829BE2 /* XCRemoteSwiftPackageReference "swift-algorithms" */, + 95A3C0E62F31D7A300829BE2 /* XCRemoteSwiftPackageReference "swifter" */, + 95A3C0E72F31D81400829BE2 /* XCRemoteSwiftPackageReference "swift-argument-parser" */, ); preferredProjectObjectVersion = 77; productRefGroup = 7C078D902E3B339200FB7CAC /* Products */; @@ -231,6 +263,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7CF7A1A530AA000100F00001 /* Sources/Fluid/Resources/FV_start.m4a in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -307,7 +340,10 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEPLOYMENT_LOCATION = YES; DEVELOPMENT_TEAM = V4J43B279J; ENABLE_CODE_COVERAGE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -327,12 +363,19 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + GENERATE_PKGINFO_FILE = YES; + "GENERATE_PKGINFO_FILE[sdk=macosx*]" = YES; + "INFOPLIST_FILE[sdk=*]" = ""; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + "MACH_O_TYPE[sdk=macosx*]" = mh_execute; MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -373,7 +416,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEPLOYMENT_LOCATION = YES; + "DEPLOYMENT_LOCATION[sdk=*]" = YES; DEVELOPMENT_TEAM = V4J43B279J; ENABLE_CODE_COVERAGE = NO; ENABLE_NS_ASSERTIONS = NO; @@ -387,12 +434,19 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + GENERATE_PKGINFO_FILE = YES; + "GENERATE_PKGINFO_FILE[sdk=macosx*]" = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + "INSTALL_PATH[sdk=*]" = ""; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + "MACH_O_TYPE[sdk=macosx*]" = mh_execute; MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; @@ -403,13 +457,15 @@ ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + AUTOMATION_APPLE_EVENTS = YES; CODE_SIGN_ENTITLEMENTS = Fluid.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = V4J43B279J; - ENABLE_APP_SANDBOX = NO; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 4JSY8RYCTF; + ENABLE_APP_SANDBOX = YES; ENABLE_CODE_COVERAGE = NO; ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; @@ -440,6 +496,7 @@ PRODUCT_NAME = "FluidVoice Debug"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ARCH_ARM64"; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -457,13 +514,15 @@ ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + AUTOMATION_APPLE_EVENTS = YES; CODE_SIGN_ENTITLEMENTS = Fluid.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = V4J43B279J; - ENABLE_APP_SANDBOX = NO; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 4JSY8RYCTF; + ENABLE_APP_SANDBOX = YES; ENABLE_CODE_COVERAGE = NO; ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; @@ -494,6 +553,7 @@ PRODUCT_NAME = FluidVoice; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ARCH_ARM64"; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -514,13 +574,15 @@ CLANG_ENABLE_MODULES = YES; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = V4J43B279J; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 4JSY8RYCTF; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = FluidDictationIntegrationTests; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MACH_O_TYPE = mh_bundle; MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_BUNDLE_IDENTIFIER = com.FluidApp.FluidDictationIntegrationTests; PRODUCT_NAME = FluidDictationIntegrationTests; @@ -539,13 +601,15 @@ CLANG_ENABLE_MODULES = YES; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = V4J43B279J; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 4JSY8RYCTF; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = FluidDictationIntegrationTests; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MACH_O_TYPE = mh_bundle; MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_BUNDLE_IDENTIFIER = com.FluidApp.FluidDictationIntegrationTests; PRODUCT_NAME = FluidDictationIntegrationTests; @@ -623,8 +687,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ejbills/mediaremote-adapter"; requirement = { - kind = branch; branch = master; + kind = branch; }; }; 7CE006BB2E80EBE600DDCCD6 /* XCRemoteSwiftPackageReference "AppUpdater" */ = { @@ -635,6 +699,30 @@ minimumVersion = 1.1.1; }; }; + 95A3C0E52F31D77000829BE2 /* XCRemoteSwiftPackageReference "swift-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-algorithms.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.1; + }; + }; + 95A3C0E62F31D7A300829BE2 /* XCRemoteSwiftPackageReference "swifter" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/httpswift/swifter.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.5.0; + }; + }; + 95A3C0E72F31D81400829BE2 /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-argument-parser.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.7.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -668,6 +756,11 @@ package = 7CE006BB2E80EBE600DDCCD6 /* XCRemoteSwiftPackageReference "AppUpdater" */; productName = AppUpdater; }; + 95A3C0E82F31D9AE00829BE2 /* Swifter */ = { + isa = XCSwiftPackageProductDependency; + package = 95A3C0E62F31D7A300829BE2 /* XCRemoteSwiftPackageReference "swifter" */; + productName = Swifter; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 7C078D872E3B339200FB7CAC /* Project object */; diff --git a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5851468..74f602b 100644 --- a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f521508b82d28f1791de94693be786cfcf7bd2e0f4ca46267c31e159f5bf10f3", + "originHash" : "c6729241885f4d1bb856fa4639ad35b52386520e8afbdadff060bb9c762d4015", "pins" : [ { "identity" : "appupdater", @@ -73,6 +73,24 @@ "version" : "6.22.1" } }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -100,6 +118,15 @@ "version" : "1.6.4" } }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, { "identity" : "swift-sdk", "kind" : "remoteSourceControl", @@ -127,6 +154,15 @@ "version" : "1.1.6" } }, + { + "identity" : "swifter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/httpswift/swifter.git", + "state" : { + "revision" : "9483a5d459b45c3ffd059f7b55f9638e268632fd", + "version" : "1.5.0" + } + }, { "identity" : "swiftwhisper", "kind" : "remoteSourceControl", diff --git a/Fluid.xcodeproj/xcshareddata/xcschemes/Fluid.xcscheme b/Fluid.xcodeproj/xcshareddata/xcschemes/Fluid.xcscheme index be14e83..08a2541 100644 --- a/Fluid.xcodeproj/xcshareddata/xcschemes/Fluid.xcscheme +++ b/Fluid.xcodeproj/xcshareddata/xcschemes/Fluid.xcscheme @@ -1,6 +1,6 @@ CFBundleVersion 8 CFBundleShortVersionString - 1.5.8 + 1.6.0 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) LSApplicationCategoryType diff --git a/Package.swift b/Package.swift index d9b86ef..58ca538 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,7 @@ let package = Package( .package(url: "https://github.com/MrKai77/DynamicNotchKit", from: "1.0.0"), .package(url: "https://github.com/exPHAT/SwiftWhisper.git", branch: "master"), .package(url: "https://github.com/PostHog/posthog-ios.git", from: "3.0.0"), + .package(url: "https://github.com/httpswift/swifter.git", from: "1.5.0"), ], targets: [ .executableTarget( @@ -26,6 +27,7 @@ let package = Package( "DynamicNotchKit", "SwiftWhisper", .product(name: "PostHog", package: "posthog-ios"), + .product(name: "Swifter", package: "swifter"), ] ), ] diff --git a/README.md b/README.md index cfc777b..1c04a4e 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,12 @@ https://github.com/user-attachments/assets/c57ef6d5-f0a1-4a3f-a121-637533442c24 - **Smart typing** directly into any app - **Menu bar integration** for quick access - **Auto-updates** with seamless restart +- **Local API**: Automate transcriptions via HTTP requests + +## New Features (v1.6) +- **Local Transcription API**: Full HTTP API for automating file transcriptions. +- **Backlog Management**: View, manage, and clear transcription history directly in the app. +- **VTT Output**: Export transcriptions as WebVTT subtitles. ## Supported Models @@ -93,6 +99,13 @@ Universal support (runs on Intel & Apple Silicon). Supports 99 languages. https://discord.gg/VUPHaKSvYV +https://discord.gg/VUPHaKSvYV + +## API Documentation + +FluidVoice includes a fully functional local API for automation. +👉 [Read the API Documentation](Documentation/API.md) + ## Building from Source ```bash diff --git a/Sources/Fluid/AppDelegate.swift b/Sources/Fluid/AppDelegate.swift index 24bba4c..71a6887 100644 --- a/Sources/Fluid/AppDelegate.swift +++ b/Sources/Fluid/AppDelegate.swift @@ -248,33 +248,55 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func requestAccessibilityPermissions() { + print("[System Log] Requesting accessibility permissions...") // Never show if already trusted - guard !AXIsProcessTrusted() else { return } + let isTrusted = AXIsProcessTrusted() + print("[System Log] Currently trusted: \(isTrusted)") + + guard !isTrusted else { + print("[System Log] Already trusted, skipping prompt.") + return + } // Per-session debounce - if AXPromptState.hasPromptedThisSession { return } + if AXPromptState.hasPromptedThisSession { + print("[System Log] Already prompted this session, skipping.") + return + } // Cooldown: avoid re-prompting too often across launches let cooldownKey = "AXLastPromptAt" let now = Date().timeIntervalSince1970 let last = UserDefaults.standard.double(forKey: cooldownKey) let oneDay: Double = 24 * 60 * 60 + + // Log the cooldown state if last > 0, (now - last) < oneDay { + print("[System Log] Prompt is on cooldown (last: \(Date(timeIntervalSince1970: last))).") + DebugLogger.shared.info("Accessibility prompt on cooldown (last: \(Date(timeIntervalSince1970: last)))", source: "AppDelegate") + // For debugging, we might want to comment this out, but let's just log it first. + // If the user says "No log entry", it means this return hit silently. + // Now they will see it. return } DebugLogger.shared.warning("Accessibility permissions required for global hotkeys.", source: "AppDelegate") DebugLogger.shared.info("Prompting for Accessibility permission…", source: "AppDelegate") + print("[System Log] Calling AXIsProcessTrustedWithOptions...") let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary - AXIsProcessTrustedWithOptions(options) + let result = AXIsProcessTrustedWithOptions(options) + print("[System Log] AXIsProcessTrustedWithOptions returned: \(result)") AXPromptState.hasPromptedThisSession = true UserDefaults.standard.set(now, forKey: cooldownKey) // If still not trusted shortly after, deep-link to the Accessibility pane for convenience DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { - guard !AXIsProcessTrusted(), + let trustedAfter = AXIsProcessTrusted() + print("[System Log] Check after prompt: \(trustedAfter)") + + guard !trustedAfter, let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else { return } NSWorkspace.shared.open(url) diff --git a/Sources/Fluid/Assets.xcassets/Provider_Anthropic.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Anthropic.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_Anthropic.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_Anthropic.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/Assets.xcassets/Provider_AppleIntelligence.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_AppleIntelligence.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_AppleIntelligence.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_AppleIntelligence.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/Assets.xcassets/Provider_Cerebras.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Cerebras.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_Cerebras.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_Cerebras.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/Assets.xcassets/Provider_Compatible.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Compatible.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_Compatible.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_Compatible.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/Assets.xcassets/Provider_Fluid1.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Fluid1.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_Fluid1.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_Fluid1.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/Assets.xcassets/Provider_Gemini.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Gemini.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_Gemini.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_Gemini.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/Assets.xcassets/Provider_Groq.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Groq.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_Groq.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_Groq.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/Assets.xcassets/Provider_LMStudio.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_LMStudio.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_LMStudio.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_LMStudio.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/Assets.xcassets/Provider_Ollama.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_Ollama.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_Ollama.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_Ollama.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/Assets.xcassets/Provider_OpenAI.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_OpenAI.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_OpenAI.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_OpenAI.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/Assets.xcassets/Provider_OpenRouter.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_OpenRouter.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_OpenRouter.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_OpenRouter.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/Assets.xcassets/Provider_xAI.imageset/Contents.json b/Sources/Fluid/Assets.xcassets/Provider_xAI.imageset/Contents.json index 1c4a4c4..c1fd129 100644 --- a/Sources/Fluid/Assets.xcassets/Provider_xAI.imageset/Contents.json +++ b/Sources/Fluid/Assets.xcassets/Provider_xAI.imageset/Contents.json @@ -1,7 +1,22 @@ { "images" : [ - { "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, - { "filename" : "logo@2x.png", "idiom" : "universal", "scale" : "2x" } + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } ], - "info" : { "author" : "xcode", "version" : 1 } + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 6a74413..090eb39 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -206,6 +206,8 @@ struct ContentView: View { // Now it's safe to access services (they'll be lazily created) self.audioObserver.startObserving() self.asr.initialize() + _ = self.appServices.backlogManager // Trigger lazy initialization coverage + _ = self.appServices.apiService // Trigger lazy initialization and server start // Configure menu bar manager with ASR service AFTER services are initialized self.menuBarManager.configure(asrService: self.appServices.asr) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 805adb8..f6ac480 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -87,6 +87,11 @@ final class SettingsStore: ObservableObject { static let fillerWords = "FillerWords" static let removeFillerWordsEnabled = "RemoveFillerWordsEnabled" + // API Settings + static let enableAPI = "EnableAPI" + static let apiPort = "APIPort" + static let apiBacklogLimit = "APIBacklogLimit" + // GAAV Mode (removes capitalization and trailing punctuation) static let gaavModeEnabled = "GAAVModeEnabled" @@ -747,6 +752,46 @@ final class SettingsStore: ObservableObject { } } + // MARK: - API Settings + + var enableAPI: Bool { + get { + let value = self.defaults.object(forKey: Keys.enableAPI) + if value == nil { return true } + return self.defaults.bool(forKey: Keys.enableAPI) + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.enableAPI) + // Post notification for APIService to observe + NotificationCenter.default.post(name: NSNotification.Name("EnableAPIChanged"), object: nil) + } + } + + var apiPort: UInt16 { + get { + let value = self.defaults.integer(forKey: Keys.apiPort) + return value == 0 ? 7086 : UInt16(value) + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.apiPort) + // Post notification for APIService to observe (port change requires restart) + NotificationCenter.default.post(name: NSNotification.Name("EnableAPIChanged"), object: nil) + } + } + + var apiBacklogLimit: Int { + get { + let value = self.defaults.integer(forKey: Keys.apiBacklogLimit) + return value == 0 ? 50 : value // Default to 50 items + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.apiBacklogLimit) + } + } + var availableModels: [String] { get { (self.defaults.array(forKey: Keys.availableAIModels) as? [String]) ?? [] } set { diff --git a/Sources/Fluid/Services/APIService.swift b/Sources/Fluid/Services/APIService.swift new file mode 100644 index 0000000..8adfabc --- /dev/null +++ b/Sources/Fluid/Services/APIService.swift @@ -0,0 +1,399 @@ +import Combine +import Foundation +import Swifter + +@MainActor +final class APIService: ObservableObject { + static let shared = APIService() + + private let server = HttpServer() + private var port: UInt16 = 7086 + private var isStarted = false + + /// Helper to execute MainActor code synchronously from a background thread + private func runOnMain(_ block: @MainActor @escaping () -> T) -> T? { + var result: T? + let semaphore = DispatchSemaphore(value: 0) + + Task { @MainActor in + result = block() + semaphore.signal() + } + + let timeout = semaphore.wait(timeout: .now() + 10.0) + if timeout == .timedOut { + DebugLogger.shared.error("runOnMain timed out", source: "APIService") + return nil + } + return result + } + + /// Helper for Void returns + private func runOnMainVoid(_ block: @MainActor @escaping () -> Void) { + let semaphore = DispatchSemaphore(value: 0) + + Task { @MainActor in + block() + semaphore.signal() + } + + _ = semaphore.wait(timeout: .now() + 10.0) + } + + private init() { + self.setupRoutes() + + // Observe settings change + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleSettingsChange), + name: NSNotification.Name("EnableAPIChanged"), + object: nil + ) + } + + @objc private func handleSettingsChange() { + if SettingsStore.shared.enableAPI { + self.start() + } else { + self.stop() + } + } + + func initialize() { + self.port = SettingsStore.shared.apiPort + if SettingsStore.shared.enableAPI { + self.start() + } + } + + private func start() { + guard !self.isStarted else { return } + do { + self.server.listenAddressIPv4 = "127.0.0.1" + try self.server.start(self.port, forceIPv4: true) + self.isStarted = true + DebugLogger.shared.info("APIService started on 127.0.0.1:\(self.port)", source: "APIService") + } catch { + DebugLogger.shared.error("Failed to start APIService: \(error)", source: "APIService") + } + } + + private func stop() { + guard self.isStarted else { return } + self.server.stop() + self.isStarted = false + DebugLogger.shared.info("APIService stopped", source: "APIService") + } + + private func setupRoutes() { + // middleware to log requests + self.server.middleware.append { _ in + // Logging on background thread is fine if logger is thread safe + // We'll skip jumping to main for logging + nil + } + + // POST /transcribe + self.server.POST["/transcribe"] = { [weak self] request in + guard let self else { return .internalServerError } + + // Allow JSON body + let data = Data(request.body) + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let urlString = json["url"] as? String, + let url = URL(string: urlString) + else { + return .badRequest(.text("Invalid JSON or missing 'url' field")) + } + + let modelId = json["model"] as? String + + // Fire and forget (or wait if we want to confirm it's queued) + // We'll wait to ensure it's in the queue before returning + let job = self.runOnMain { + BacklogManager.shared.addJob(url: url, modelId: modelId) + } + + guard let job else { return .internalServerError } + + return .ok(.json(["id": job.id, "url": url.absoluteString, "status": "pending"])) + } + + // GET /status + self.server.GET["/status"] = { [weak self] request in + guard let self else { return .internalServerError } + + // 1. Try ID-based lookup first + if let id = request.queryParams.first(where: { $0.0 == "id" })?.1 { + let result = self.runOnMain { + guard let job = BacklogManager.shared.getJob(id: id) else { + return HttpResponse.notFound + } + + var response: [String: Any] = [ + "id": job.id, + "status": job.status.rawValue, + "created_at": job.createdAt.timeIntervalSince1970, + ] + + if let model = job.modelId { + response["model"] = model + } + + if let duration = job.processingDuration { + response["processing_duration"] = duration + } + + if let error = job.error { + response["error"] = error + } + + // Also include the URL for reference + response["url"] = job.fileURL.absoluteString + + return .ok(.json(response)) + } + return result ?? .internalServerError + } + + // 2. Fallback to URL-based lookup + guard let urlString = request.queryParams.first(where: { $0.0 == "url" })?.1, + let url = URL(string: urlString) + else { + return .badRequest(.text("Missing 'id' or 'url' query parameter")) + } + + let modelId = request.queryParams.first(where: { $0.0 == "model" })?.1 + + let result = self.runOnMain { + let jobs = BacklogManager.shared.getJobs(for: url) + var matches = jobs + + if let model = modelId { + matches = matches.filter { $0.modelId == model } + } else if jobs.count > 1 { + // Ambiguous request (multiple jobs exist but no model specified) + let choices = jobs.map { [ + "id": $0.id, + "model": $0.modelId ?? "default", + "status": $0.status.rawValue, + ] } + return HttpResponse.raw(409, "Conflict", ["Content-Type": "application/json"]) { writer in + let json = ["error": "Ambiguous request. Multiple transcriptions found for this URL. Please specify a 'model' parameter.", "choices": choices] as [String: Any] + try? writer.write(JSONSerialization.data(withJSONObject: json)) + } + } + + guard let job = matches.first else { + return .notFound + } + + var response: [String: Any] = [ + "id": job.id, + "status": job.status.rawValue, + "created_at": job.createdAt.timeIntervalSince1970, + ] + + if let model = job.modelId { + response["model"] = model + } + + if let duration = job.processingDuration { + response["processing_duration"] = duration + } + + if let error = job.error { + response["error"] = error + } + + return .ok(.json(response)) + } + return result ?? .internalServerError + } + + // GET /result + self.server.GET["/result"] = { [weak self] request in + guard let self else { return .internalServerError } + + let format = request.queryParams.first(where: { $0.0 == "format" })?.1 ?? "text" + + // 1. Try based on ID + if let id = request.queryParams.first(where: { $0.0 == "id" })?.1 { + let result = self.runOnMain { + guard let job = BacklogManager.shared.getJob(id: id) else { + return HttpResponse.notFound + } + + if job.status != .completed { + return .badRequest(.text("Job not completed")) + } + + guard let text = job.resultText else { + return .internalServerError + } + + if format == "vtt" { + let vtt = """ + WEBVTT + + 00:00:00.000 --> 00:00:10.000 + \(text) + """ + return .ok(.text(vtt)) + } else { + return .ok(.text(text)) + } + } + return result ?? .internalServerError + } + + // 2. Fallback to URL + guard let urlString = request.queryParams.first(where: { $0.0 == "url" })?.1, + let url = URL(string: urlString) + else { + return .badRequest(.text("Missing 'id' or 'url' query parameter")) + } + + let modelId = request.queryParams.first(where: { $0.0 == "model" })?.1 + + let result = self.runOnMain { + let jobs = BacklogManager.shared.getJobs(for: url) + var matches = jobs + + if let model = modelId { + matches = matches.filter { $0.modelId == model } + } else if jobs.count > 1 { + let choices = jobs.map { [ + "id": $0.id, + "model": $0.modelId ?? "default", + "status": $0.status.rawValue, + ] } + return HttpResponse.raw(409, "Conflict", ["Content-Type": "application/json"]) { writer in + let json = ["error": "Ambiguous request. Multiple transcriptions found for this URL. Please specify a 'model' parameter.", "choices": choices] as [String: Any] + try? writer.write(JSONSerialization.data(withJSONObject: json)) + } + } + + guard let job = matches.first else { + return .notFound + } + + if job.status != .completed { + return .badRequest(.text("Job not completed")) + } + + guard let text = job.resultText else { + return .internalServerError + } + + if format == "vtt" { + let vtt = """ + WEBVTT + + 00:00:00.000 --> 00:00:10.000 + \(text) + """ + return .ok(.text(vtt)) + } else { + return .ok(.text(text)) + } + } + return result ?? .internalServerError + } + + // DELETE /backlog + self.server.DELETE["/backlog"] = { [weak self] request in + guard let self else { return .internalServerError } + + // 1. Try ID + if let id = request.queryParams.first(where: { $0.0 == "id" })?.1 { + let result = self.runOnMain { + guard BacklogManager.shared.getJob(id: id) != nil else { + return HttpResponse.notFound + } + BacklogManager.shared.deleteJob(id: id) + return .ok(.text("Deleted")) + } + return result ?? .internalServerError + } + + // 2. Fallback to URL + guard let urlString = request.queryParams.first(where: { $0.0 == "url" })?.1, + let url = URL(string: urlString) + else { + return .badRequest(.text("Missing 'id' or 'url' query parameter")) + } + + let modelId = request.queryParams.first(where: { $0.0 == "model" })?.1 + + let result = self.runOnMain { + let jobs = BacklogManager.shared.getJobs(for: url) + var matches = jobs + + if let model = modelId { + matches = matches.filter { $0.modelId == model } + } else if jobs.count > 1 { + let choices = jobs.map { [ + "id": $0.id, + "model": $0.modelId ?? "default", + "status": $0.status.rawValue, + ] } + return HttpResponse.raw(409, "Conflict", ["Content-Type": "application/json"]) { writer in + let json = ["error": "Ambiguous request. Multiple transcriptions found for this URL. Please specify a 'model' parameter.", "choices": choices] as [String: Any] + try? writer.write(JSONSerialization.data(withJSONObject: json)) + } + } + + guard let job = matches.first else { + return .notFound + } + + BacklogManager.shared.deleteJob(id: job.id) + return .ok(.text("Deleted")) + } + return result ?? .internalServerError + } + + // GET /list + self.server.GET["/list"] = { [weak self] _ in + guard let self else { return .internalServerError } + + let jobs = self.runOnMain { BacklogManager.shared.jobs } ?? [] + let list = jobs.map { job -> [String: Any] in + var dict: [String: Any] = [ + "id": job.id, + "status": job.status.rawValue, + "url": job.fileURL.absoluteString, + ] + if let model = job.modelId { + dict["model"] = model + } + if let duration = job.processingDuration { + dict["processing_duration"] = duration + } + if let error = job.error { + dict["error"] = error + } + return dict + } + + return .ok(.json(list)) + } + + // GET /models + self.server.GET["/models"] = { [weak self] _ in + guard let self else { return .internalServerError } + + let models = self.runOnMain { + // Return available transcription models + SettingsStore.SpeechModel.availableModels + .filter { $0.isInstalled } + .map(\.id) + .sorted() + } ?? [] + + return .ok(.json(["models": models])) + } + } +} diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 81b72c2..c1ad015 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -234,7 +234,7 @@ final class ASRService: ObservableObject { /// Gets a provider for a specific model (without changing the active selection) /// Used for downloading models without switching the active model. - private func getProvider(for model: SettingsStore.SpeechModel) -> TranscriptionProvider { + func getProvider(for model: SettingsStore.SpeechModel) -> TranscriptionProvider { switch model { case .appleSpeechAnalyzer: if #available(macOS 26.0, *) { diff --git a/Sources/Fluid/Services/AppServices.swift b/Sources/Fluid/Services/AppServices.swift index 0c73e02..c9c55c8 100644 --- a/Sources/Fluid/Services/AppServices.swift +++ b/Sources/Fluid/Services/AppServices.swift @@ -68,6 +68,35 @@ final class AppServices: ObservableObject { return service } + /// Meeting Transcription Service (lazily initialized) + private var _meetingTranscriptionService: MeetingTranscriptionService? + var meetingTranscriptionService: MeetingTranscriptionService { + if let existing = self._meetingTranscriptionService { + return existing + } + DebugLogger.shared.info("📝 Lazily creating MeetingTranscriptionService", source: "AppServices") + let service = MeetingTranscriptionService(asrService: self.asr) + self._meetingTranscriptionService = service + return service + } + + /// Backlog manager for file transcription (lazily initialized) + var backlogManager: BacklogManager { + let manager = BacklogManager.shared + // Ensure it's configured with a transcription service (singleton configuration) + // We check if it's already configured inside, or just re-configure harmlessly + // efficiently we just pass our stable reference + manager.configure(with: self.meetingTranscriptionService) + return manager + } + + /// API Service (lazily initialized) + var apiService: APIService { + let service = APIService.shared + service.initialize() + return service + } + private var cancellables = Set() private init() { @@ -110,6 +139,8 @@ final class AppServices: ObservableObject { // Access the properties to trigger lazy initialization _ = self.audioObserver _ = self.asr + _ = self.backlogManager + _ = self.apiService DebugLogger.shared.info("✅ All services initialized", source: "AppServices") } diff --git a/Sources/Fluid/Services/BacklogManager.swift b/Sources/Fluid/Services/BacklogManager.swift new file mode 100644 index 0000000..9ae040d --- /dev/null +++ b/Sources/Fluid/Services/BacklogManager.swift @@ -0,0 +1,286 @@ +import Foundation +import Combine +import FluidAudio + +/// Represents a file transcription job +struct TranscriptionJob: Identifiable, Codable, Equatable { + var id: String = UUID().uuidString + let fileURL: URL + let createdAt: Date + var status: JobStatus + var modelId: String? // User-specified model ID + var resultText: String? + var error: String? + var processingDuration: TimeInterval? + + enum JobStatus: String, Codable, Equatable { + case pending + case processing + case completed + case failed + } + + init(fileURL: URL, modelId: String? = nil) { + self.fileURL = fileURL + self.createdAt = Date() + self.status = .pending + self.modelId = modelId + } + + // Migration for old data matching + private enum CodingKeys: String, CodingKey { + case id, fileURL, createdAt, status, modelId, resultText, error, processingDuration + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.fileURL = try container.decode(URL.self, forKey: .fileURL) + self.createdAt = try container.decode(Date.self, forKey: .createdAt) + self.status = try container.decode(JobStatus.self, forKey: .status) + self.modelId = try container.decodeIfPresent(String.self, forKey: .modelId) + self.resultText = try container.decodeIfPresent(String.self, forKey: .resultText) + self.error = try container.decodeIfPresent(String.self, forKey: .error) + self.processingDuration = try container.decodeIfPresent(TimeInterval.self, forKey: .processingDuration) + + // Use existing ID or generate new one for legacy entries + self.id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(fileURL, forKey: .fileURL) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(status, forKey: .status) + try container.encodeIfPresent(modelId, forKey: .modelId) + try container.encodeIfPresent(resultText, forKey: .resultText) + try container.encodeIfPresent(error, forKey: .error) + try container.encodeIfPresent(processingDuration, forKey: .processingDuration) + } +} + +@MainActor +final class BacklogManager: ObservableObject { + static let shared = BacklogManager() + + @Published private(set) var jobs: [TranscriptionJob] = [] + @Published var isProcessing: Bool = false + + private let defaults = UserDefaults.standard + private let storageKey = "TranscriptionBacklog" + + // Dependencies + private var transcriptionService: MeetingTranscriptionService? + + private init() { + loadJobs() + } + + func configure(with service: MeetingTranscriptionService) { + self.transcriptionService = service + self.processNextJob() // Check if we should start processing + } + + // MARK: - Job Management + + @discardableResult + func addJob(url: URL, modelId: String?) -> TranscriptionJob { + // Check for existing job with same URL AND same model + // If modelId is nil, we treat it as "default/unspecified". + // We only block if there's an exact match on (URL, modelId). + + if let existing = jobs.first(where: { job in + job.fileURL == url && job.modelId == modelId + }) { + DebugLogger.shared.info("Skipping duplicate job for \(url.lastPathComponent) (model: \(modelId ?? "nil"))", source: "BacklogManager") + return existing + } + + let job = TranscriptionJob(fileURL: url, modelId: modelId) + jobs.append(job) + saveJobs() + + DebugLogger.shared.info("Added transcription job: \(url.lastPathComponent) (model: \(modelId ?? "default"))", source: "BacklogManager") + processNextJob() + return job + } + + /// Returns all jobs matching the given URL. + func getJobs(for url: URL) -> [TranscriptionJob] { + return jobs.filter { $0.fileURL == url } + } + + /// Returns a specific job by ID. + func getJob(id: String) -> TranscriptionJob? { + return jobs.first(where: { $0.id == id }) + } + + // Helper for legacy lookup (returns first match if multiple, but ideally shouldn't be used for strict logic) + func getJob(url: URL) -> TranscriptionJob? { + jobs.first(where: { $0.fileURL == url }) + } + + func deleteJob(id: String) { + jobs.removeAll(where: { $0.id == id }) + saveJobs() + DebugLogger.shared.info("Deleted transcription job: \(id)", source: "BacklogManager") + } + + func deleteJobs(url: URL) { + jobs.removeAll(where: { $0.fileURL == url }) + saveJobs() + DebugLogger.shared.info("Deleted all transcription jobs for: \(url.lastPathComponent)", source: "BacklogManager") + } + + func clearCompleted() { + jobs.removeAll(where: { $0.status == .completed || $0.status == .failed }) + saveJobs() + } + + // MARK: - Processing + + private func processNextJob() { + guard !isProcessing, let service = transcriptionService else { return } + + // Find next pending job + guard let index = jobs.firstIndex(where: { $0.status == .pending }) else { return } + + // Check for max items limit (FIFO: If we exceed limit, we might want to prune completed/failed logic, but for now we just process pending) + // User asked for "setting for a number of items to maintain in backlog". + // We should enforce that on add or periodically. Let's enforce on complete. + + isProcessing = true + var job = jobs[index] + job.status = .processing + jobs[index] = job + saveJobs() // Pending -> Processing + + DebugLogger.shared.info("Starting transcription for: \(job.fileURL.lastPathComponent)", source: "BacklogManager") + + Task(priority: .userInitiated) { + var fileToTranscribe = job.fileURL + var tempDownloadURL: URL? = nil + + // Handle HTTP/HTTPS URLs - Download to temp file + if job.fileURL.scheme?.lowercased() == "http" || job.fileURL.scheme?.lowercased() == "https" { + DebugLogger.shared.info("Downloading remote file: \(job.fileURL)", source: "BacklogManager") + do { + let (tempURL, response) = try await URLSession.shared.download(from: job.fileURL) + + if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { + throw NSError(domain: "BacklogManager", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP Download failed with status code: \(httpResponse.statusCode)"]) + } + + let ext = job.fileURL.pathExtension.isEmpty ? "mp3" : job.fileURL.pathExtension + let destURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension(ext) + try FileManager.default.moveItem(at: tempURL, to: destURL) + + fileToTranscribe = destURL + tempDownloadURL = destURL + DebugLogger.shared.info("Downloaded to: \(destURL.path)", source: "BacklogManager") + } catch { + DebugLogger.shared.error("Download failed: \(error)", source: "BacklogManager") + await MainActor.run { + if let index = self.jobs.firstIndex(where: { $0.id == job.id }) { + self.jobs[index].status = .failed + self.jobs[index].error = "Download failed: \(error.localizedDescription)" + self.saveJobs() + } + self.isProcessing = false + self.processNextJob() + } + return + } + } + + // Cleanup temp file + defer { + if let tempURL = tempDownloadURL { + try? FileManager.default.removeItem(at: tempURL) + } + } + + do { + // If job specifies a model, we might need to switch models? + // The current MeetingTranscriptionService uses the globally selected model in ASRService. + // Switching models globally might be disruptive if the user is using the app. + // However, the request implies "model to transcribe". + // We should probably temporarily switch or pass the model to the service. + // Looking at MeetingTranscriptionService, it uses `asrService.fileTranscriptionProvider`. + // ASRService has `downloadModel` and `getProvider` but currently `transcriptionProvider` is computed from SettingsStore. + // To support per-job model, we'd need to extend MeetingTranscriptionService or ASRService to accept a model override. + // For now, let's assume we use the current model if not specified, or try to switch if specified. + + // NOTE: Swapping global model is risky. Ideally ASRService allows transient provider usage. + + + // Track timing + let startTime = Date() + // Use the modelId from the job if present, otherwise nil (defaulting to global) + let result = try await service.transcribeFile(fileToTranscribe, modelId: job.modelId) + let endTime = Date() + let duration = endTime.timeIntervalSince(startTime) + + await MainActor.run { + if let index = self.jobs.firstIndex(where: { $0.id == job.id }) { + self.jobs[index].status = .completed + self.jobs[index].resultText = result.text + self.jobs[index].processingDuration = duration + // If modelId was nil, we could potentially update it here if the service returned which model was used + // different logic might be needed if service.transcribeFile returns metadata + self.saveJobs() + } + self.isProcessing = false + self.pruneBacklog() + self.processNextJob() // Loop + } + } catch { + await MainActor.run { + DebugLogger.shared.error("Backlog processing failed: \(error.localizedDescription)", source: "BacklogManager") + if let index = self.jobs.firstIndex(where: { $0.id == job.id }) { + self.jobs[index].status = .failed + self.jobs[index].error = error.localizedDescription + self.saveJobs() + } + self.isProcessing = false + self.processNextJob() // Loop + } + } + } + } + + private func pruneBacklog() { + let maxItems = SettingsStore.shared.apiBacklogLimit + guard jobs.count > maxItems else { return } + + // Remove oldest completed/failed jobs first + // If still too many, remove oldest pending (FIFO)? Or just refuse to add? + // User said "setting for a number of items to maintain in backlog". Usually implies retention policy. + // We'll remove oldest completed jobs. + + let completed = jobs.filter { $0.status == .completed || $0.status == .failed }.sorted { $0.createdAt < $1.createdAt } + + if let toRemove = completed.first { + jobs.removeAll(where: { $0.id == toRemove.id }) + saveJobs() + // Recurse if still over limit + if jobs.count > maxItems { + pruneBacklog() + } + } + } + + // MARK: - Persistence + + private func loadJobs() { + guard let data = defaults.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode([TranscriptionJob].self, from: data) else { return } + self.jobs = decoded + } + + private func saveJobs() { + if let encoded = try? JSONEncoder().encode(jobs) { + defaults.set(encoded, forKey: storageKey) + } + } +} diff --git a/Sources/Fluid/Services/FileLogger.swift b/Sources/Fluid/Services/FileLogger.swift index ec2bd00..4c1f2c2 100644 --- a/Sources/Fluid/Services/FileLogger.swift +++ b/Sources/Fluid/Services/FileLogger.swift @@ -30,6 +30,9 @@ final class FileLogger { self.backupLogURL = self.logDirectory.appendingPathComponent("Fluid.log.1", isDirectory: false) self.legacyLogFileURL = self.logDirectory.appendingPathComponent("fluid.log", isDirectory: false) self.legacyBackupLogURL = self.logDirectory.appendingPathComponent("fluid.log.1", isDirectory: false) + + // Print the log path to stdout so the user can see it in Console.app + print("[System Log] FileLogger writing to: \(self.logFileURL.path)") self.queue.sync { self.createLogDirectoryIfNeeded() diff --git a/Sources/Fluid/Services/MeetingTranscriptionService.swift b/Sources/Fluid/Services/MeetingTranscriptionService.swift index 22a9371..9de87de 100644 --- a/Sources/Fluid/Services/MeetingTranscriptionService.swift +++ b/Sources/Fluid/Services/MeetingTranscriptionService.swift @@ -95,7 +95,8 @@ final class MeetingTranscriptionService: ObservableObject { /// Transcribe an audio or video file /// - Parameters: /// - fileURL: URL to the audio/video file - func transcribeFile(_ fileURL: URL) async throws -> TranscriptionResult { + /// - modelId: Optional specific model ID to use + func transcribeFile(_ fileURL: URL, modelId: String? = nil) async throws -> TranscriptionResult { self.isTranscribing = true error = nil self.progress = 0.0 @@ -107,15 +108,65 @@ final class MeetingTranscriptionService: ObservableObject { } do { - // Initialize models if not already done (reuses ASRService models) - if !self.asrService.isAsrReady { - try await self.initializeModels() + var selectedModel: SettingsStore.SpeechModel? = nil + + // Resolve specific model if requested + if let modelId = modelId { + if let model = SettingsStore.SpeechModel.availableModels.first(where: { $0.id == modelId }) { + selectedModel = model + DebugLogger.shared.info("Specific model requested: \(model.displayName) (ID: \(modelId))", source: "MeetingTranscriptionService") + } else { + DebugLogger.shared.warning("Requested model '\(modelId)' not found/available. Falling back to default.", source: "MeetingTranscriptionService") + } + } else { + DebugLogger.shared.info("No specific model requested, using default.", source: "MeetingTranscriptionService") + } + + // Determine effective provider + let provider: TranscriptionProvider + + if let model = selectedModel { + let globalId = SettingsStore.shared.selectedSpeechModel.id + DebugLogger.shared.info("[Debug] Request ID: '\(model.id)' vs Global ID: '\(globalId)'", source: "MeetingTranscriptionService") + + // OPTIMIZATION: Check if this model is ALREADY the active global model + // This avoids re-initializing the model (loading from disk) if it's already in memory + if model.id == globalId { + DebugLogger.shared.info("✅ Requested model matches global. Reusing global provider (Fast Path).", source: "MeetingTranscriptionService") + + if !self.asrService.isAsrReady { + try await self.initializeModels() + } + provider = self.asrService.fileTranscriptionProvider + } else { + // Use specific provider - separate instance from the global one + DebugLogger.shared.info("⚠️ Requested model differs from global. Creating new provider (Slow Path).", source: "MeetingTranscriptionService") + provider = self.asrService.getProvider(for: model) + + // Ensure specific model is ready (loaded into memory) + if !provider.isReady { + self.currentStatus = "Loading \(model.displayName)..." + DebugLogger.shared.info("Initializing specific provider implementation for \(model.displayName)...", source: "MeetingTranscriptionService") + + let prepStart = Date() + // We must call prepare() to load the model, even if files exist on disk + try await provider.prepare { [weak self] progress in + self?.progress = progress + } + DebugLogger.shared.info("Provider preparation took \(Date().timeIntervalSince(prepStart))s", source: "MeetingTranscriptionService") + } + } + } else { + DebugLogger.shared.info("ℹ️ No model specified. Using global default.", source: "MeetingTranscriptionService") + // Use default (global) provider + if !self.asrService.isAsrReady { + try await self.initializeModels() + } + provider = self.asrService.fileTranscriptionProvider } - // Get the current transcription provider (works for both Parakeet and Whisper) - let provider = self.asrService.fileTranscriptionProvider guard provider.isReady else { - throw TranscriptionError.modelLoadFailed("Transcription provider not ready") + throw TranscriptionError.modelLoadFailed("Transcription provider not ready after initialization") } // Check file extension diff --git a/Sources/Fluid/UI/MeetingTranscriptionView.swift b/Sources/Fluid/UI/MeetingTranscriptionView.swift index 90e7a6e..28e1daa 100644 --- a/Sources/Fluid/UI/MeetingTranscriptionView.swift +++ b/Sources/Fluid/UI/MeetingTranscriptionView.swift @@ -4,6 +4,7 @@ import UniformTypeIdentifiers struct MeetingTranscriptionView: View { let asrService: ASRService @StateObject private var transcriptionService: MeetingTranscriptionService + @ObservedObject private var backlogManager = BacklogManager.shared @State private var selectedFileURL: URL? @Environment(\.theme) private var theme @@ -68,6 +69,14 @@ struct MeetingTranscriptionView: View { if let error = transcriptionService.error { self.errorCard(error: error) } + + // Backlog / History Section + if !self.backlogManager.jobs.isEmpty { + Divider() + .padding(.vertical, 8) + + self.backlogList + } } .padding(24) } @@ -89,6 +98,121 @@ struct MeetingTranscriptionView: View { } } + // MARK: - Backlog List + + private var backlogList: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("History & Backlog") + .font(.headline) + + Spacer() + + if self.backlogManager.jobs.contains(where: { $0.status == .completed || $0.status == .failed }) { + Button("Clear History") { + self.backlogManager.clearCompleted() + } + .buttonStyle(.plain) + .foregroundColor(.secondary) + .font(.caption) + } + } + + VStack(spacing: 8) { + // Sort by newest first + ForEach(self.backlogManager.jobs.sorted(by: { $0.createdAt > $1.createdAt })) { job in + self.jobRow(for: job) + } + } + } + } + + private func jobRow(for job: TranscriptionJob) -> some View { + HStack { + // Icon based on status + Group { + switch job.status { + case .pending: + Image(systemName: "hourglass") + .foregroundColor(.secondary) + case .processing: + ProgressView() + .controlSize(.small) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color.fluidGreen) + case .failed: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + } + } + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(job.fileURL.lastPathComponent) + .font(.system(size: 14, weight: .medium)) + .lineLimit(1) + + HStack(spacing: 6) { + Text(job.createdAt.formatted(date: .abbreviated, time: .shortened)) + + if let modelId = job.modelId { + Text("•") + Text(modelId) + } + + if let duration = job.processingDuration { + Text("•") + Text("\(String(format: "%.1fs", duration))") + } + } + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + if job.status == .completed, let resultText = job.resultText { + Button("View") { + // Load into main view + let result = TranscriptionResult( + text: resultText, + confidence: 1.0, // We don't store confidence in backlog yet, default to 1 + duration: 0, // We don't store audio duration in backlog yet + processingTime: job.processingDuration ?? 0, + fileName: job.fileURL.lastPathComponent + ) + self.transcriptionService.result = result + self.transcriptionService.error = nil + } + .buttonStyle(.bordered) + .controlSize(.small) + } else if job.status == .failed, let error = job.error { + Text(error) + .font(.caption) + .foregroundColor(.red) + .lineLimit(1) + } else if job.status == .processing { + Text("Processing...") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("Pending") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(self.theme.palette.cardBackground) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(self.theme.palette.cardBorder.opacity(0.3), lineWidth: 1) + ) + ) + } + // MARK: - File Selection Card private var fileSelectionCard: some View { diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 3f4336d..4c9cd1c 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -372,6 +372,71 @@ struct SettingsView: View { .padding(16) } + // API Settings Card + ThemedCard(style: .standard) { + VStack(alignment: .leading, spacing: 14) { + Label("File Transcription API", systemImage: "server.rack") + .font(.headline) + .foregroundStyle(.primary) + + VStack(spacing: 16) { + self.settingsToggleRow( + title: "Enable API Server", + description: "Allow external applications to submit files for transcription (Port 7086).", + isOn: Binding( + get: { SettingsStore.shared.enableAPI }, + set: { SettingsStore.shared.enableAPI = $0 } + ) + ) + + if SettingsStore.shared.enableAPI { + Divider().opacity(0.2) + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Backlog Limit") + .font(.body) + Text("Maximum number of transcription jobs to keep in history.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + TextField("Limit", value: Binding( + get: { SettingsStore.shared.apiBacklogLimit }, + set: { SettingsStore.shared.apiBacklogLimit = $0 } + ), formatter: NumberFormatter()) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + } + + Divider().opacity(0.2) + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Port") + .font(.body) + Text("Listening port (default: 7086). Requires restart.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + TextField( + "Port", + value: Binding( + get: { SettingsStore.shared.apiPort }, + set: { SettingsStore.shared.apiPort = $0 } + ), + formatter: NumberFormatter() + ) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + } + } + } + } + .padding(16) + } + // Microphone Permission Card ThemedCard(style: .standard) { VStack(alignment: .leading, spacing: 14) { diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 6820113..1cc1393 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -31,6 +31,90 @@ final class DictationE2ETests: XCTestCase { "Expected transcription to contain 'voice' (or a close variant like 'boys'). Got: \(raw)" ) } + + @MainActor + func testAPIService_startsAndResponds() async throws { + // Arrange + SettingsStore.shared.enableAPI = true + + let apiService = APIService.shared + apiService.initialize() + + // Wait briefly for server start + try await Task.sleep(nanoseconds: 500_000_000) // 0.5s + + // Act + let url = URL(string: "http://localhost:7086/models")! + let (data, response) = try await URLSession.shared.data(from: url) + + // Assert + let httpResponse = response as? HTTPURLResponse + XCTAssertEqual(httpResponse?.statusCode, 200, "Expected status code 200 from API") + + // Validate JSON content + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertNotNil(json?["models"], "Expected 'models' field in response") + } + + @MainActor + func testAPIService_idBasedOperations() async throws { + // Arrange + SettingsStore.shared.enableAPI = true + let apiService = APIService.shared + apiService.initialize(port: 7089) + + // Wait briefly for server start + try await Task.sleep(nanoseconds: 500_000_000) + + // create a dummy job in BacklogManager + let dummyURL = URL(fileURLWithPath: "/tmp/test_audio.mp3") + + // Manually inject a job into BacklogManager (since it's a singleton, we can modify it directly if it exposes properties, otherwise we use addJob) + // BacklogManager.shared.addJob checks for duplicates by URL+Model. + // We can't force an ID via addJob easily without changing BacklogManager internals or init. + // However, we can use addJob and then find the ID. + + BacklogManager.shared.addJob(url: dummyURL, modelId: "whisperTiny") + + // Give it a moment to persist/update + try await Task.sleep(nanoseconds: 100_000_000) + + guard let job = BacklogManager.shared.getJobs(for: dummyURL).first(where: { $0.modelId == "whisperTiny" }) else { + XCTFail("Failed to add dummy job") + return + } + let jobID = job.id + + // Act & Assert 1: Get Status by ID + let statusURL = URL(string: "http://localhost:7089/status?id=\(jobID)")! + let (statusData, statusResponse) = try await URLSession.shared.data(from: statusURL) + XCTAssertEqual((statusResponse as? HTTPURLResponse)?.statusCode, 200) + + let statusJSON = try JSONSerialization.jsonObject(with: statusData) as? [String: Any] + XCTAssertEqual(statusJSON?["id"] as? String, jobID) + let status = statusJSON?["status"] as? String + XCTAssertTrue(status == "pending" || status == "processing", "Status was \(String(describing: status))") + + // Act & Assert 2: Get Result by ID (should fail as not completed) + let resultURL = URL(string: "http://localhost:7089/result?id=\(jobID)")! + var request = URLRequest(url: resultURL) + request.httpMethod = "GET" + let (_, resultResponse) = try await URLSession.shared.data(for: request) + // Should be 400 Bad Request because job is not completed + XCTAssertEqual((resultResponse as? HTTPURLResponse)?.statusCode, 400) + + // Act & Assert 3: Delete by ID + let deleteURL = URL(string: "http://localhost:7089/backlog?id=\(jobID)")! + var deleteRequest = URLRequest(url: deleteURL) + deleteRequest.httpMethod = "DELETE" + let (_, deleteResponse) = try await URLSession.shared.data(for: deleteRequest) + XCTAssertEqual((deleteResponse as? HTTPURLResponse)?.statusCode, 200) + + // Verify Deletion + let (_, verifyResponse) = try await URLSession.shared.data(from: statusURL) + // Should be 404 Not Found + XCTAssertEqual((verifyResponse as? HTTPURLResponse)?.statusCode, 404) + } private static func modelDirectoryForRun() -> URL { // Use a stable path on CI so GitHub Actions cache can speed up runs.