diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 0000000..73560b8 --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,82 @@ +name: Test Layers + +on: + pull_request: + push: + branches: + - main + +jobs: + unit: + runs-on: macos-26 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.0.app/Contents/Developer + + - name: Make scripts executable + run: chmod +x scripts/*.sh + + - name: Run unit tests + run: ./scripts/test-unit.sh + + - name: Upload unit artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-test-artifacts + path: | + .build/tests/logs + .build/tests/results + + integration: + runs-on: macos-26 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.0.app/Contents/Developer + + - name: Make scripts executable + run: chmod +x scripts/*.sh + + - name: Run integration tests + run: ./scripts/test-integration.sh + + - name: Upload integration artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: integration-test-artifacts + path: | + .build/tests/logs + .build/tests/results + + ui: + runs-on: macos-26 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.0.app/Contents/Developer + + - name: Make scripts executable + run: chmod +x scripts/*.sh + + - name: Run UI tests + env: + UI_TEST_TIMEOUT_SECONDS: 240 + run: ./scripts/test-ui.sh + + - name: Upload UI artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ui-test-artifacts + path: | + .build/tests/logs + .build/tests/results diff --git a/.gitignore b/.gitignore index a16628d..b0bf876 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ Pods/ *~ *.orig *.log +.factory/ diff --git a/Textream/Textream.xcodeproj/project.pbxproj b/Textream/Textream.xcodeproj/project.pbxproj index df30b53..6230148 100644 --- a/Textream/Textream.xcodeproj/project.pbxproj +++ b/Textream/Textream.xcodeproj/project.pbxproj @@ -6,16 +6,66 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 193AA8D093CA0AF223F012BA /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 330EE2802733C02CEFDEBCBD /* Cocoa.framework */; }; + 59E4F7480F97E6152D968079 /* NotchOverlayControllerIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C5FF5A5B7BF245EBF86CA6 /* NotchOverlayControllerIntegrationTests.swift */; }; + 5CE514D139209A136A38C519 /* TextreamUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6369791E56E14F28A63DD792 /* TextreamUITests.swift */; }; + 7788060C6EBADA6525D33CF9 /* PerformanceBaselineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2BC8007E9109F12EAB6EF /* PerformanceBaselineTests.swift */; }; + 791D146835435876ED1A1CFB /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 330EE2802733C02CEFDEBCBD /* Cocoa.framework */; }; + E60D6A3C9F3763EC40CB39C8 /* PersistentHUDIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 557CCC16C1763CDB90F8511C /* PersistentHUDIntegrationTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 909757218359211567D50A2C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 446966AC2F37E47300AF141F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 446966B32F37E47300AF141F; + remoteInfo = Textream; + }; + E5A37E57CF4D08A3465B2F78 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 446966AC2F37E47300AF141F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 446966B32F37E47300AF141F; + remoteInfo = Textream; + }; + FDB4A1C9731B4825B1504058 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 446966AC2F37E47300AF141F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 446966B32F37E47300AF141F; + remoteInfo = Textream; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 330EE2802733C02CEFDEBCBD /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; 446966B42F37E47300AF141F /* Textream.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Textream.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 557CCC16C1763CDB90F8511C /* PersistentHUDIntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PersistentHUDIntegrationTests.swift; sourceTree = ""; }; + 57C5FF5A5B7BF245EBF86CA6 /* NotchOverlayControllerIntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotchOverlayControllerIntegrationTests.swift; sourceTree = ""; }; + 6369791E56E14F28A63DD792 /* TextreamUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextreamUITests.swift; sourceTree = ""; }; + 8725135751CE8E42ED1E541E /* TextreamUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = TextreamUITests.xctest; path = TextreamUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 98C2BC8007E9109F12EAB6EF /* PerformanceBaselineTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PerformanceBaselineTests.swift; sourceTree = ""; }; + E859F8A12E984AAF8BE8E174 /* TextreamTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TextreamTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F1FBF3E9809692AFF8FF289A /* TextreamIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = TextreamIntegrationTests.xctest; path = TextreamIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 446966B62F37E47300AF141F /* Textream */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Textream; sourceTree = ""; }; + 7F08D5A9B1B84F27B45880A3 /* TextreamTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = TextreamTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +76,29 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C6DF0AB2FC9CE20F314E5266 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 791D146835435876ED1A1CFB /* Cocoa.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D31417C7A36DB18DB455C981 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 193AA8D093CA0AF223F012BA /* Cocoa.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E2DB40B4FA1B451F864B042C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -33,7 +106,11 @@ isa = PBXGroup; children = ( 446966B62F37E47300AF141F /* Textream */, + 7F08D5A9B1B84F27B45880A3 /* TextreamTests */, 446966B52F37E47300AF141F /* Products */, + A57778BE8CE3FD7418C75907 /* Frameworks */, + D811AAC5ACC72EE11F0366E6 /* TextreamIntegrationTests */, + F433B6CD2922B4FB9D875B30 /* TextreamUITests */, ); sourceTree = ""; }; @@ -41,13 +118,70 @@ isa = PBXGroup; children = ( 446966B42F37E47300AF141F /* Textream.app */, + E859F8A12E984AAF8BE8E174 /* TextreamTests.xctest */, + F1FBF3E9809692AFF8FF289A /* TextreamIntegrationTests.xctest */, + 8725135751CE8E42ED1E541E /* TextreamUITests.xctest */, ); name = Products; sourceTree = ""; }; + A57778BE8CE3FD7418C75907 /* Frameworks */ = { + isa = PBXGroup; + children = ( + D901717DAC43202FD3CFB40B /* OS X */, + ); + name = Frameworks; + sourceTree = ""; + }; + D811AAC5ACC72EE11F0366E6 /* TextreamIntegrationTests */ = { + isa = PBXGroup; + children = ( + 57C5FF5A5B7BF245EBF86CA6 /* NotchOverlayControllerIntegrationTests.swift */, + 98C2BC8007E9109F12EAB6EF /* PerformanceBaselineTests.swift */, + 557CCC16C1763CDB90F8511C /* PersistentHUDIntegrationTests.swift */, + ); + name = TextreamIntegrationTests; + path = TextreamIntegrationTests; + sourceTree = ""; + }; + D901717DAC43202FD3CFB40B /* OS X */ = { + isa = PBXGroup; + children = ( + 330EE2802733C02CEFDEBCBD /* Cocoa.framework */, + ); + name = "OS X"; + sourceTree = ""; + }; + F433B6CD2922B4FB9D875B30 /* TextreamUITests */ = { + isa = PBXGroup; + children = ( + 6369791E56E14F28A63DD792 /* TextreamUITests.swift */, + ); + name = TextreamUITests; + path = TextreamUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 3B6061E419BDD9CA0F58A3A0 /* TextreamUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F1CAEAC402DA53D982ABD953 /* Build configuration list for PBXNativeTarget "TextreamUITests" */; + buildPhases = ( + 92243A6809C60089E000F2CD /* Sources */, + D31417C7A36DB18DB455C981 /* Frameworks */, + 29AC92C2E7569B6C05835536 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7431650283B8954563DE6DF3 /* PBXTargetDependency */, + ); + name = TextreamUITests; + productName = TextreamUITests; + productReference = 8725135751CE8E42ED1E541E /* TextreamUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; 446966B32F37E47300AF141F /* Textream */ = { isa = PBXNativeTarget; buildConfigurationList = 446966C12F37E47400AF141F /* Build configuration list for PBXNativeTarget "Textream" */; @@ -64,12 +198,49 @@ 446966B62F37E47300AF141F /* Textream */, ); name = Textream; - packageProductDependencies = ( - ); productName = Textream; productReference = 446966B42F37E47300AF141F /* Textream.app */; productType = "com.apple.product-type.application"; }; + 4EAE5F04E9404F4F8C57FA31 /* TextreamTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9BE313AB3C6B46538D56B183 /* Build configuration list for PBXNativeTarget "TextreamTests" */; + buildPhases = ( + C3DA8951C2154A9F8EBA014B /* Sources */, + E2DB40B4FA1B451F864B042C /* Frameworks */, + DBFF78CECF9C40D1882719D4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 94E768B8868342D1988355F4 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 7F08D5A9B1B84F27B45880A3 /* TextreamTests */, + ); + name = TextreamTests; + productName = TextreamTests; + productReference = E859F8A12E984AAF8BE8E174 /* TextreamTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + CF31437A048FECE799CD7880 /* TextreamIntegrationTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 24C96F1C5ED54C485016F643 /* Build configuration list for PBXNativeTarget "TextreamIntegrationTests" */; + buildPhases = ( + 212D09BBB2E355FB4B8A8A68 /* Sources */, + C6DF0AB2FC9CE20F314E5266 /* Frameworks */, + 2AF371BD31FEFD8C99B78105 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 4B361F6661455DF6AE8B0194 /* PBXTargetDependency */, + ); + name = TextreamIntegrationTests; + productName = TextreamIntegrationTests; + productReference = F1FBF3E9809692AFF8FF289A /* TextreamIntegrationTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -83,6 +254,18 @@ 446966B32F37E47300AF141F = { CreatedOnToolsVersion = 26.2; }; + 3B6061E419BDD9CA0F58A3A0 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 446966B32F37E47300AF141F; + }; + 4EAE5F04E9404F4F8C57FA31 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 446966B32F37E47300AF141F; + }; + CF31437A048FECE799CD7880 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 446966B32F37E47300AF141F; + }; }; }; buildConfigurationList = 446966AF2F37E47300AF141F /* Build configuration list for PBXProject "Textream" */; @@ -100,11 +283,28 @@ projectRoot = ""; targets = ( 446966B32F37E47300AF141F /* Textream */, + 4EAE5F04E9404F4F8C57FA31 /* TextreamTests */, + CF31437A048FECE799CD7880 /* TextreamIntegrationTests */, + 3B6061E419BDD9CA0F58A3A0 /* TextreamUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 29AC92C2E7569B6C05835536 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2AF371BD31FEFD8C99B78105 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 446966B22F37E47300AF141F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -112,9 +312,26 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBFF78CECF9C40D1882719D4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 212D09BBB2E355FB4B8A8A68 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 59E4F7480F97E6152D968079 /* NotchOverlayControllerIntegrationTests.swift in Sources */, + 7788060C6EBADA6525D33CF9 /* PerformanceBaselineTests.swift in Sources */, + E60D6A3C9F3763EC40CB39C8 /* PersistentHUDIntegrationTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 446966B02F37E47300AF141F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -122,9 +339,91 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 92243A6809C60089E000F2CD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CE514D139209A136A38C519 /* TextreamUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C3DA8951C2154A9F8EBA014B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 4B361F6661455DF6AE8B0194 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Textream; + target = 446966B32F37E47300AF141F /* Textream */; + targetProxy = 909757218359211567D50A2C /* PBXContainerItemProxy */; + }; + 7431650283B8954563DE6DF3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Textream; + target = 446966B32F37E47300AF141F /* Textream */; + targetProxy = E5A37E57CF4D08A3465B2F78 /* PBXContainerItemProxy */; + }; + 94E768B8868342D1988355F4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 446966B32F37E47300AF141F /* Textream */; + targetProxy = FDB4A1C9731B4825B1504058 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 1D7454A3473C572490FCA17B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + CODE_SIGNING_REQUIRED = NO; + DEVELOPMENT_TEAM = ""; + EXECUTABLE_NAME = "$(TARGET_NAME)"; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.7; + PRODUCT_BUNDLE_IDENTIFIER = dev.fka.textreamUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_MODULE_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5; + TEST_TARGET_NAME = Textream; + WRAPPER_EXTENSION = xctest; + }; + name = Release; + }; + 3AF0B4C585AC41EE8729D28E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@loader_path/../Frameworks", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.7; + PRODUCT_BUNDLE_IDENTIFIER = dev.fka.textreamTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_VERSION = 5.0; + TEST_HOST = ""; + TEST_TARGET_NAME = ""; + }; + name = Debug; + }; 446966BF2F37E47400AF141F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -254,10 +553,13 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = Textream/Textream.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = RJA7656U34; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -325,9 +627,112 @@ }; name = Release; }; + 6A7DC0A66E426C4C5A2B3B41 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + CODE_SIGNING_REQUIRED = NO; + DEVELOPMENT_TEAM = ""; + EXECUTABLE_NAME = "$(TARGET_NAME)"; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.7; + PRODUCT_BUNDLE_IDENTIFIER = dev.fka.textreamUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_MODULE_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5; + TEST_TARGET_NAME = Textream; + WRAPPER_EXTENSION = xctest; + }; + name = Debug; + }; + CBF3695FC7ED215DB8678EF2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + EXECUTABLE_NAME = "$(TARGET_NAME)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.7; + PRODUCT_BUNDLE_IDENTIFIER = dev.fka.textreamIntegrationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_MODULE_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Textream.app/Contents/MacOS/Textream"; + WRAPPER_EXTENSION = xctest; + }; + name = Release; + }; + F686F5D5202C4E3B856D2A8F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@loader_path/../Frameworks", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.7; + PRODUCT_BUNDLE_IDENTIFIER = dev.fka.textreamTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_VERSION = 5.0; + TEST_HOST = ""; + TEST_TARGET_NAME = ""; + }; + name = Release; + }; + FA94F73BE15BC8F1F5963D3E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + DEVELOPMENT_TEAM = ""; + EXECUTABLE_NAME = "$(TARGET_NAME)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.7; + PRODUCT_BUNDLE_IDENTIFIER = dev.fka.textreamIntegrationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_MODULE_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Textream.app/Contents/MacOS/Textream"; + WRAPPER_EXTENSION = xctest; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 24C96F1C5ED54C485016F643 /* Build configuration list for PBXNativeTarget "TextreamIntegrationTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CBF3695FC7ED215DB8678EF2 /* Release */, + FA94F73BE15BC8F1F5963D3E /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 446966AF2F37E47300AF141F /* Build configuration list for PBXProject "Textream" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -346,6 +751,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 9BE313AB3C6B46538D56B183 /* Build configuration list for PBXNativeTarget "TextreamTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3AF0B4C585AC41EE8729D28E /* Debug */, + F686F5D5202C4E3B856D2A8F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F1CAEAC402DA53D982ABD953 /* Build configuration list for PBXNativeTarget "TextreamUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1D7454A3473C572490FCA17B /* Release */, + 6A7DC0A66E426C4C5A2B3B41 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 446966AC2F37E47300AF141F /* Project object */; diff --git a/Textream/Textream.xcodeproj/xcshareddata/xcschemes/Textream.xcscheme b/Textream/Textream.xcodeproj/xcshareddata/xcschemes/Textream.xcscheme new file mode 100644 index 0000000..b1e948a --- /dev/null +++ b/Textream/Textream.xcodeproj/xcshareddata/xcschemes/Textream.xcscheme @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Textream/Textream.xcodeproj/xcshareddata/xcschemes/TextreamUI.xcscheme b/Textream/Textream.xcodeproj/xcshareddata/xcschemes/TextreamUI.xcscheme new file mode 100644 index 0000000..42146da --- /dev/null +++ b/Textream/Textream.xcodeproj/xcshareddata/xcschemes/TextreamUI.xcscheme @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Textream/Textream/AttachedOverlayTypes.swift b/Textream/Textream/AttachedOverlayTypes.swift new file mode 100644 index 0000000..ee9bf69 --- /dev/null +++ b/Textream/Textream/AttachedOverlayTypes.swift @@ -0,0 +1,26 @@ +// +// AttachedOverlayTypes.swift +// Textream +// +// Created by OpenAI Codex on 21.03.2026. +// + +import Foundation + +enum AttachedAnchorCorner: String, CaseIterable, Identifiable, Codable { + case topLeft + case topRight + case bottomLeft + case bottomRight + + var id: String { rawValue } + + var label: String { + switch self { + case .topLeft: return "Top Left" + case .topRight: return "Top Right" + case .bottomLeft: return "Bottom Left" + case .bottomRight: return "Bottom Right" + } + } +} diff --git a/Textream/Textream/BrowserServer.swift b/Textream/Textream/BrowserServer.swift index c58f481..34158b8 100644 --- a/Textream/Textream/BrowserServer.swift +++ b/Textream/Textream/BrowserServer.swift @@ -8,23 +8,6 @@ import Foundation import Network -// MARK: - Browser State - -struct BrowserState: Codable { - let words: [String] - let highlightedCharCount: Int - let totalCharCount: Int - let audioLevels: [Double] - let isListening: Bool - let isDone: Bool - let fontColor: String - let cueColor: String - let hasNextPage: Bool - let isActive: Bool - let highlightWords: Bool - let lastSpokenText: String -} - // MARK: - Browser Server class BrowserServer { @@ -38,6 +21,7 @@ class BrowserServer { private var totalCharCount: Int = 0 private var hasNextPage: Bool = false private weak var speechRecognizer: SpeechRecognizer? + private weak var overlayContent: OverlayContent? private var timerWordProgress: Double = 0 private var contentActive: Bool = false @@ -70,11 +54,12 @@ class BrowserServer { // MARK: - Content Management - func showContent(speechRecognizer: SpeechRecognizer, words: [String], totalCharCount: Int, hasNextPage: Bool) { + func showContent(speechRecognizer: SpeechRecognizer, content: OverlayContent) { self.speechRecognizer = speechRecognizer - self.words = words - self.totalCharCount = totalCharCount - self.hasNextPage = hasNextPage + self.overlayContent = content + self.words = content.words + self.totalCharCount = content.totalCharCount + self.hasNextPage = content.hasNextPage self.timerWordProgress = 0 self.contentActive = true startBroadcasting() @@ -189,7 +174,10 @@ class BrowserServer { let mode = NotchSettings.shared.listeningMode switch mode { case .wordTracking: - charCount = speechRecognizer?.recognizedCharCount ?? 0 + words = overlayContent?.words ?? words + totalCharCount = overlayContent?.totalCharCount ?? totalCharCount + hasNextPage = overlayContent?.hasNextPage ?? hasNextPage + charCount = overlayContent?.highlightedCharCount ?? speechRecognizer?.recognizedCharCount ?? 0 case .classic: timerWordProgress += NotchSettings.shared.scrollSpeed * 0.1 charCount = charOffsetForWordProgress(timerWordProgress) @@ -204,6 +192,11 @@ class BrowserServer { let isDone = totalCharCount > 0 && effective >= totalCharCount let highlightWords = mode == .wordTracking + let trackingState = overlayContent?.trackingState.rawValue ?? speechRecognizer?.trackingState.rawValue ?? TrackingState.tracking.rawValue + let confidenceLevel = overlayContent?.confidenceLevel.rawValue ?? speechRecognizer?.confidenceLevel.rawValue ?? TrackingConfidence.low.rawValue + let expectedWord = overlayContent?.expectedWord ?? speechRecognizer?.expectedWord ?? "" + let nextCue = overlayContent?.nextCue ?? speechRecognizer?.nextCue ?? "" + let manualAsideActive = (overlayContent?.manualAsideMode.isActive ?? speechRecognizer?.manualAsideMode.isActive ?? false) let state = BrowserState( words: words, @@ -217,7 +210,12 @@ class BrowserServer { hasNextPage: hasNextPage, isActive: true, highlightWords: highlightWords, - lastSpokenText: speechRecognizer?.lastSpokenText ?? "" + lastSpokenText: speechRecognizer?.lastSpokenText ?? "", + trackingState: trackingState, + confidenceLevel: confidenceLevel, + expectedWord: expectedWord, + nextCue: nextCue, + manualAsideActive: manualAsideActive ) broadcast(state) } @@ -227,7 +225,12 @@ class BrowserServer { words: [], highlightedCharCount: 0, totalCharCount: 0, audioLevels: [], isListening: false, isDone: false, fontColor: "#ffffff", cueColor: "#ffffff", hasNextPage: false, isActive: false, - highlightWords: true, lastSpokenText: "" + highlightWords: true, lastSpokenText: "", + trackingState: TrackingState.tracking.rawValue, + confidenceLevel: TrackingConfidence.low.rawValue, + expectedWord: "", + nextCue: "", + manualAsideActive: false ) broadcast(state) } @@ -418,16 +421,21 @@ class BrowserServer { } function rgba(rgb,a){return 'rgba('+rgb[0]+','+rgb[1]+','+rgb[2]+','+a+')';} - // Detect annotation words: [bracket] or emoji-only (no letters/digits) + // Detect visually-styled annotation words. Bracket cues still participate + // in tracking as long as they contain matchable letters or digits. function isAnnotation(w){ if(w.startsWith('[')&&w.endsWith(']'))return true; return!/[a-zA-Z0-9\\u00C0-\\u024F\\u0400-\\u04FF\\u3000-\\u9FFF\\uAC00-\\uD7AF]/.test(w); } + function trackingTokenCount(w){ + const m=w.match(/[a-zA-Z0-9\\u00C0-\\u024F\\u0400-\\u04FF\\u3000-\\u9FFF\\uAC00-\\uD7AF]/g); + return m?m.length:0; + } + // Count letters+digits in a word function letterCount(w){ - let n=0;for(const ch of w)if(/[a-zA-Z0-9\\u00C0-\\u024F\\u0400-\\u04FF\\u3000-\\u9FFF\\uAC00-\\uD7AF]/.test(ch))n++; - return Math.max(1,n); + return Math.max(1,trackingTokenCount(w)); } /* ---- connection ---- */ @@ -471,13 +479,14 @@ class BrowserServer { c.innerHTML=''; let cp=0; for(let i=0;i0; const sp=document.createElement('span'); sp.className=ann?'w ann':'w'; sp.dataset.s=cp; sp.dataset.l=wd.length; sp.dataset.lc=letterCount(wd); sp.dataset.a=ann?'1':'0'; + sp.dataset.t=trackable?'1':'0'; sp.textContent=wd+' '; c.appendChild(sp); cp+=wd.length+1; @@ -485,13 +494,14 @@ class BrowserServer { prevWordKey=wordKey; } - // Find the next-word index (first non-fully-lit non-annotation) + // Find the next-word index. Bracket cues remain trackable, while + // emoji-only / punctuation-only tokens are skipped. let nextIdx=-1; if(hlWords){ const spans=c.children; for(let i=0;i=lc; const charsInto=hcc-charOff; - const isCurrent=(i===nextIdx)||(charsInto>=0&&!isFullyLit&&!ann); + const isCurrent=trackable&&((i===nextIdx)||(charsInto>=0&&!isFullyLit)); let color,underline=false; @@ -517,7 +527,8 @@ class BrowserServer { color=ann?rgba(crgb,0.4):fc; } else if(ann){ // Annotation: cue color with varying opacity - color=isFullyLit?rgba(crgb,0.5):rgba(crgb,0.2); + color=isFullyLit?rgba(crgb,0.5):(isCurrent?rgba(crgb,0.72):rgba(crgb,0.2)); + underline=isCurrent; } else if(isFullyLit){ // Already read: dimmed color=rgba(rgb,0.3); diff --git a/Textream/Textream/ContentView.swift b/Textream/Textream/ContentView.swift index 773eb95..9d87266 100644 --- a/Textream/Textream/ContentView.swift +++ b/Textream/Textream/ContentView.swift @@ -22,6 +22,9 @@ struct ContentView: View { @State private var dropAlertTitle: String = "Import Error" @State private var showSettings = false @State private var showAbout = false + @State private var settingsInitialTab: SettingsTab = .appearance + @State private var showAccessibilityLaunchGuide = false + @State private var attachedDiagnostics = AttachedDiagnosticsStore.shared @FocusState private var isTextFocused: Bool private let defaultText = """ @@ -185,8 +188,113 @@ Happy presenting! [wave] isRecording = false } + private var accessibilityLaunchGuideTitle: String { + if NotchSettings.shared.overlayMode == .attached { + return "Attached Overlay is using screen-corner fallback" + } + return "Accessibility unlocks Attached Overlay" + } + + private var accessibilityLaunchGuideMessage: String { + if NotchSettings.shared.overlayMode == .attached { + return "Accessibility is still off, so Textream cannot lock onto other app windows yet. Attached Overlay will stay in the screen corner until you allow access." + } + return "If you want Textream to follow another app window, grant Accessibility before you use Attached Overlay. Until then, Textream will fall back to the screen corner instead of silently failing." + } + + private var accessibilityLaunchGuideCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "hand.raised.square.on.square") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 4) { + Text(accessibilityLaunchGuideTitle) + .font(.system(size: 13, weight: .semibold)) + Text(accessibilityLaunchGuideMessage) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + Button { + dismissAccessibilityLaunchGuide() + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 20, height: 20) + .background(Color.primary.opacity(0.05), in: Circle()) + } + .buttonStyle(.plain) + } + + HStack(spacing: 8) { + Button("Open System Settings") { + dismissAccessibilityLaunchGuide() + WindowAnchorService.openAccessibilitySettings() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Button("Review Attached Setup") { + dismissAccessibilityLaunchGuide() + settingsInitialTab = .teleprompter + showSettings = true + } + .buttonStyle(.bordered) + .controlSize(.small) + + Spacer() + + Button("Later") { + dismissAccessibilityLaunchGuide() + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .font(.system(size: 11, weight: .medium)) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.accentColor.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Color.accentColor.opacity(0.18), lineWidth: 1) + ) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 8) + } + + private func dismissAccessibilityLaunchGuide() { + NotchSettings.shared.hasSeenAccessibilityLaunchGuide = true + showAccessibilityLaunchGuide = false + } + + private func refreshAccessibilityLaunchGuide() { + guard !AppRuntime.isRunningUITests else { + showAccessibilityLaunchGuide = false + return + } + + let permissionState = attachedDiagnostics.refreshPermissionState() + let shouldShowGuide = permissionState == .notGranted && + (!NotchSettings.shared.hasSeenAccessibilityLaunchGuide || NotchSettings.shared.overlayMode == .attached) + showAccessibilityLaunchGuide = shouldShowGuide + } + private var mainContent: some View { VStack(spacing: 0) { + if showAccessibilityLaunchGuide { + accessibilityLaunchGuideCard + } + ZStack { HighlightingTextEditor( text: currentText, @@ -380,6 +488,7 @@ Happy presenting! [wave] Spacer() Button { + settingsInitialTab = .appearance showSettings = true } label: { Text("Open Settings") @@ -462,6 +571,7 @@ Happy presenting! [wave] .buttonStyle(.plain) Button { + settingsInitialTab = .appearance showSettings = true } label: { HStack(spacing: 4) { @@ -480,13 +590,16 @@ Happy presenting! [wave] .padding(.horizontal, 8) } } - .sheet(isPresented: $showSettings) { - SettingsView(settings: NotchSettings.shared) + .sheet(isPresented: $showSettings, onDismiss: { + refreshAccessibilityLaunchGuide() + }) { + SettingsView(settings: NotchSettings.shared, initialTab: settingsInitialTab) } .sheet(isPresented: $showAbout) { AboutView() } .onReceive(NotificationCenter.default.publisher(for: .openSettings)) { _ in + settingsInitialTab = .appearance showSettings = true } .onReceive(NotificationCenter.default.publisher(for: .openAbout)) { _ in @@ -495,6 +608,7 @@ Happy presenting! [wave] .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in // Sync button state when app is re-activated (e.g. dock click) isRunning = service.overlayController.isShowing + refreshAccessibilityLaunchGuide() } .onAppear { // Set default text for the first page if empty @@ -514,6 +628,7 @@ Happy presenting! [wave] } else { isTextFocused = true } + refreshAccessibilityLaunchGuide() } } diff --git a/Textream/Textream/DirectorServer.swift b/Textream/Textream/DirectorServer.swift index 7c0fd99..6d13728 100644 --- a/Textream/Textream/DirectorServer.swift +++ b/Textream/Textream/DirectorServer.swift @@ -8,29 +8,6 @@ import Foundation import Network -// MARK: - Director State (App → Web) - -struct DirectorState: Codable { - let words: [String] - let highlightedCharCount: Int - let totalCharCount: Int - let isActive: Bool - let isDone: Bool - let isListening: Bool - let fontColor: String - let cueColor: String - let lastSpokenText: String - let audioLevels: [Double] -} - -// MARK: - Director Command (Web → App) - -struct DirectorCommand: Codable { - let type: String // "setText", "updateText", "stop" - let text: String? - let readCharCount: Int? -} - // MARK: - Director Server class DirectorServer { @@ -52,6 +29,7 @@ class DirectorServer { private var words: [String] = [] private var totalCharCount: Int = 0 private weak var speechRecognizer: SpeechRecognizer? + private weak var overlayContent: OverlayContent? private var contentActive: Bool = false private var lastBroadcastState: Data? @@ -91,10 +69,11 @@ class DirectorServer { // MARK: - Content Management - func showContent(speechRecognizer: SpeechRecognizer, words: [String], totalCharCount: Int) { + func showContent(speechRecognizer: SpeechRecognizer, content: OverlayContent) { self.speechRecognizer = speechRecognizer - self.words = words - self.totalCharCount = totalCharCount + self.overlayContent = content + self.words = content.words + self.totalCharCount = content.totalCharCount self.contentActive = true startBroadcasting() } @@ -261,9 +240,16 @@ class DirectorServer { private func broadcastCurrentState() { guard contentActive, !wsConnections.isEmpty else { return } - let charCount = speechRecognizer?.recognizedCharCount ?? 0 + words = overlayContent?.words ?? words + totalCharCount = overlayContent?.totalCharCount ?? totalCharCount + let charCount = overlayContent?.highlightedCharCount ?? speechRecognizer?.recognizedCharCount ?? 0 let effective = min(charCount, totalCharCount) let isDone = totalCharCount > 0 && effective >= totalCharCount + let trackingState = overlayContent?.trackingState.rawValue ?? speechRecognizer?.trackingState.rawValue ?? TrackingState.tracking.rawValue + let confidenceLevel = overlayContent?.confidenceLevel.rawValue ?? speechRecognizer?.confidenceLevel.rawValue ?? TrackingConfidence.low.rawValue + let expectedWord = overlayContent?.expectedWord ?? speechRecognizer?.expectedWord ?? "" + let nextCue = overlayContent?.nextCue ?? speechRecognizer?.nextCue ?? "" + let manualAsideActive = (overlayContent?.manualAsideMode.isActive ?? speechRecognizer?.manualAsideMode.isActive ?? false) let state = DirectorState( words: words, @@ -275,7 +261,12 @@ class DirectorServer { fontColor: NotchSettings.shared.fontColorPreset.cssColor, cueColor: NotchSettings.shared.cueColorPreset.cssColor, lastSpokenText: speechRecognizer?.lastSpokenText ?? "", - audioLevels: (speechRecognizer?.audioLevels ?? []).map { Double($0) } + audioLevels: (speechRecognizer?.audioLevels ?? []).map { Double($0) }, + trackingState: trackingState, + confidenceLevel: confidenceLevel, + expectedWord: expectedWord, + nextCue: nextCue, + manualAsideActive: manualAsideActive ) broadcast(state) } @@ -285,7 +276,12 @@ class DirectorServer { words: [], highlightedCharCount: 0, totalCharCount: 0, isActive: false, isDone: false, isListening: false, fontColor: "#ffffff", cueColor: "#ffffff", lastSpokenText: "", - audioLevels: [] + audioLevels: [], + trackingState: TrackingState.tracking.rawValue, + confidenceLevel: TrackingConfidence.low.rawValue, + expectedWord: "", + nextCue: "", + manualAsideActive: false ) broadcast(state) } diff --git a/Textream/Textream/ExternalDisplayController.swift b/Textream/Textream/ExternalDisplayController.swift index ac2b272..19911ab 100644 --- a/Textream/Textream/ExternalDisplayController.swift +++ b/Textream/Textream/ExternalDisplayController.swift @@ -12,7 +12,7 @@ import Combine class ExternalDisplayController { private var panel: NSPanel? private var cancellables = Set() - let overlayContent = OverlayContent() + private weak var overlayContent: OverlayContent? /// Find the target external screen based on saved screen ID, or first non-main screen func targetScreen() -> NSScreen? { @@ -29,22 +29,19 @@ class ExternalDisplayController { return screens.first } - func show(speechRecognizer: SpeechRecognizer, words: [String], totalCharCount: Int, hasNextPage: Bool = false) { + func show(speechRecognizer: SpeechRecognizer, content: OverlayContent) { let settings = NotchSettings.shared guard settings.externalDisplayMode != .off else { return } guard let screen = targetScreen() else { return } dismiss() - - overlayContent.words = words - overlayContent.totalCharCount = totalCharCount - overlayContent.hasNextPage = hasNextPage + overlayContent = content let mirrorAxis = settings.externalDisplayMode == .mirror ? settings.mirrorAxis : nil let screenFrame = screen.frame let content = ExternalDisplayView( - content: overlayContent, + content: content, speechRecognizer: speechRecognizer, mirrorAxis: mirrorAxis ) @@ -122,6 +119,17 @@ struct ExternalDisplayView: View { NotchSettings.shared.listeningMode } + private var hudItems: [HUDPresentationItem] { + PersistentHUDPresenter.items( + content: content, + isListening: speechRecognizer.isListening, + configuration: HUDPresentationConfiguration( + isEnabled: NotchSettings.shared.persistentHUDEnabled, + modules: NotchSettings.shared.hudModules + ) + ) + } + /// Convert fractional word index to char offset using actual word lengths private func charOffsetForWordProgress(_ progress: Double) -> Int { let wholeWord = Int(progress) @@ -153,7 +161,7 @@ struct ExternalDisplayView: View { private var effectiveCharCount: Int { switch listeningMode { case .wordTracking: - return speechRecognizer.recognizedCharCount + return content.highlightedCharCount case .classic, .silencePaused: return charOffsetForWordProgress(timerWordProgress) } @@ -172,6 +180,17 @@ struct ExternalDisplayView: View { } } + private var shouldShowStatusBlock: Bool { + listeningMode == .wordTracking || content.attachedRequiresAttention + } + + private var secondaryStatusText: String { + if content.attachedRequiresAttention { + return content.attachedDetailLine + } + return speechRecognizer.lastSpokenText.split(separator: " ").suffix(5).joined(separator: " ") + } + var body: some View { ZStack { Color.black.ignoresSafeArea() @@ -258,13 +277,20 @@ struct ExternalDisplayView: View { ) .frame(width: 240, height: 32) - if listeningMode == .wordTracking { - Text(speechRecognizer.lastSpokenText.split(separator: " ").suffix(5).joined(separator: " ")) - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(.white.opacity(0.5)) - .lineLimit(1) - .truncationMode(.head) - .frame(maxWidth: .infinity, alignment: .leading) + if shouldShowStatusBlock { + VStack(alignment: .leading, spacing: 3) { + Text(content.statusLine.isEmpty ? content.trackingState.label : content.statusLine) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white.opacity(0.75)) + if !secondaryStatusText.isEmpty { + Text(secondaryStatusText) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.white.opacity(0.42)) + .lineLimit(1) + .truncationMode(.head) + } + } + .frame(maxWidth: .infinity, alignment: .leading) } else { Spacer() } @@ -288,7 +314,19 @@ struct ExternalDisplayView: View { } } .padding(.horizontal, hPad) - .padding(.bottom, 40) + .padding(.bottom, 20) + + if !hudItems.isEmpty { + PersistentHUDStripView(items: hudItems, compact: false) + .padding(.horizontal, hPad) + .padding(.bottom, 40) + } + + if NotchSettings.shared.qaDebugOverlayEnabled { + QADebugOverlayView(speechRecognizer: speechRecognizer, compact: false) + .padding(.horizontal, hPad) + .padding(.bottom, 40) + } } } } diff --git a/Textream/Textream/MarqueeTextView.swift b/Textream/Textream/MarqueeTextView.swift index 74e5afb..8bed74c 100644 --- a/Textream/Textream/MarqueeTextView.swift +++ b/Textream/Textream/MarqueeTextView.swift @@ -7,55 +7,6 @@ import SwiftUI -// MARK: - CJK-aware word splitting - -extension Unicode.Scalar { - var isCJK: Bool { - let v = value - return (v >= 0x4E00 && v <= 0x9FFF) // CJK Unified Ideographs - || (v >= 0x3400 && v <= 0x4DBF) // CJK Extension A - || (v >= 0x20000 && v <= 0x2A6DF) // CJK Extension B - || (v >= 0xF900 && v <= 0xFAFF) // CJK Compatibility Ideographs - || (v >= 0x3040 && v <= 0x309F) // Hiragana - || (v >= 0x30A0 && v <= 0x30FF) // Katakana - || (v >= 0xAC00 && v <= 0xD7AF) // Hangul Syllables - } -} - -/// Splits text into display-ready words. CJK characters (Chinese, Japanese, Korean) -/// are split into individual characters so the flow layout can wrap them properly. -func splitTextIntoWords(_ text: String) -> [String] { - let tokens = text.replacingOccurrences(of: "\n", with: " ") - .split(omittingEmptySubsequences: true, whereSeparator: { $0.isWhitespace }) - .map { String($0) } - - var result: [String] = [] - for token in tokens { - guard token.unicodeScalars.contains(where: { $0.isCJK }) else { - result.append(token) - continue - } - // Token contains CJK characters — split each CJK char individually; - // consecutive non-CJK chars (e.g. Latin letters, digits) stay grouped. - var buffer = "" - for char in token { - if char.unicodeScalars.first.map({ $0.isCJK }) == true { - if !buffer.isEmpty { - result.append(buffer) - buffer = "" - } - result.append(String(char)) - } else { - buffer.append(char) - } - } - if !buffer.isEmpty { - result.append(buffer) - } - } - return result -} - // MARK: - Data struct WordItem: Identifiable { @@ -63,6 +14,7 @@ struct WordItem: Identifiable { let word: String let charOffset: Int // char offset of this word in the full text (counting spaces) let isAnnotation: Bool // true for [bracket] words and emoji-only words + let participatesInTracking: Bool } // MARK: - Preference key to report word Y positions @@ -370,13 +322,14 @@ struct WordFlowLayout: View { return (items, lines) } - // Find the index of the next word to read (first non-fully-lit, non-annotation word) + // Find the index of the next word to read. Bracket cues still participate in + // tracking, while punctuation / emoji-only tokens are skipped. private func nextWordIndex(items: [WordItem]) -> Int { for item in items { - if item.isAnnotation { continue } + if !item.participatesInTracking { continue } let charsIntoWord = highlightedCharCount - item.charOffset let litCount = max(0, min(item.word.count, charsIntoWord)) - let letterCount = max(1, item.word.filter { $0.isLetter || $0.isNumber }.count) + let letterCount = max(1, normalizedTrackingToken(item.word).count) if litCount < letterCount { return item.id } @@ -424,9 +377,9 @@ struct WordFlowLayout: View { let wordLen = item.word.count let charsIntoWord = highlightedCharCount - item.charOffset let litCount = max(0, min(wordLen, charsIntoWord)) - let letterCount = max(1, item.word.filter { $0.isLetter || $0.isNumber }.count) + let letterCount = max(1, normalizedTrackingToken(item.word).count) let isFullyLit = litCount >= letterCount - let isCurrentWord = isNextWord || (charsIntoWord >= 0 && !isFullyLit) + let isCurrentWord = item.participatesInTracking && (isNextWord || (charsIntoWord >= 0 && !isFullyLit)) // When highlighting is off (classic/silence-paused), use uniform color if !highlightWords { @@ -453,13 +406,15 @@ struct WordFlowLayout: View { // Annotations: italic, dimmed with cue color if item.isAnnotation { - let annotationColor: Color = isFullyLit - ? cueColor.opacity(cueReadOpacity) - : cueColor.opacity(cueUnreadOpacity) + let annotationOpacity: Double = isFullyLit + ? cueReadOpacity + : (isCurrentWord ? max(cueUnreadOpacity, 0.7) : cueUnreadOpacity) + let annotationColor: Color = cueColor.opacity(annotationOpacity) return Text(item.word + " ") .font(Font(font).italic()) .foregroundStyle(annotationColor) + .underline(isCurrentWord, color: annotationColor) .background( GeometryReader { wordGeo in Color.clear.preference( @@ -505,19 +460,22 @@ struct WordFlowLayout: View { var offset = 0 for (i, word) in words.enumerated() { let isAnnotation = Self.isAnnotationWord(word) - items.append(WordItem(id: i, word: word, charOffset: offset, isAnnotation: isAnnotation)) + items.append( + WordItem( + id: i, + word: word, + charOffset: offset, + isAnnotation: isAnnotation, + participatesInTracking: wordParticipatesInTracking(word) + ) + ) offset += word.count + 1 // +1 for space } return items } static func isAnnotationWord(_ word: String) -> Bool { - // Words inside square brackets like [smile] - if word.hasPrefix("[") && word.hasSuffix("]") { return true } - // Emoji-only words (no letters or numbers) - let stripped = word.filter { $0.isLetter || $0.isNumber } - if stripped.isEmpty { return true } - return false + isStyledAnnotationWord(word) } private func buildLines(items: [WordItem]) -> [[WordItem]] { diff --git a/Textream/Textream/NotchOverlayController.swift b/Textream/Textream/NotchOverlayController.swift index 330d202..b8eaa64 100644 --- a/Textream/Textream/NotchOverlayController.swift +++ b/Textream/Textream/NotchOverlayController.swift @@ -35,6 +35,26 @@ class OverlayContent { var words: [String] = [] var totalCharCount: Int = 0 var hasNextPage: Bool = false + var highlightedCharCount: Int = 0 + var trackingState: TrackingState = .tracking + var expectedWord: String = "" + var nextCue: String = "" + var confidenceLevel: TrackingConfidence = .low + var confidenceScore: Double = 0 + var manualAsideMode: ManualAsideMode = .inactive + var trackingStatusLine: String = "" + var partialText: String = "" + var manualIgnoreActive: Bool = false + var attachedDiagnosticState: AttachedDiagnosticState = .inactive + var attachedAnchorSourceLabel: String = "Inactive" + var attachedTargetWindowLabel: String = "" + var attachedStatusLine: String = "" + var attachedDetailLine: String = "" + var attachedRequiresAttention: Bool = false + + var statusLine: String { + attachedStatusLine.isEmpty ? trackingStatusLine : attachedStatusLine + } // Page picker var pageCount: Int = 1 @@ -44,10 +64,18 @@ class OverlayContent { var jumpToPageIndex: Int? = nil } -class NotchOverlayController: NSObject { +class NotchOverlayController: NSObject, NSWindowDelegate { + private enum OverlayPresentationMode: Equatable { + case pinned + case floating + case floatingFollowCursor + case fullscreen + case attached + } + private var panel: NSPanel? - let speechRecognizer = SpeechRecognizer() - let overlayContent = OverlayContent() + let speechRecognizer: SpeechRecognizer + let overlayContent: OverlayContent var onComplete: (() -> Void)? var onNextPage: (() -> Void)? private var cancellables = Set() @@ -58,10 +86,83 @@ class NotchOverlayController: NSObject { private var currentScreenID: UInt32 = 0 private var stopButtonPanel: NSPanel? private var escMonitor: Any? + private var activePresentationMode: OverlayPresentationMode? + private var isUserResizingPrimaryPanel = false + private let windowAnchorService: WindowAnchorService + private let attachedDiagnostics: AttachedDiagnosticsStore + private let hotkeyController: any TrackingHotkeyControlling + private let disablePermissionOnboarding: Bool + private var shouldDriveLiveCursorTracking: Bool { + !AppRuntime.isHeadlessTestRuntime + } + + private var shouldShowFloatingStopButton: Bool { + !AppRuntime.isHeadlessTestRuntime + } + + init( + speechRecognizer: SpeechRecognizer = SpeechRecognizer(), + overlayContent: OverlayContent = OverlayContent(), + windowAnchorService: WindowAnchorService = WindowAnchorService(), + hotkeyController: any TrackingHotkeyControlling = TrackingHotkeyController.shared, + attachedDiagnostics: AttachedDiagnosticsStore = .shared, + disablePermissionOnboarding: Bool = AppRuntime.isRunningUITests + ) { + self.speechRecognizer = speechRecognizer + self.overlayContent = overlayContent + self.windowAnchorService = windowAnchorService + self.hotkeyController = hotkeyController + self.attachedDiagnostics = attachedDiagnostics + self.disablePermissionOnboarding = disablePermissionOnboarding + super.init() + speechRecognizer.onTrackingSnapshot = { [weak self] snapshot, frame in + self?.applyTrackingSnapshot(snapshot, frame: frame) + } + setAnchorDebugInactive() + } + + deinit { + hotkeyController.stop() + mouseTrackingTimer?.cancel() + cursorTrackingTimer?.cancel() + cancellables.removeAll() + windowAnchorService.stopTracking() + windowAnchorService.onResolutionChanged = nil + speechRecognizer.onTrackingSnapshot = nil + removeEscMonitor() + } + + private func applyTrackingSnapshot(_ snapshot: TrackingSnapshot, frame: SpeechRecognitionFrame?) { + OverlayStateProjector.apply(snapshot: snapshot, frame: frame, to: overlayContent) + } + + private func syncAttachedStatusFromDiagnostics() { + overlayContent.attachedDiagnosticState = attachedDiagnostics.state + overlayContent.attachedAnchorSourceLabel = attachedDiagnostics.anchorSourceLabel + overlayContent.attachedTargetWindowLabel = attachedDiagnostics.targetWindowLabel + overlayContent.attachedStatusLine = attachedDiagnostics.isDegraded ? attachedDiagnostics.statusLine : "" + overlayContent.attachedDetailLine = attachedDiagnostics.isDegraded ? attachedDiagnostics.detailLine : "" + overlayContent.attachedRequiresAttention = attachedDiagnostics.isDegraded + } + + private func presentAttachedPermissionOnboardingIfNeeded() { + guard !disablePermissionOnboarding else { return } + guard !NotchSettings.shared.hasSeenAttachedOnboarding else { return } + NotchSettings.shared.hasSeenAttachedOnboarding = true + + let alert = NSAlert() + alert.messageText = "Allow Accessibility for Attached Overlay" + alert.informativeText = "Attached Overlay needs Accessibility access before it can follow another app window. Until access is granted, Textream will stay in the screen corner." + alert.addButton(withTitle: "Open System Settings") + alert.addButton(withTitle: "Continue with Fallback") + if alert.runModal() == .alertFirstButtonReturn { + WindowAnchorService.openAccessibilitySettings() + } + } func show(text: String, hasNextPage: Bool = false, onComplete: (() -> Void)? = nil) { self.onComplete = onComplete - self.onNextPage = { [weak self] in + self.onNextPage = { TextreamService.shared.advanceToNextPage() } self.isDismissing = false @@ -73,50 +174,20 @@ class NotchOverlayController: NSObject { overlayContent.words = normalized overlayContent.totalCharCount = normalized.joined(separator: " ").count overlayContent.hasNextPage = hasNextPage + overlayContent.highlightedCharCount = 0 + overlayContent.partialText = "" + overlayContent.manualIgnoreActive = false + speechRecognizer.updateText(text, preservingCharCount: 0) let settings = NotchSettings.shared - - let screen: NSScreen - switch settings.notchDisplayMode { - case .followMouse: - screen = screenUnderMouse() ?? NSScreen.main ?? NSScreen.screens[0] - case .fixedDisplay: - screen = NSScreen.screens.first(where: { $0.displayID == settings.pinnedScreenID }) ?? NSScreen.main ?? NSScreen.screens[0] - } - - let screenFrame = screen.frame - - if settings.overlayMode == .fullscreen { - let fsScreen: NSScreen - if settings.fullscreenScreenID != 0, - let match = NSScreen.screens.first(where: { $0.displayID == settings.fullscreenScreenID }) { - fsScreen = match - } else { - fsScreen = screen - } - showFullscreen(settings: settings, screen: fsScreen) - } else if settings.overlayMode == .floating && settings.followCursorWhenUndocked { - showFollowCursor(settings: settings, screen: screen) - } else { - switch settings.overlayMode { - case .pinned: - showPinned(settings: settings, screen: screen) - case .floating: - showFloating(settings: settings, screenFrame: screenFrame) - case .fullscreen: - break // handled above - } - } - - // Show floating stop button only in follow-cursor mode (panel ignores mouse events) - if settings.overlayMode == .floating && settings.followCursorWhenUndocked { - showStopButton(on: screen) - } + let preferredScreen = preferredScreen(for: settings) + presentOverlay(using: settings, preferredScreen: preferredScreen) // Word tracking & silence-paused need the microphone; classic does not if settings.listeningMode != .classic { speechRecognizer.start(with: text) } + updateHotkeyRegistration(for: settings.listeningMode) } func updateContent(text: String, hasNextPage: Bool) { @@ -127,10 +198,14 @@ class NotchOverlayController: NSObject { speechRecognizer.shouldDismiss = false speechRecognizer.shouldAdvancePage = false speechRecognizer.lastSpokenText = "" + overlayContent.highlightedCharCount = 0 + overlayContent.partialText = "" + overlayContent.manualIgnoreActive = false overlayContent.words = normalized overlayContent.totalCharCount = normalized.joined(separator: " ").count overlayContent.hasNextPage = hasNextPage + speechRecognizer.updateText(text, preservingCharCount: 0) let settings = NotchSettings.shared if settings.listeningMode != .classic { @@ -143,6 +218,124 @@ class NotchOverlayController: NSObject { return NSScreen.screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) }) } + private func preferredScreen(for settings: NotchSettings) -> NSScreen { + switch settings.overlayMode { + case .fullscreen: + if settings.fullscreenScreenID != 0, + let match = NSScreen.screens.first(where: { $0.displayID == settings.fullscreenScreenID }) { + return match + } + case .pinned: + if settings.notchDisplayMode == .fixedDisplay, + let pinned = NSScreen.screens.first(where: { $0.displayID == settings.pinnedScreenID }) { + return pinned + } + case .floating, .attached: + break + } + + if let panelScreen = panel?.screen { + return panelScreen + } + return screenUnderMouse() ?? NSScreen.main ?? NSScreen.screens[0] + } + + private func desiredPresentationMode(for settings: NotchSettings) -> OverlayPresentationMode { + switch settings.overlayMode { + case .pinned: + return .pinned + case .floating: + return settings.followCursorWhenUndocked ? .floatingFollowCursor : .floating + case .fullscreen: + return .fullscreen + case .attached: + return .attached + } + } + + private func presentOverlay(using settings: NotchSettings, preferredScreen: NSScreen) { + switch desiredPresentationMode(for: settings) { + case .fullscreen: + setAnchorDebugInactive(message: "Attached mode inactive while fullscreen overlay is active") + showFullscreen(settings: settings, screen: preferredScreen) + case .attached: + showAttached(settings: settings, fallbackScreen: preferredScreen) + case .floatingFollowCursor: + setAnchorDebugInactive(message: "Attached mode inactive while follow-cursor floating overlay is active") + showFollowCursor(settings: settings, screen: preferredScreen) + if shouldShowFloatingStopButton { + showStopButton(on: preferredScreen) + } + case .pinned: + setAnchorDebugInactive(message: "Attached mode inactive while pinned overlay is active") + showPinned(settings: settings, screen: preferredScreen) + case .floating: + setAnchorDebugInactive(message: "Attached mode inactive while floating overlay is active") + showFloating(settings: settings, screenFrame: preferredScreen.frame) + } + } + + func refreshPresentationForSettingsChange() { + guard isShowing else { return } + + let settings = NotchSettings.shared + let desiredMode = desiredPresentationMode(for: settings) + let shouldRebuild = desiredMode == .pinned + || desiredMode == .fullscreen + || !canRefreshPresentationInPlace(from: activePresentationMode, to: desiredMode) + + if shouldRebuild { + rebuildPresentation(using: settings) + return + } + + panel?.sharingType = settings.hideFromScreenShare ? .none : .readOnly + + switch desiredMode { + case .floating: + refreshFloatingPresentation(settings: settings, followingCursor: false) + case .floatingFollowCursor: + refreshFloatingPresentation(settings: settings, followingCursor: true) + case .attached: + refreshAttachedPresentation(settings: settings) + case .pinned, .fullscreen: + rebuildPresentation(using: settings) + } + } + + private func canRefreshPresentationInPlace( + from current: OverlayPresentationMode?, + to desired: OverlayPresentationMode + ) -> Bool { + switch (current, desired) { + case (.floating, .floating), + (.floating, .floatingFollowCursor), + (.floatingFollowCursor, .floating), + (.floatingFollowCursor, .floatingFollowCursor), + (.attached, .attached): + return true + default: + return false + } + } + + private func rebuildPresentation(using settings: NotchSettings) { + stopMouseTracking() + stopCursorTracking() + removeStopButton() + removeEscMonitor() + windowAnchorService.stopTracking() + panel?.delegate = nil + panel?.orderOut(nil) + panel = nil + frameTracker = nil + activePresentationMode = nil + + let preferred = preferredScreen(for: settings) + presentOverlay(using: settings, preferredScreen: preferred) + updateHotkeyRegistration(for: settings.listeningMode) + } + private func startMouseTracking() { mouseTrackingTimer?.cancel() mouseTrackingTimer = Timer.publish(every: 0.3, on: .main, in: .common) @@ -159,10 +352,13 @@ class NotchOverlayController: NSObject { private func startCursorTracking() { cursorTrackingTimer?.cancel() - cursorTrackingTimer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common) + let interval = shouldDriveLiveCursorTracking ? (1.0 / 60.0) : 0.25 + cursorTrackingTimer = Timer.publish(every: interval, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in - self?.updateCursorPosition() + guard let self else { return } + guard self.shouldDriveLiveCursorTracking else { return } + self.updateCursorPosition() } } @@ -177,18 +373,17 @@ class NotchOverlayController: NSObject { let cursorOffset: CGFloat = 8 let x = mouse.x + cursorOffset let h = panel.frame.height - var y = mouse.y - h + let y = mouse.y - h let w = panel.frame.width - // Keep panel below the menu bar so the status bar stop button stays visible + var nextFrame = NSRect(x: x, y: y, width: w, height: h) + if let screen = NSScreen.screens.first(where: { NSMouseInRect(mouse, $0.frame, false) }) { - let menuBarBottom = screen.visibleFrame.maxY - if y + h > menuBarBottom { - y = menuBarBottom - h - } + nextFrame = clamp(frame: nextFrame, within: screen.visibleFrame) + updateStopButton(on: screen) } - panel.setFrame(NSRect(x: x, y: y, width: w, height: h), display: false) + panel.setFrame(nextFrame, display: false) } private func checkMouseScreen() { @@ -212,7 +407,121 @@ class NotchOverlayController: NSObject { panel.setFrame(NSRect(x: x, y: y, width: w, height: h), display: true) } + private func refreshFloatingPresentation(settings: NotchSettings, followingCursor: Bool) { + guard let panel else { + rebuildPresentation(using: settings) + return + } + + let nextSize = CGSize(width: settings.notchWidth, height: settings.textAreaHeight) + panel.contentView = makeFloatingPanelContentView( + baseHeight: nextSize.height, + followingCursor: followingCursor + ) + panel.minSize = NSSize(width: NotchSettings.minWidth, height: NotchSettings.minHeight) + panel.maxSize = NSSize(width: NotchSettings.maxWidth, height: NotchSettings.maxHeight) + panel.ignoresMouseEvents = followingCursor + panel.isMovableByWindowBackground = !followingCursor + configureDirectResize(for: panel, enabled: !followingCursor) + + if followingCursor { + activePresentationMode = .floatingFollowCursor + let resized = CGRect(origin: panel.frame.origin, size: nextSize) + panel.setFrame(resized, display: true) + panel.orderFrontRegardless() + startCursorTracking() + if shouldDriveLiveCursorTracking { + updateCursorPosition() + } + if shouldShowFloatingStopButton, + let targetScreen = screenUnderMouse() ?? panel.screen ?? NSScreen.main { + showStopButton(on: targetScreen) + } else { + removeStopButton() + } + } else { + activePresentationMode = .floating + stopCursorTracking() + removeStopButton() + + let visibleFrame = (panel.screen ?? preferredScreen(for: settings)).visibleFrame + var nextFrame = CGRect(origin: panel.frame.origin, size: nextSize) + nextFrame = clamp(frame: nextFrame, within: visibleFrame) + panel.setFrame(nextFrame, display: true) + } + + installKeyMonitor() + } + + private func refreshAttachedPresentation(settings: NotchSettings) { + guard let panel else { + rebuildPresentation(using: settings) + return + } + + activePresentationMode = .attached + panel.minSize = NSSize(width: NotchSettings.minWidth, height: NotchSettings.minHeight) + panel.maxSize = NSSize(width: NotchSettings.maxWidth, height: NotchSettings.maxHeight) + configureDirectResize(for: panel, enabled: true) + + let nextSize = CGSize(width: settings.notchWidth, height: settings.textAreaHeight) + panel.setFrame(CGRect(origin: panel.frame.origin, size: nextSize), display: true) + + attachedDiagnostics.beginAttachedSession( + targetWindowID: settings.attachedTargetWindowID, + targetWindowLabel: settings.attachedTargetWindowLabel + ) + syncAttachedStatusFromDiagnostics() + + guard settings.attachedTargetWindowID != 0 else { + windowAnchorService.stopTracking() + let fallbackScreen = resolveFallbackScreen(preferred: preferredScreen(for: settings)) + let fallbackFrame = windowAnchorService.fallbackFrame( + overlaySize: nextSize, + corner: settings.attachedAnchorCorner, + marginX: settings.attachedMarginX, + marginY: settings.attachedMarginY, + on: fallbackScreen + ) + let fallbackResolution = WindowAnchorResolution( + frame: fallbackFrame, + window: nil, + source: .fallback, + isAccessibilityTrusted: WindowAnchorService.isAccessibilityTrusted(prompt: false), + message: "No target window is selected. Using the screen corner." + ) + updateAttachedFallbackFrame(on: fallbackScreen, resolution: fallbackResolution) + installKeyMonitor() + return + } + + if windowAnchorService.trackedWindowID != settings.attachedTargetWindowID { + windowAnchorService.startTracking(windowID: settings.attachedTargetWindowID) + } else { + windowAnchorService.emitCurrentResolution() + } + installKeyMonitor() + } + + private func makeFloatingPanelContentView(baseHeight: CGFloat, followingCursor: Bool) -> NSView { + if AppRuntime.isHeadlessTestRuntime { + let placeholder = NSView(frame: NSRect(x: 0, y: 0, width: 1, height: max(baseHeight, 1))) + placeholder.wantsLayer = true + placeholder.layer?.backgroundColor = NSColor.clear.cgColor + return placeholder + } + + let floatingView = FloatingOverlayView( + content: overlayContent, + speechRecognizer: speechRecognizer, + baseHeight: baseHeight, + followingCursor: followingCursor + ) + return NSHostingView(rootView: floatingView) + } + private func showPinned(settings: NotchSettings, screen: NSScreen) { + activePresentationMode = .pinned let notchWidth = settings.notchWidth let textAreaHeight = settings.textAreaHeight let maxExtraHeight: CGFloat = 350 @@ -266,6 +575,7 @@ class NotchOverlayController: NSObject { } private func showFollowCursor(settings: NotchSettings, screen: NSScreen) { + activePresentationMode = .floatingFollowCursor let panelWidth = settings.notchWidth let panelHeight = settings.textAreaHeight @@ -274,13 +584,10 @@ class NotchOverlayController: NSObject { let xPosition = mouse.x + cursorOffset let yPosition = mouse.y - panelHeight - let floatingView = FloatingOverlayView( - content: overlayContent, - speechRecognizer: speechRecognizer, + let contentView = makeFloatingPanelContentView( baseHeight: panelHeight, followingCursor: true ) - let contentView = NSHostingView(rootView: floatingView) let panel = NSPanel( contentRect: NSRect(x: xPosition, y: yPosition, width: panelWidth, height: panelHeight), @@ -294,8 +601,11 @@ class NotchOverlayController: NSObject { panel.level = .screenSaver panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.ignoresMouseEvents = true + panel.minSize = NSSize(width: NotchSettings.minWidth, height: NotchSettings.minHeight) + panel.maxSize = NSSize(width: NotchSettings.maxWidth, height: NotchSettings.maxHeight) panel.sharingType = NotchSettings.shared.hideFromScreenShare ? .none : .readOnly panel.contentView = contentView + panel.delegate = nil panel.orderFrontRegardless() self.panel = panel @@ -305,6 +615,7 @@ class NotchOverlayController: NSObject { } private func showFullscreen(settings: NotchSettings, screen: NSScreen) { + activePresentationMode = .fullscreen let screenFrame = screen.frame let fullscreenView = ExternalDisplayView( @@ -336,18 +647,17 @@ class NotchOverlayController: NSObject { } private func showFloating(settings: NotchSettings, screenFrame: CGRect) { + activePresentationMode = .floating let panelWidth = settings.notchWidth let panelHeight = settings.textAreaHeight let xPosition = screenFrame.midX - panelWidth / 2 let yPosition = screenFrame.midY - panelHeight / 2 + 100 - let floatingView = FloatingOverlayView( - content: overlayContent, - speechRecognizer: speechRecognizer, - baseHeight: panelHeight + let contentView = makeFloatingPanelContentView( + baseHeight: panelHeight, + followingCursor: false ) - let contentView = NSHostingView(rootView: floatingView) let panel = NSPanel( contentRect: NSRect(x: xPosition, y: yPosition, width: panelWidth, height: panelHeight), @@ -362,10 +672,11 @@ class NotchOverlayController: NSObject { panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.ignoresMouseEvents = false panel.isMovableByWindowBackground = true - panel.minSize = NSSize(width: 280, height: panelHeight) - panel.maxSize = NSSize(width: 500, height: panelHeight + 350) + panel.minSize = NSSize(width: NotchSettings.minWidth, height: NotchSettings.minHeight) + panel.maxSize = NSSize(width: NotchSettings.maxWidth, height: NotchSettings.maxHeight) panel.sharingType = NotchSettings.shared.hideFromScreenShare ? .none : .readOnly panel.contentView = contentView + panel.delegate = self panel.orderFrontRegardless() self.panel = panel @@ -373,22 +684,246 @@ class NotchOverlayController: NSObject { installKeyMonitor() } + private func showAttached(settings: NotchSettings, fallbackScreen: NSScreen) { + activePresentationMode = .attached + let panelWidth = settings.notchWidth + let panelHeight = settings.textAreaHeight + let fallbackFrame = windowAnchorService.fallbackFrame( + overlaySize: CGSize(width: panelWidth, height: panelHeight), + corner: settings.attachedAnchorCorner, + marginX: settings.attachedMarginX, + marginY: settings.attachedMarginY, + on: fallbackScreen + ) + + let contentView = makeFloatingPanelContentView( + baseHeight: panelHeight, + followingCursor: false + ) + + let panel = NSPanel( + contentRect: fallbackFrame, + styleMask: [.borderless, .nonactivatingPanel, .resizable], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .screenSaver + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.ignoresMouseEvents = false + panel.isMovableByWindowBackground = false + panel.minSize = NSSize(width: NotchSettings.minWidth, height: NotchSettings.minHeight) + panel.maxSize = NSSize(width: NotchSettings.maxWidth, height: NotchSettings.maxHeight) + panel.sharingType = NotchSettings.shared.hideFromScreenShare ? .none : .readOnly + panel.contentView = contentView + panel.delegate = self + panel.orderFrontRegardless() + self.panel = panel + + attachedDiagnostics.beginAttachedSession( + targetWindowID: settings.attachedTargetWindowID, + targetWindowLabel: settings.attachedTargetWindowLabel + ) + syncAttachedStatusFromDiagnostics() + + if !WindowAnchorService.isAccessibilityTrusted(prompt: false) { + let fallbackResolution = WindowAnchorResolution( + frame: fallbackFrame, + window: nil, + source: .fallback, + isAccessibilityTrusted: false, + message: "Accessibility access is not granted yet. Starting in the screen corner." + ) + updateAttachedFallbackFrame(on: fallbackScreen, resolution: fallbackResolution) + presentAttachedPermissionOnboardingIfNeeded() + } + + windowAnchorService.onResolutionChanged = { [weak self] resolution in + guard let self else { return } + DispatchQueue.main.async { + let settings = NotchSettings.shared + let fallbackScreen = self.resolveFallbackScreen( + preferred: fallbackScreen, + targetFrame: resolution.frame ?? resolution.window?.bounds + ) + let fallbackFrame = self.windowAnchorService.fallbackFrame( + overlaySize: self.panel?.frame.size ?? CGSize(width: settings.notchWidth, height: settings.textAreaHeight), + corner: settings.attachedAnchorCorner, + marginX: settings.attachedMarginX, + marginY: settings.attachedMarginY, + on: fallbackScreen + ) + + if !resolution.isAccessibilityTrusted { + let fallbackResolution = WindowAnchorResolution( + frame: fallbackFrame, + window: resolution.window, + source: .fallback, + isAccessibilityTrusted: false, + message: "Accessibility access is required. Using the screen corner." + ) + self.updateAttachedFallbackFrame(on: fallbackScreen, resolution: fallbackResolution) + return + } + + if let frame = resolution.frame { + self.applyAttachedFrame(frame, settings: settings) + self.attachedDiagnostics.updateResolution( + resolution, + targetWindowID: settings.attachedTargetWindowID, + targetWindowLabel: settings.attachedTargetWindowLabel, + overlayHidden: false + ) + self.syncAttachedStatusFromDiagnostics() + QADebugStore.shared.recordAnchor(resolution) + } else { + let fallbackResolution = resolution.with( + source: .fallback, + frame: fallbackFrame, + message: resolution.message + ) + self.updateAttachedFallbackFrame(on: fallbackScreen, resolution: fallbackResolution) + } + } + } + + if settings.attachedTargetWindowID != 0 { + windowAnchorService.startTracking(windowID: settings.attachedTargetWindowID) + } else { + let fallbackResolution = WindowAnchorResolution( + frame: fallbackFrame, + window: nil, + source: .fallback, + isAccessibilityTrusted: WindowAnchorService.isAccessibilityTrusted(prompt: false), + message: "No target window is selected. Using the screen corner." + ) + updateAttachedFallbackFrame(on: fallbackScreen, resolution: fallbackResolution) + } + + installKeyMonitor() + } + + private func applyAttachedFrame(_ targetFrame: CGRect, settings: NotchSettings) { + guard let panel else { return } + let overlaySize = CGSize(width: panel.frame.width, height: panel.frame.height) + let targetVisibleFrame = windowAnchorService.screen( + for: targetFrame, + corner: settings.attachedAnchorCorner + )?.visibleFrame + let origin = windowAnchorService.anchoredOrigin( + targetFrame: targetFrame, + overlaySize: overlaySize, + corner: settings.attachedAnchorCorner, + marginX: settings.attachedMarginX, + marginY: settings.attachedMarginY, + within: targetVisibleFrame + ) + let nextFrame = CGRect(origin: origin, size: overlaySize) + panel.setFrame(nextFrame, display: true) + panel.alphaValue = 1 + } + + private func updateAttachedFallbackFrame(on _: NSScreen, resolution: WindowAnchorResolution) { + guard let panel else { return } + let settings = NotchSettings.shared + let shouldForceVisibleFallback = !resolution.isAccessibilityTrusted || settings.attachedTargetWindowID == 0 + let isWindowAvailable = resolution.window != nil && resolution.source != .fallback && resolution.source != .unavailable + + if !shouldForceVisibleFallback && + !isWindowAvailable && + settings.attachedHideWhenWindowUnavailable && + settings.attachedFallbackBehavior == .hideOverlay { + panel.alphaValue = 0 + let hiddenResolution = resolution.with(message: "\(resolution.message) Overlay hidden by attached fallback policy") + attachedDiagnostics.updateResolution( + hiddenResolution, + targetWindowID: settings.attachedTargetWindowID, + targetWindowLabel: settings.attachedTargetWindowLabel, + overlayHidden: true + ) + syncAttachedStatusFromDiagnostics() + QADebugStore.shared.recordAnchor(hiddenResolution) + return + } + + if shouldForceVisibleFallback || settings.attachedFallbackBehavior == .screenCorner { + panel.setFrame(resolution.frame ?? panel.frame, display: true) + panel.alphaValue = 1 + } else { + panel.alphaValue = isWindowAvailable ? 1 : 0 + } + + attachedDiagnostics.updateResolution( + resolution, + targetWindowID: settings.attachedTargetWindowID, + targetWindowLabel: settings.attachedTargetWindowLabel, + overlayHidden: panel.alphaValue == 0 + ) + syncAttachedStatusFromDiagnostics() + QADebugStore.shared.recordAnchor(resolution) + } + + private func startHotkeys() { + hotkeyController.onToggleAside = { [weak self] in + self?.speechRecognizer.toggleAsideMode() + } + hotkeyController.onHoldIgnoreChanged = { [weak self] active in + self?.speechRecognizer.setTemporaryIgnoreActive(active) + } + hotkeyController.start() + } + + private func updateHotkeyRegistration(for listeningMode: ListeningMode) { + if listeningMode == .wordTracking { + startHotkeys() + } else { + hotkeyController.stop() + } + } + + private func completeOverlayDismissal(anchorMessage: String) { + stopMouseTracking() + stopCursorTracking() + removeStopButton() + removeEscMonitor() + cancellables.removeAll() + windowAnchorService.stopTracking() + windowAnchorService.onResolutionChanged = nil + setAnchorDebugInactive(message: anchorMessage) + hotkeyController.stop() + panel?.delegate = nil + panel?.orderOut(nil) + panel = nil + frameTracker = nil + activePresentationMode = nil + speechRecognizer.shouldDismiss = false + isDismissing = false + onComplete?() + } + func dismiss() { + guard panel != nil, !isDismissing else { return } + isDismissing = true + // Trigger the shrink animation speechRecognizer.shouldDismiss = true speechRecognizer.forceStop() + hotkeyController.stop() + windowAnchorService.stopTracking() + setAnchorDebugInactive(message: "Overlay dismissed") - // Wait for animation, then remove panel - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in - self?.stopMouseTracking() - self?.stopCursorTracking() - self?.removeStopButton() - self?.removeEscMonitor() - self?.panel?.orderOut(nil) - self?.panel = nil - self?.frameTracker = nil - self?.speechRecognizer.shouldDismiss = false - self?.onComplete?() + let finalize = { [weak self] in + self?.completeOverlayDismissal(anchorMessage: "Overlay dismissed") + } + if AppRuntime.isHeadlessTestRuntime { + finalize() + } else { + // Wait for animation, then remove panel + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + finalize() + } } } @@ -414,11 +949,20 @@ class NotchOverlayController: NSObject { removeStopButton() removeEscMonitor() cancellables.removeAll() + windowAnchorService.stopTracking() + windowAnchorService.onResolutionChanged = nil + setAnchorDebugInactive(message: "Overlay force-closed") + hotkeyController.stop() speechRecognizer.forceStop() speechRecognizer.recognizedCharCount = 0 + overlayContent.highlightedCharCount = 0 + overlayContent.partialText = "" + panel?.delegate = nil panel?.orderOut(nil) panel = nil frameTracker = nil + activePresentationMode = nil + isDismissing = false speechRecognizer.shouldDismiss = false speechRecognizer.shouldAdvancePage = false } @@ -447,46 +991,117 @@ class NotchOverlayController: NSObject { .sink { [weak self] _ in guard let self, self.speechRecognizer.shouldDismiss, !self.isDismissing else { return } self.isDismissing = true - // Wait for shrink animation, then cleanup - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - self.stopMouseTracking() - self.stopCursorTracking() - self.removeStopButton() - self.removeEscMonitor() - self.cancellables.removeAll() - self.panel?.orderOut(nil) - self.panel = nil - self.frameTracker = nil - self.speechRecognizer.shouldDismiss = false - self.onComplete?() + let finalize = { + self.completeOverlayDismissal(anchorMessage: "Overlay dismissed after completion") + } + if AppRuntime.isHeadlessTestRuntime { + finalize() + } else { + // Wait for shrink animation, then cleanup + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + finalize() + } } } .store(in: &cancellables) } + private func resolveFallbackScreen(preferred: NSScreen, targetFrame: CGRect? = nil) -> NSScreen { + if let targetFrame, let targetScreen = windowAnchorService.screen(for: targetFrame) { + return targetScreen + } + if let panelScreen = panel?.screen { + return panelScreen + } + return NSScreen.main ?? preferred + } + + private func setAnchorDebugInactive(message: String = "Attached mode inactive") { + attachedDiagnostics.markInactive( + message: message, + targetWindowID: NotchSettings.shared.attachedTargetWindowID, + targetWindowLabel: NotchSettings.shared.attachedTargetWindowLabel + ) + syncAttachedStatusFromDiagnostics() + QADebugStore.shared.recordAnchor( + WindowAnchorResolution( + frame: nil, + window: nil, + source: .unavailable, + isAccessibilityTrusted: WindowAnchorService.isAccessibilityTrusted(prompt: false), + message: message + ) + ) + } + var isShowing: Bool { panel != nil } + var debugPanelFrame: CGRect? { + panel?.frame + } + + var debugPanelAlpha: CGFloat { + panel?.alphaValue ?? 0 + } + + var debugHotkeysRunning: Bool { + hotkeyController.isRunning + } + + var debugCursorTrackingRunning: Bool { + cursorTrackingTimer != nil + } + + var debugPresentationMode: String { + switch activePresentationMode { + case .pinned: return "pinned" + case .floating: return "floating" + case .floatingFollowCursor: return "floatingFollowCursor" + case .fullscreen: return "fullscreen" + case .attached: return "attached" + case nil: return "inactive" + } + } + + var debugTrackedWindowID: Int? { + windowAnchorService.trackedWindowID + } + + func debugRefreshAttachedResolution() { + windowAnchorService.emitCurrentResolution() + } + + func debugSimulateUserResize(to size: CGSize) { + guard let panel else { return } + isUserResizingPrimaryPanel = true + panel.setFrame(CGRect(origin: panel.frame.origin, size: size), display: true) + syncPanelSizeToSettings(from: panel) + isUserResizingPrimaryPanel = false + if activePresentationMode == .attached { + refreshAttachedPresentation(settings: NotchSettings.shared) + } + } + + func debugUpdateHotkeyRegistration(for listeningMode: ListeningMode) { + updateHotkeyRegistration(for: listeningMode) + } + // MARK: - Floating Stop Button private func showStopButton(on screen: NSScreen) { - guard stopButtonPanel == nil else { return } - - let buttonSize: CGFloat = 36 - let margin: CGFloat = 8 - let screenFrame = screen.frame - let visibleFrame = screen.visibleFrame - let menuBarBottom = visibleFrame.maxY - let x = screenFrame.midX - buttonSize / 2 - let y = menuBarBottom - buttonSize - margin + if let stopButtonPanel { + stopButtonPanel.setFrame(stopButtonFrame(on: screen), display: true) + return + } let stopView = NSHostingView(rootView: StopButtonView { self.dismiss() }) let panel = NSPanel( - contentRect: NSRect(x: x, y: y, width: buttonSize, height: buttonSize), + contentRect: stopButtonFrame(on: screen), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false @@ -504,16 +1119,101 @@ class NotchOverlayController: NSObject { } private func removeStopButton() { - stopButtonPanel?.orderOut(nil) + stopButtonPanel?.contentView = nil + stopButtonPanel?.close() stopButtonPanel = nil } + private func stopButtonFrame(on screen: NSScreen) -> CGRect { + let buttonSize: CGFloat = 36 + let margin: CGFloat = 8 + let screenFrame = screen.frame + let menuBarBottom = screen.visibleFrame.maxY + let x = screenFrame.midX - buttonSize / 2 + let y = menuBarBottom - buttonSize - margin + return CGRect(x: x, y: y, width: buttonSize, height: buttonSize) + } + + private func updateStopButton(on screen: NSScreen) { + guard let stopButtonPanel else { return } + stopButtonPanel.setFrame(stopButtonFrame(on: screen), display: true) + } + + private func configureDirectResize(for panel: NSPanel, enabled: Bool) { + var styleMask = panel.styleMask + if enabled { + styleMask.insert(.resizable) + panel.delegate = self + } else { + styleMask.remove(.resizable) + if panel.delegate === self { + panel.delegate = nil + } + } + panel.styleMask = styleMask + } + + private func syncPanelSizeToSettings(from window: NSWindow) { + guard window === panel else { return } + + let settings = NotchSettings.shared + guard activePresentationMode == .floating || activePresentationMode == .attached else { return } + guard activePresentationMode != .floatingFollowCursor else { return } + + let clampedWidth = min(max(window.frame.width, NotchSettings.minWidth), NotchSettings.maxWidth) + let clampedHeight = min(max(window.frame.height, NotchSettings.minHeight), NotchSettings.maxHeight) + + if abs(settings.notchWidth - clampedWidth) > 0.5 { + settings.notchWidth = clampedWidth + } + if abs(settings.textAreaHeight - clampedHeight) > 0.5 { + settings.textAreaHeight = clampedHeight + } + } + + private func clamp(frame: CGRect, within visibleFrame: CGRect) -> CGRect { + guard !visibleFrame.isEmpty else { return frame } + + let width = min(frame.width, visibleFrame.width) + let height = min(frame.height, visibleFrame.height) + let maxX = max(visibleFrame.minX, visibleFrame.maxX - width) + let maxY = max(visibleFrame.minY, visibleFrame.maxY - height) + + return CGRect( + x: min(max(frame.minX, visibleFrame.minX), maxX), + y: min(max(frame.minY, visibleFrame.minY), maxY), + width: width, + height: height + ) + } + private func removeEscMonitor() { if let escMonitor { NSEvent.removeMonitor(escMonitor) } escMonitor = nil } + + func windowWillStartLiveResize(_ notification: Notification) { + guard notification.object as AnyObject? === panel else { return } + isUserResizingPrimaryPanel = true + } + + func windowDidResize(_ notification: Notification) { + guard isUserResizingPrimaryPanel, + let window = notification.object as? NSWindow else { return } + syncPanelSizeToSettings(from: window) + } + + func windowDidEndLiveResize(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + syncPanelSizeToSettings(from: window) + isUserResizingPrimaryPanel = false + + if activePresentationMode == .attached { + refreshAttachedPresentation(settings: NotchSettings.shared) + } + } } // MARK: - Floating Stop Button View @@ -642,6 +1342,17 @@ struct NotchOverlayView: View { NotchSettings.shared.listeningMode } + private var hudItems: [HUDPresentationItem] { + PersistentHUDPresenter.items( + content: content, + isListening: speechRecognizer.isListening, + configuration: HUDPresentationConfiguration( + isEnabled: NotchSettings.shared.persistentHUDEnabled, + modules: NotchSettings.shared.hudModules + ) + ) + } + /// Convert fractional word index to char offset using actual word lengths private func charOffsetForWordProgress(_ progress: Double) -> Int { let wholeWord = Int(progress) @@ -673,7 +1384,7 @@ struct NotchOverlayView: View { private var effectiveCharCount: Int { switch listeningMode { case .wordTracking: - return speechRecognizer.recognizedCharCount + return content.highlightedCharCount case .classic, .silencePaused: return charOffsetForWordProgress(timerWordProgress) } @@ -816,6 +1527,17 @@ struct NotchOverlayView: View { } } + private var shouldShowStatusBlock: Bool { + listeningMode == .wordTracking || content.attachedRequiresAttention + } + + private var secondaryStatusText: String { + if content.attachedRequiresAttention { + return content.attachedDetailLine + } + return speechRecognizer.lastSpokenText.split(separator: " ").suffix(4).joined(separator: " ") + } + private var prompterView: some View { VStack(spacing: 0) { SpeechScrollView( @@ -858,13 +1580,21 @@ struct NotchOverlayView: View { .frame(width: 80, height: 24) .clipped() - if listeningMode == .wordTracking { - Text(speechRecognizer.lastSpokenText.split(separator: " ").suffix(3).joined(separator: " ")) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.white.opacity(0.5)) - .lineLimit(1) - .truncationMode(.head) - .frame(maxWidth: .infinity, alignment: .leading) + if shouldShowStatusBlock { + VStack(alignment: .leading, spacing: 1) { + Text(content.statusLine.isEmpty ? content.trackingState.label : content.statusLine) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + if !secondaryStatusText.isEmpty { + Text(secondaryStatusText) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white.opacity(0.38)) + .lineLimit(1) + .truncationMode(.head) + } + } + .frame(maxWidth: .infinity, alignment: .leading) } else { Spacer(minLength: 0) } @@ -956,6 +1686,18 @@ struct NotchOverlayView: View { .padding(.horizontal, 12) .padding(.bottom, 10) + if !hudItems.isEmpty { + PersistentHUDStripView(items: hudItems, compact: true) + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + + if NotchSettings.shared.qaDebugOverlayEnabled { + QADebugOverlayView(speechRecognizer: speechRecognizer, compact: true) + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + // Resize handle - only visible on hover if isHovering { VStack(spacing: 0) { @@ -1159,6 +1901,17 @@ struct FloatingOverlayView: View { NotchSettings.shared.listeningMode } + private var hudItems: [HUDPresentationItem] { + PersistentHUDPresenter.items( + content: content, + isListening: speechRecognizer.isListening, + configuration: HUDPresentationConfiguration( + isEnabled: NotchSettings.shared.persistentHUDEnabled, + modules: NotchSettings.shared.hudModules + ) + ) + } + /// Convert fractional word index to char offset using actual word lengths private func charOffsetForWordProgress(_ progress: Double) -> Int { let wholeWord = Int(progress) @@ -1190,7 +1943,7 @@ struct FloatingOverlayView: View { private var effectiveCharCount: Int { switch listeningMode { case .wordTracking: - return speechRecognizer.recognizedCharCount + return content.highlightedCharCount case .classic, .silencePaused: return charOffsetForWordProgress(timerWordProgress) } @@ -1209,6 +1962,17 @@ struct FloatingOverlayView: View { } } + private var shouldShowStatusBlock: Bool { + listeningMode == .wordTracking || content.attachedRequiresAttention + } + + private var secondaryStatusText: String { + if content.attachedRequiresAttention { + return content.attachedDetailLine + } + return speechRecognizer.lastSpokenText.split(separator: " ").suffix(4).joined(separator: " ") + } + var body: some View { VStack(spacing: 0) { if content.showPagePicker { @@ -1333,13 +2097,21 @@ struct FloatingOverlayView: View { ) .frame(width: 160, height: 24) - if listeningMode == .wordTracking { - Text(speechRecognizer.lastSpokenText.split(separator: " ").suffix(3).joined(separator: " ")) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.white.opacity(0.5)) - .lineLimit(1) - .truncationMode(.head) - .frame(maxWidth: .infinity, alignment: .leading) + if shouldShowStatusBlock { + VStack(alignment: .leading, spacing: 1) { + Text(content.statusLine.isEmpty ? content.trackingState.label : content.statusLine) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.72)) + .lineLimit(1) + if !secondaryStatusText.isEmpty { + Text(secondaryStatusText) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white.opacity(0.38)) + .lineLimit(1) + .truncationMode(.head) + } + } + .frame(maxWidth: .infinity, alignment: .leading) } else { Spacer() } @@ -1431,7 +2203,19 @@ struct FloatingOverlayView: View { } .frame(height: 24) .padding(.horizontal, 16) - .padding(.bottom, 12) + .padding(.bottom, 8) + + if !hudItems.isEmpty { + PersistentHUDStripView(items: hudItems, compact: true) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + + if NotchSettings.shared.qaDebugOverlayEnabled { + QADebugOverlayView(speechRecognizer: speechRecognizer, compact: true) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } } } diff --git a/Textream/Textream/NotchSettings.swift b/Textream/Textream/NotchSettings.swift index 1a02c9d..2279511 100644 --- a/Textream/Textream/NotchSettings.swift +++ b/Textream/Textream/NotchSettings.swift @@ -167,8 +167,8 @@ enum CueBrightness: String, CaseIterable, Identifiable { // MARK: - Overlay Mode -enum OverlayMode: String, CaseIterable, Identifiable { - case pinned, floating, fullscreen +enum OverlayMode: String, CaseIterable, Identifiable, Codable { + case pinned, floating, fullscreen, attached var id: String { rawValue } @@ -177,14 +177,16 @@ enum OverlayMode: String, CaseIterable, Identifiable { case .pinned: return "Pinned to Notch" case .floating: return "Floating Window" case .fullscreen: return "Fullscreen" + case .attached: return "Attached Overlay" } } var description: String { switch self { case .pinned: return "Anchored below the notch at the top of your screen." - case .floating: return "A draggable window you can place anywhere. Always on top." + case .floating: return "A floating teleprompter you can drag anywhere, or switch into Follow Cursor." case .fullscreen: return "Fullscreen teleprompter on the selected display. Press Esc to stop." + case .attached: return "Follows a selected app window corner and falls back to the screen corner if macOS cannot keep a stable anchor." } } @@ -193,10 +195,120 @@ enum OverlayMode: String, CaseIterable, Identifiable { case .pinned: return "rectangle.topthird.inset.filled" case .floating: return "macwindow.on.rectangle" case .fullscreen: return "rectangle.fill" + case .attached: return "uiwindow.split.2x1" } } } +enum ManualAsideHotkey: String, CaseIterable, Identifiable, Codable { + case optionDoubleTap + + var id: String { rawValue } + + var label: String { + switch self { + case .optionDoubleTap: return "Option Double-Tap" + } + } +} + +enum TemporaryIgnoreHotkey: String, CaseIterable, Identifiable, Codable { + case fnHold + + var id: String { rawValue } + + var label: String { + switch self { + case .fnHold: return "Hold Fn" + } + } +} + +enum AttachedFallbackBehavior: String, CaseIterable, Identifiable, Codable { + case screenCorner + case hideOverlay + + var id: String { rawValue } + + var label: String { + switch self { + case .screenCorner: return "Screen Corner" + case .hideOverlay: return "Hide Overlay" + } + } +} + +enum LayoutPreset: String, CaseIterable, Identifiable, Codable { + case custom + case interview + case liveStream + case presentation + case dualDisplay + case sidecar + + var id: String { rawValue } + + var label: String { + switch self { + case .custom: return "Custom" + case .interview: return "Interview" + case .liveStream: return "Live Stream" + case .presentation: return "Presentation" + case .dualDisplay: return "Dual Display" + case .sidecar: return "Sidecar iPad" + } + } + + static var recommendedCases: [LayoutPreset] { + [.interview, .liveStream, .presentation] + } + + var isLegacyBuiltIn: Bool { + switch self { + case .dualDisplay, .sidecar: + return true + default: + return false + } + } +} + +enum HUDModule: String, CaseIterable, Identifiable, Codable { + case trackingState + case expectedWord + case nextCue + case microphoneStatus + case elapsedTime + + var id: String { rawValue } + + var label: String { + switch self { + case .trackingState: return "Tracking State" + case .expectedWord: return "Expected Word" + case .nextCue: return "Next Cue" + case .microphoneStatus: return "Microphone" + case .elapsedTime: return "Elapsed Time" + } + } +} + +struct CustomLayoutPreset: Codable, Identifiable, Hashable { + let id: UUID + var name: String + var overlayMode: OverlayMode + var notchWidth: Double + var textAreaHeight: Double + var floatingGlassEffect: Bool + var glassOpacity: Double + var showElapsedTime: Bool + var persistentHUDEnabled: Bool + var hudModules: [HUDModule] + var attachedAnchorCorner: AttachedAnchorCorner + var attachedMarginX: Double + var attachedMarginY: Double +} + // MARK: - Notch Display Mode enum NotchDisplayMode: String, CaseIterable, Identifiable { @@ -206,14 +318,14 @@ enum NotchDisplayMode: String, CaseIterable, Identifiable { var label: String { switch self { - case .followMouse: return "Follow Mouse" + case .followMouse: return "Follow Mouse Display" case .fixedDisplay: return "Fixed Display" } } var description: String { switch self { - case .followMouse: return "The notch moves to whichever display your mouse is on." + case .followMouse: return "The pinned notch stays at the top camera area, but it moves to whichever display currently holds your mouse pointer." case .fixedDisplay: return "The notch stays on the selected display." } } @@ -354,6 +466,34 @@ class NotchSettings { didSet { UserDefaults.standard.set(overlayMode.rawValue, forKey: "overlayMode") } } + var strictTrackingEnabled: Bool { + didSet { UserDefaults.standard.set(strictTrackingEnabled, forKey: "strictTrackingEnabled") } + } + + var legacyTrackingFallbackEnabled: Bool { + didSet { UserDefaults.standard.set(legacyTrackingFallbackEnabled, forKey: "legacyTrackingFallbackEnabled") } + } + + var manualAsideHotkey: ManualAsideHotkey { + didSet { UserDefaults.standard.set(manualAsideHotkey.rawValue, forKey: "manualAsideHotkey") } + } + + var temporaryIgnoreHotkey: TemporaryIgnoreHotkey { + didSet { UserDefaults.standard.set(temporaryIgnoreHotkey.rawValue, forKey: "temporaryIgnoreHotkey") } + } + + var matchWindowSize: Int { + didSet { UserDefaults.standard.set(matchWindowSize, forKey: "matchWindowSize") } + } + + var advanceThreshold: Double { + didSet { UserDefaults.standard.set(advanceThreshold, forKey: "advanceThreshold") } + } + + var offScriptFreezeDelay: Double { + didSet { UserDefaults.standard.set(offScriptFreezeDelay, forKey: "offScriptFreezeDelay") } + } + var notchDisplayMode: NotchDisplayMode { didSet { UserDefaults.standard.set(notchDisplayMode.rawValue, forKey: "notchDisplayMode") } } @@ -374,6 +514,42 @@ class NotchSettings { didSet { UserDefaults.standard.set(followCursorWhenUndocked, forKey: "followCursorWhenUndocked") } } + var attachedAnchorCorner: AttachedAnchorCorner { + didSet { UserDefaults.standard.set(attachedAnchorCorner.rawValue, forKey: "attachedAnchorCorner") } + } + + var attachedMarginX: Double { + didSet { UserDefaults.standard.set(attachedMarginX, forKey: "attachedMarginX") } + } + + var attachedMarginY: Double { + didSet { UserDefaults.standard.set(attachedMarginY, forKey: "attachedMarginY") } + } + + var attachedFallbackBehavior: AttachedFallbackBehavior { + didSet { UserDefaults.standard.set(attachedFallbackBehavior.rawValue, forKey: "attachedFallbackBehavior") } + } + + var attachedTargetWindowID: Int { + didSet { UserDefaults.standard.set(attachedTargetWindowID, forKey: "attachedTargetWindowID") } + } + + var attachedTargetWindowLabel: String { + didSet { UserDefaults.standard.set(attachedTargetWindowLabel, forKey: "attachedTargetWindowLabel") } + } + + var attachedHideWhenWindowUnavailable: Bool { + didSet { UserDefaults.standard.set(attachedHideWhenWindowUnavailable, forKey: "attachedHideWhenWindowUnavailable") } + } + + var hasSeenAttachedOnboarding: Bool { + didSet { UserDefaults.standard.set(hasSeenAttachedOnboarding, forKey: "hasSeenAttachedOnboarding") } + } + + var hasSeenAccessibilityLaunchGuide: Bool { + didSet { UserDefaults.standard.set(hasSeenAccessibilityLaunchGuide, forKey: "hasSeenAccessibilityLaunchGuide") } + } + var externalDisplayMode: ExternalDisplayMode { didSet { UserDefaults.standard.set(externalDisplayMode.rawValue, forKey: "externalDisplayMode") } } @@ -441,6 +617,34 @@ class NotchSettings { didSet { UserDefaults.standard.set(Int(directorServerPort), forKey: "directorServerPort") } } + var activeLayoutPreset: LayoutPreset { + didSet { UserDefaults.standard.set(activeLayoutPreset.rawValue, forKey: "activeLayoutPreset") } + } + + var customPresets: [CustomLayoutPreset] { + didSet { saveCustomPresets() } + } + + var persistentHUDEnabled: Bool { + didSet { UserDefaults.standard.set(persistentHUDEnabled, forKey: "persistentHUDEnabled") } + } + + var hudModules: [HUDModule] { + didSet { saveHUDModules() } + } + + var qaDebugOverlayEnabled: Bool { + didSet { UserDefaults.standard.set(qaDebugOverlayEnabled, forKey: "qaDebugOverlayEnabled") } + } + + var trackingDebugLoggingEnabled: Bool { + didSet { UserDefaults.standard.set(trackingDebugLoggingEnabled, forKey: "trackingDebugLoggingEnabled") } + } + + var anchorDebugLoggingEnabled: Bool { + didSet { UserDefaults.standard.set(anchorDebugLoggingEnabled, forKey: "anchorDebugLoggingEnabled") } + } + var font: NSFont { fontFamilyPreset.font(size: fontSizePreset.pointSize) } @@ -448,6 +652,19 @@ class NotchSettings { static let defaultWidth: CGFloat = 340 static let defaultHeight: CGFloat = 150 static let defaultLocale: String = Locale.current.identifier + static let defaultStrictTrackingEnabled = true + static let defaultLegacyTrackingFallbackEnabled = true + static let defaultMatchWindowSize = 6 + static let defaultAdvanceThreshold = 3.4 + static let defaultOffScriptFreezeDelay = 0.9 + static let defaultAttachedAnchorCorner: AttachedAnchorCorner = .topRight + static let defaultAttachedMarginX = 16.0 + static let defaultAttachedMarginY = 14.0 + static let defaultAttachedFallbackBehavior: AttachedFallbackBehavior = .screenCorner + static let defaultAttachedHideWhenWindowUnavailable = false + static let defaultActiveLayoutPreset: LayoutPreset = .custom + static let defaultPersistentHUDEnabled = true + static let defaultHUDModules: [HUDModule] = [.trackingState, .expectedWord] static let minWidth: CGFloat = 310 static let maxWidth: CGFloat = 500 @@ -466,6 +683,16 @@ class NotchSettings { self.cueColorPreset = FontColorPreset(rawValue: UserDefaults.standard.string(forKey: "cueColorPreset") ?? "") ?? .white self.cueBrightness = CueBrightness(rawValue: UserDefaults.standard.string(forKey: "cueBrightness") ?? "") ?? .dim self.overlayMode = OverlayMode(rawValue: UserDefaults.standard.string(forKey: "overlayMode") ?? "") ?? .pinned + self.strictTrackingEnabled = UserDefaults.standard.object(forKey: "strictTrackingEnabled") as? Bool ?? Self.defaultStrictTrackingEnabled + self.legacyTrackingFallbackEnabled = UserDefaults.standard.object(forKey: "legacyTrackingFallbackEnabled") as? Bool ?? Self.defaultLegacyTrackingFallbackEnabled + self.manualAsideHotkey = ManualAsideHotkey(rawValue: UserDefaults.standard.string(forKey: "manualAsideHotkey") ?? "") ?? .optionDoubleTap + self.temporaryIgnoreHotkey = TemporaryIgnoreHotkey(rawValue: UserDefaults.standard.string(forKey: "temporaryIgnoreHotkey") ?? "") ?? .fnHold + let savedMatchWindow = UserDefaults.standard.integer(forKey: "matchWindowSize") + self.matchWindowSize = savedMatchWindow > 0 ? savedMatchWindow : Self.defaultMatchWindowSize + let savedAdvanceThreshold = UserDefaults.standard.double(forKey: "advanceThreshold") + self.advanceThreshold = savedAdvanceThreshold > 0 ? savedAdvanceThreshold : Self.defaultAdvanceThreshold + let savedFreezeDelay = UserDefaults.standard.double(forKey: "offScriptFreezeDelay") + self.offScriptFreezeDelay = savedFreezeDelay > 0 ? savedFreezeDelay : Self.defaultOffScriptFreezeDelay self.notchDisplayMode = NotchDisplayMode(rawValue: UserDefaults.standard.string(forKey: "notchDisplayMode") ?? "") ?? .followMouse let savedPinnedScreenID = UserDefaults.standard.integer(forKey: "pinnedScreenID") self.pinnedScreenID = UInt32(savedPinnedScreenID) @@ -473,6 +700,17 @@ class NotchSettings { let savedOpacity = UserDefaults.standard.double(forKey: "glassOpacity") self.glassOpacity = savedOpacity > 0 ? savedOpacity : 0.15 self.followCursorWhenUndocked = UserDefaults.standard.object(forKey: "followCursorWhenUndocked") as? Bool ?? false + self.attachedAnchorCorner = AttachedAnchorCorner(rawValue: UserDefaults.standard.string(forKey: "attachedAnchorCorner") ?? "") ?? Self.defaultAttachedAnchorCorner + let savedAttachedMarginX = UserDefaults.standard.double(forKey: "attachedMarginX") + self.attachedMarginX = savedAttachedMarginX > 0 ? savedAttachedMarginX : Self.defaultAttachedMarginX + let savedAttachedMarginY = UserDefaults.standard.double(forKey: "attachedMarginY") + self.attachedMarginY = savedAttachedMarginY > 0 ? savedAttachedMarginY : Self.defaultAttachedMarginY + self.attachedFallbackBehavior = AttachedFallbackBehavior(rawValue: UserDefaults.standard.string(forKey: "attachedFallbackBehavior") ?? "") ?? Self.defaultAttachedFallbackBehavior + self.attachedTargetWindowID = UserDefaults.standard.integer(forKey: "attachedTargetWindowID") + self.attachedTargetWindowLabel = UserDefaults.standard.string(forKey: "attachedTargetWindowLabel") ?? "" + self.attachedHideWhenWindowUnavailable = UserDefaults.standard.object(forKey: "attachedHideWhenWindowUnavailable") as? Bool ?? Self.defaultAttachedHideWhenWindowUnavailable + self.hasSeenAttachedOnboarding = UserDefaults.standard.object(forKey: "hasSeenAttachedOnboarding") as? Bool ?? false + self.hasSeenAccessibilityLaunchGuide = UserDefaults.standard.object(forKey: "hasSeenAccessibilityLaunchGuide") as? Bool ?? false self.externalDisplayMode = ExternalDisplayMode(rawValue: UserDefaults.standard.string(forKey: "externalDisplayMode") ?? "") ?? .off let savedScreenID = UserDefaults.standard.integer(forKey: "externalScreenID") self.externalScreenID = UInt32(savedScreenID) @@ -494,5 +732,135 @@ class NotchSettings { self.directorModeEnabled = UserDefaults.standard.object(forKey: "directorModeEnabled") as? Bool ?? false let savedDirectorPort = UserDefaults.standard.integer(forKey: "directorServerPort") self.directorServerPort = savedDirectorPort > 0 ? UInt16(savedDirectorPort) : 7575 + self.activeLayoutPreset = LayoutPreset(rawValue: UserDefaults.standard.string(forKey: "activeLayoutPreset") ?? "") ?? Self.defaultActiveLayoutPreset + self.customPresets = Self.loadCustomPresets() + self.persistentHUDEnabled = UserDefaults.standard.object(forKey: "persistentHUDEnabled") as? Bool ?? Self.defaultPersistentHUDEnabled + self.hudModules = Self.loadHUDModules() + self.qaDebugOverlayEnabled = UserDefaults.standard.object(forKey: "qaDebugOverlayEnabled") as? Bool ?? false + self.trackingDebugLoggingEnabled = UserDefaults.standard.object(forKey: "trackingDebugLoggingEnabled") as? Bool ?? false + self.anchorDebugLoggingEnabled = UserDefaults.standard.object(forKey: "anchorDebugLoggingEnabled") as? Bool ?? false + } + + func applyBuiltInPreset(_ preset: LayoutPreset) { + switch preset { + case .custom: + break + case .interview: + overlayMode = .floating + notchWidth = 320 + textAreaHeight = 140 + floatingGlassEffect = true + glassOpacity = 0.12 + showElapsedTime = true + persistentHUDEnabled = true + hudModules = [.trackingState] + externalDisplayMode = .off + case .liveStream: + overlayMode = .attached + notchWidth = 350 + textAreaHeight = 170 + attachedAnchorCorner = .topRight + floatingGlassEffect = true + glassOpacity = 0.16 + showElapsedTime = true + persistentHUDEnabled = true + hudModules = Self.defaultHUDModules + case .presentation: + overlayMode = .pinned + notchWidth = 430 + textAreaHeight = 220 + fontSizePreset = .xl + cueBrightness = .bright + showElapsedTime = true + persistentHUDEnabled = true + hudModules = Self.defaultHUDModules + externalDisplayMode = .off + case .dualDisplay: + overlayMode = .floating + notchWidth = 360 + textAreaHeight = 180 + showElapsedTime = true + persistentHUDEnabled = true + hudModules = [.trackingState, .expectedWord, .microphoneStatus] + externalDisplayMode = .teleprompter + case .sidecar: + overlayMode = .fullscreen + notchWidth = 400 + textAreaHeight = 220 + showElapsedTime = true + persistentHUDEnabled = true + hudModules = [.trackingState, .expectedWord, .nextCue, .elapsedTime] + externalDisplayMode = .teleprompter + } + activeLayoutPreset = preset + } + + func applyCustomPreset(_ preset: CustomLayoutPreset) { + overlayMode = preset.overlayMode + notchWidth = CGFloat(preset.notchWidth) + textAreaHeight = CGFloat(preset.textAreaHeight) + floatingGlassEffect = preset.floatingGlassEffect + glassOpacity = preset.glassOpacity + showElapsedTime = preset.showElapsedTime + persistentHUDEnabled = preset.persistentHUDEnabled + hudModules = preset.hudModules + attachedAnchorCorner = preset.attachedAnchorCorner + attachedMarginX = preset.attachedMarginX + attachedMarginY = preset.attachedMarginY + activeLayoutPreset = .custom + } + + func saveCurrentAsCustomPreset() { + let nextIndex = customPresets.count + 1 + let preset = CustomLayoutPreset( + id: UUID(), + name: "Custom \(nextIndex)", + overlayMode: overlayMode, + notchWidth: Double(notchWidth), + textAreaHeight: Double(textAreaHeight), + floatingGlassEffect: floatingGlassEffect, + glassOpacity: glassOpacity, + showElapsedTime: showElapsedTime, + persistentHUDEnabled: persistentHUDEnabled, + hudModules: hudModules, + attachedAnchorCorner: attachedAnchorCorner, + attachedMarginX: attachedMarginX, + attachedMarginY: attachedMarginY + ) + customPresets.append(preset) + activeLayoutPreset = .custom + } + + func deleteCustomPreset(_ preset: CustomLayoutPreset) { + customPresets.removeAll { $0.id == preset.id } + } + + private func saveCustomPresets() { + if let data = try? JSONEncoder().encode(customPresets) { + UserDefaults.standard.set(data, forKey: "customPresets") + } + } + + private func saveHUDModules() { + if let data = try? JSONEncoder().encode(hudModules) { + UserDefaults.standard.set(data, forKey: "hudModules") + } + } + + private static func loadCustomPresets() -> [CustomLayoutPreset] { + guard let data = UserDefaults.standard.data(forKey: "customPresets"), + let presets = try? JSONDecoder().decode([CustomLayoutPreset].self, from: data) else { + return [] + } + return presets + } + + private static func loadHUDModules() -> [HUDModule] { + guard let data = UserDefaults.standard.data(forKey: "hudModules"), + let modules = try? JSONDecoder().decode([HUDModule].self, from: data), + !modules.isEmpty else { + return Self.defaultHUDModules + } + return modules } } diff --git a/Textream/Textream/OverlayStateProjector.swift b/Textream/Textream/OverlayStateProjector.swift new file mode 100644 index 0000000..c69fb5a --- /dev/null +++ b/Textream/Textream/OverlayStateProjector.swift @@ -0,0 +1,83 @@ +// +// OverlayStateProjector.swift +// Textream +// +// Created by OpenAI Codex on 21.03.2026. +// + +import Foundation + +struct OverlayProjectionState: Equatable { + var highlightedCharCount: Int + var trackingState: TrackingState + var expectedWord: String + var nextCue: String + var confidenceLevel: TrackingConfidence + var confidenceScore: Double + var manualAsideMode: ManualAsideMode + var trackingStatusLine: String + var partialText: String + var manualIgnoreActive: Bool + + init( + highlightedCharCount: Int = 0, + trackingState: TrackingState = .tracking, + expectedWord: String = "", + nextCue: String = "", + confidenceLevel: TrackingConfidence = .low, + confidenceScore: Double = 0, + manualAsideMode: ManualAsideMode = .inactive, + trackingStatusLine: String = "", + partialText: String = "", + manualIgnoreActive: Bool = false + ) { + self.highlightedCharCount = highlightedCharCount + self.trackingState = trackingState + self.expectedWord = expectedWord + self.nextCue = nextCue + self.confidenceLevel = confidenceLevel + self.confidenceScore = confidenceScore + self.manualAsideMode = manualAsideMode + self.trackingStatusLine = trackingStatusLine + self.partialText = partialText + self.manualIgnoreActive = manualIgnoreActive + } +} + +enum OverlayStateProjector { + static func projected( + snapshot: TrackingSnapshot, + frame: SpeechRecognitionFrame? + ) -> OverlayProjectionState { + OverlayProjectionState( + highlightedCharCount: snapshot.highlightedCharCount, + trackingState: snapshot.trackingState, + expectedWord: snapshot.expectedWord, + nextCue: snapshot.nextCue, + confidenceLevel: snapshot.confidenceLevel, + confidenceScore: snapshot.confidenceScore, + manualAsideMode: snapshot.manualAsideMode, + trackingStatusLine: snapshot.statusLine, + partialText: frame?.partialText ?? "", + manualIgnoreActive: snapshot.manualAsideMode == .hold + ) + } + + static func apply( + snapshot: TrackingSnapshot, + frame: SpeechRecognitionFrame?, + to content: OverlayContent + ) { + let projection = projected(snapshot: snapshot, frame: frame) + content.highlightedCharCount = projection.highlightedCharCount + content.trackingState = projection.trackingState + content.expectedWord = projection.expectedWord + content.nextCue = projection.nextCue + content.confidenceLevel = projection.confidenceLevel + content.confidenceScore = projection.confidenceScore + content.manualAsideMode = projection.manualAsideMode + content.trackingStatusLine = projection.trackingStatusLine + content.manualIgnoreActive = projection.manualIgnoreActive + content.partialText = projection.partialText + } +} diff --git a/Textream/Textream/PersistentHUDPresenter.swift b/Textream/Textream/PersistentHUDPresenter.swift new file mode 100644 index 0000000..576169d --- /dev/null +++ b/Textream/Textream/PersistentHUDPresenter.swift @@ -0,0 +1,186 @@ +// +// PersistentHUDPresenter.swift +// Textream +// +// Created by OpenAI Codex on 21.03.2026. +// + +import Foundation + +enum HUDPresentationTone: String, Equatable { + case neutral + case success + case warning + case info + case attention +} + +enum HUDPresentationItemKind: String, Equatable { + case pill + case elapsedTime +} + +struct HUDPresentationItem: Identifiable, Equatable { + let id: String + let kind: HUDPresentationItemKind + let text: String + let tone: HUDPresentationTone +} + +struct HUDPresentationConfiguration: Equatable { + let isEnabled: Bool + let modules: [HUDModule] +} + +struct HUDPresentationInput: Equatable { + let trackingState: TrackingState + let expectedWord: String + let nextCue: String + let attachedRequiresAttention: Bool + let attachedDiagnosticState: AttachedDiagnosticState + let attachedStatusLine: String + + init( + trackingState: TrackingState = .tracking, + expectedWord: String = "", + nextCue: String = "", + attachedRequiresAttention: Bool = false, + attachedDiagnosticState: AttachedDiagnosticState = .inactive, + attachedStatusLine: String = "" + ) { + self.trackingState = trackingState + self.expectedWord = expectedWord + self.nextCue = nextCue + self.attachedRequiresAttention = attachedRequiresAttention + self.attachedDiagnosticState = attachedDiagnosticState + self.attachedStatusLine = attachedStatusLine + } + + init(content: OverlayContent) { + self.init( + trackingState: content.trackingState, + expectedWord: content.expectedWord, + nextCue: content.nextCue, + attachedRequiresAttention: content.attachedRequiresAttention, + attachedDiagnosticState: content.attachedDiagnosticState, + attachedStatusLine: content.attachedStatusLine + ) + } +} + +enum PersistentHUDPresenter { + static func items( + content: OverlayContent, + isListening: Bool, + configuration: HUDPresentationConfiguration + ) -> [HUDPresentationItem] { + items( + input: HUDPresentationInput(content: content), + isListening: isListening, + configuration: configuration + ) + } + + static func items( + input: HUDPresentationInput, + isListening: Bool, + configuration: HUDPresentationConfiguration + ) -> [HUDPresentationItem] { + guard configuration.isEnabled else { return [] } + + var items: [HUDPresentationItem] = [] + + if input.attachedRequiresAttention && !input.attachedStatusLine.isEmpty { + items.append( + HUDPresentationItem( + id: "attached-status", + kind: .pill, + text: input.attachedStatusLine, + tone: attachedTone(for: input.attachedDiagnosticState) + ) + ) + } + + for module in configuration.modules { + switch module { + case .trackingState: + items.append( + HUDPresentationItem( + id: module.rawValue, + kind: .pill, + text: input.trackingState.label, + tone: tone(for: input.trackingState) + ) + ) + case .expectedWord: + if !input.expectedWord.isEmpty { + items.append( + HUDPresentationItem( + id: module.rawValue, + kind: .pill, + text: "Now: \(input.expectedWord)", + tone: .neutral + ) + ) + } + case .nextCue: + if !input.nextCue.isEmpty { + items.append( + HUDPresentationItem( + id: module.rawValue, + kind: .pill, + text: "Next: \(input.nextCue)", + tone: .neutral + ) + ) + } + case .microphoneStatus: + items.append( + HUDPresentationItem( + id: module.rawValue, + kind: .pill, + text: isListening ? "Mic On" : "Mic Off", + tone: isListening ? .warning : .neutral + ) + ) + case .elapsedTime: + items.append( + HUDPresentationItem( + id: module.rawValue, + kind: .elapsedTime, + text: "Elapsed Time", + tone: .neutral + ) + ) + } + } + + return items + } + + private static func tone(for state: TrackingState) -> HUDPresentationTone { + switch state { + case .tracking: + return .success + case .uncertain: + return .warning + case .aside: + return .info + case .lost: + return .attention + } + } + + private static func attachedTone(for state: AttachedDiagnosticState) -> HUDPresentationTone { + switch state { + case .permissionRequired: + return .warning + case .quartzFallback: + return .info + case .targetUnreadable, .targetLostFallback, .hiddenFallback: + return .attention + case .inactive, .attachedLive, .noTargetSelected: + return .neutral + } + } +} diff --git a/Textream/Textream/PersistentHUDView.swift b/Textream/Textream/PersistentHUDView.swift new file mode 100644 index 0000000..2304182 --- /dev/null +++ b/Textream/Textream/PersistentHUDView.swift @@ -0,0 +1,136 @@ +// +// PersistentHUDView.swift +// Textream +// +// Created by OpenAI Codex on 21.03.2026. +// + +import SwiftUI + +struct PersistentHUDStripView: View { + let items: [HUDPresentationItem] + var compact: Bool = true + + var body: some View { + if !items.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: compact ? 6 : 8) { + ForEach(items) { item in + itemView(item) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: compact ? 28 : 36) + } + } + + @ViewBuilder + private func itemView(_ item: HUDPresentationItem) -> some View { + switch item.kind { + case .pill: + hudPill(item.text, tone: toneColor(for: item.tone)) + case .elapsedTime: + ElapsedTimeView(fontSize: compact ? 10 : 13) + .padding(.horizontal, compact ? 10 : 12) + .padding(.vertical, compact ? 4 : 6) + .background(.white.opacity(0.08)) + .clipShape(Capsule()) + } + } + + private func hudPill(_ text: String, tone: Color) -> some View { + Text(text) + .font(.system(size: compact ? 10 : 12, weight: .medium)) + .foregroundStyle(tone) + .lineLimit(1) + .padding(.horizontal, compact ? 10 : 12) + .padding(.vertical, compact ? 4 : 6) + .background(.white.opacity(0.08)) + .clipShape(Capsule()) + } + + private func toneColor(for tone: HUDPresentationTone) -> Color { + switch tone { + case .success: + return .green.opacity(0.85) + case .warning: + return .yellow.opacity(0.88) + case .info: + return .blue.opacity(0.88) + case .attention: + return .orange.opacity(0.9) + case .neutral: + return .white.opacity(0.72) + } + } +} + +struct PersistentHUDView: View { + @Bindable var content: OverlayContent + @Bindable var speechRecognizer: SpeechRecognizer + var compact: Bool = true + + private var items: [HUDPresentationItem] { + PersistentHUDPresenter.items( + content: content, + isListening: speechRecognizer.isListening, + configuration: HUDPresentationConfiguration( + isEnabled: NotchSettings.shared.persistentHUDEnabled, + modules: NotchSettings.shared.hudModules + ) + ) + } + + var body: some View { + PersistentHUDStripView(items: items, compact: compact) + } +} + +struct QADebugOverlayView: View { + @Bindable var speechRecognizer: SpeechRecognizer + var compact: Bool = true + private let qaDebug = QADebugStore.shared + + var body: some View { + if NotchSettings.shared.qaDebugOverlayEnabled { + VStack(alignment: .leading, spacing: compact ? 4 : 6) { + Text(trackingLine) + .font(.system(size: compact ? 9 : 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white.opacity(0.78)) + .textSelection(.enabled) + Text(detailLine) + .font(.system(size: compact ? 9 : 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.white.opacity(0.6)) + .textSelection(.enabled) + if NotchSettings.shared.overlayMode == .attached || qaDebug.anchorSourceLabel != "Inactive" { + Text(anchorLine) + .font(.system(size: compact ? 9 : 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.white.opacity(0.58)) + .textSelection(.enabled) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, compact ? 10 : 12) + .padding(.vertical, compact ? 6 : 8) + .background(.white.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + + private var trackingLine: String { + let expected = speechRecognizer.expectedWord.isEmpty ? "-" : speechRecognizer.expectedWord + return "TRACK \(speechRecognizer.trackingState.shortLabel) | expected \(expected) | conf \(speechRecognizer.confidenceLevel.label) | freeze \(speechRecognizer.trackingFreezeReason)" + } + + private var detailLine: String { + speechRecognizer.trackingDebugSummary + } + + private var anchorLine: String { + let window = qaDebug.anchorWindowLabel.isEmpty ? "-" : qaDebug.anchorWindowLabel + let frame = qaDebug.anchorFrameLabel.isEmpty ? "-" : qaDebug.anchorFrameLabel + let trusted = qaDebug.anchorAccessibilityTrusted ? "AX on" : "AX off" + return "ANCHOR \(qaDebug.anchorSourceLabel) | \(trusted) | \(window) | \(frame) | \(qaDebug.anchorMessage)" + } +} diff --git a/Textream/Textream/QADebugStore.swift b/Textream/Textream/QADebugStore.swift new file mode 100644 index 0000000..26ff731 --- /dev/null +++ b/Textream/Textream/QADebugStore.swift @@ -0,0 +1,117 @@ +// +// QADebugStore.swift +// Textream +// +// Created by OpenAI Codex on 21.03.2026. +// + +import CoreGraphics +import Foundation +import Observation +import OSLog + +struct QALogEntry: Identifiable, Hashable { + let id: UUID + let timestamp: Date + let category: String + let message: String + + init( + id: UUID = UUID(), + timestamp: Date = Date(), + category: String, + message: String + ) { + self.id = id + self.timestamp = timestamp + self.category = category + self.message = message + } +} + +@Observable +final class QADebugStore { + static let shared = QADebugStore() + + var trackingStateLabel: String = "Idle" + var trackingExpectedWord: String = "" + var trackingConfidenceLabel: String = "Low" + var trackingFreezeReason: String = "None" + var trackingDebugSummary: String = "Waiting for speech" + var trackingPartialText: String = "" + + var anchorSourceLabel: String = "Inactive" + var anchorMessage: String = "Attached mode inactive" + var anchorWindowLabel: String = "" + var anchorFrameLabel: String = "" + var anchorAccessibilityTrusted: Bool = false + + var recentLogs: [QALogEntry] = [] + + private let trackingLogger = Logger(subsystem: "dev.fka.textream", category: "qa.tracking") + private let anchorLogger = Logger(subsystem: "dev.fka.textream", category: "qa.anchor") + + private var lastTrackingFingerprint: String = "" + private var lastAnchorFingerprint: String = "" + + func recordTracking(snapshot: TrackingSnapshot, frame: SpeechRecognitionFrame?) { + trackingStateLabel = snapshot.trackingState.label + trackingExpectedWord = snapshot.expectedWord + trackingConfidenceLabel = snapshot.confidenceLevel.label + trackingFreezeReason = snapshot.decisionReason.freezeLabel + trackingDebugSummary = snapshot.debugSummary + trackingPartialText = frame?.partialText ?? trackingPartialText + + let fingerprint = [ + snapshot.trackingState.rawValue, + snapshot.expectedWord, + snapshot.confidenceLevel.rawValue, + snapshot.decisionReason.rawValue, + snapshot.debugSummary, + ].joined(separator: "|") + guard fingerprint != lastTrackingFingerprint else { return } + lastTrackingFingerprint = fingerprint + + guard NotchSettings.shared.trackingDebugLoggingEnabled else { return } + let message = """ + state=\(snapshot.trackingState.rawValue) expected=\(snapshot.expectedWord.isEmpty ? "-" : snapshot.expectedWord) confidence=\(snapshot.confidenceLevel.rawValue) freeze=\(snapshot.decisionReason.freezeLabel) detail=\(snapshot.debugSummary) + """ + appendLog(category: "tracking", message: message, logger: trackingLogger) + } + + func recordAnchor(_ resolution: WindowAnchorResolution) { + anchorSourceLabel = resolution.source.label + anchorMessage = resolution.message + anchorWindowLabel = resolution.window?.displayName ?? "" + anchorFrameLabel = resolution.frameLabel + anchorAccessibilityTrusted = resolution.isAccessibilityTrusted + + let fingerprint = [ + resolution.source.rawValue, + resolution.window?.displayName ?? "", + resolution.frameLabel, + String(resolution.isAccessibilityTrusted), + resolution.message, + ].joined(separator: "|") + guard fingerprint != lastAnchorFingerprint else { return } + lastAnchorFingerprint = fingerprint + + guard NotchSettings.shared.anchorDebugLoggingEnabled else { return } + let message = """ + source=\(resolution.source.rawValue) trusted=\(resolution.isAccessibilityTrusted) window=\(resolution.window?.displayName ?? "-") frame=\(resolution.frameLabel) detail=\(resolution.message) + """ + appendLog(category: "anchor", message: message, logger: anchorLogger) + } + + func clearLogs() { + recentLogs.removeAll() + } + + private func appendLog(category: String, message: String, logger: Logger) { + recentLogs.insert(QALogEntry(category: category, message: message), at: 0) + if recentLogs.count > 120 { + recentLogs.removeLast(recentLogs.count - 120) + } + logger.debug("\(message, privacy: .public)") + } +} diff --git a/Textream/Textream/RemoteStateTypes.swift b/Textream/Textream/RemoteStateTypes.swift new file mode 100644 index 0000000..9c86955 --- /dev/null +++ b/Textream/Textream/RemoteStateTypes.swift @@ -0,0 +1,208 @@ +// +// RemoteStateTypes.swift +// Textream +// +// Created by OpenAI Codex on 21.03.2026. +// + +import Foundation + +// MARK: - Browser State + +struct BrowserState: Codable { + let words: [String] + let highlightedCharCount: Int + let totalCharCount: Int + let audioLevels: [Double] + let isListening: Bool + let isDone: Bool + let fontColor: String + let cueColor: String + let hasNextPage: Bool + let isActive: Bool + let highlightWords: Bool + let lastSpokenText: String + let trackingState: String + let confidenceLevel: String + let expectedWord: String + let nextCue: String + let manualAsideActive: Bool + + init( + words: [String], + highlightedCharCount: Int, + totalCharCount: Int, + audioLevels: [Double], + isListening: Bool, + isDone: Bool, + fontColor: String, + cueColor: String, + hasNextPage: Bool, + isActive: Bool, + highlightWords: Bool, + lastSpokenText: String, + trackingState: String, + confidenceLevel: String, + expectedWord: String, + nextCue: String, + manualAsideActive: Bool + ) { + self.words = words + self.highlightedCharCount = highlightedCharCount + self.totalCharCount = totalCharCount + self.audioLevels = audioLevels + self.isListening = isListening + self.isDone = isDone + self.fontColor = fontColor + self.cueColor = cueColor + self.hasNextPage = hasNextPage + self.isActive = isActive + self.highlightWords = highlightWords + self.lastSpokenText = lastSpokenText + self.trackingState = trackingState + self.confidenceLevel = confidenceLevel + self.expectedWord = expectedWord + self.nextCue = nextCue + self.manualAsideActive = manualAsideActive + } + + private enum CodingKeys: String, CodingKey { + case words + case highlightedCharCount + case totalCharCount + case audioLevels + case isListening + case isDone + case fontColor + case cueColor + case hasNextPage + case isActive + case highlightWords + case lastSpokenText + case trackingState + case confidenceLevel + case expectedWord + case nextCue + case manualAsideActive + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + words = try container.decode([String].self, forKey: .words) + highlightedCharCount = try container.decode(Int.self, forKey: .highlightedCharCount) + totalCharCount = try container.decode(Int.self, forKey: .totalCharCount) + audioLevels = try container.decode([Double].self, forKey: .audioLevels) + isListening = try container.decode(Bool.self, forKey: .isListening) + isDone = try container.decode(Bool.self, forKey: .isDone) + fontColor = try container.decode(String.self, forKey: .fontColor) + cueColor = try container.decode(String.self, forKey: .cueColor) + hasNextPage = try container.decode(Bool.self, forKey: .hasNextPage) + isActive = try container.decode(Bool.self, forKey: .isActive) + highlightWords = try container.decode(Bool.self, forKey: .highlightWords) + lastSpokenText = try container.decode(String.self, forKey: .lastSpokenText) + trackingState = try container.decodeIfPresent(String.self, forKey: .trackingState) ?? TrackingState.tracking.rawValue + confidenceLevel = try container.decodeIfPresent(String.self, forKey: .confidenceLevel) ?? TrackingConfidence.low.rawValue + expectedWord = try container.decodeIfPresent(String.self, forKey: .expectedWord) ?? "" + nextCue = try container.decodeIfPresent(String.self, forKey: .nextCue) ?? "" + manualAsideActive = try container.decodeIfPresent(Bool.self, forKey: .manualAsideActive) ?? false + } +} + +// MARK: - Director State (App → Web) + +struct DirectorState: Codable { + let words: [String] + let highlightedCharCount: Int + let totalCharCount: Int + let isActive: Bool + let isDone: Bool + let isListening: Bool + let fontColor: String + let cueColor: String + let lastSpokenText: String + let audioLevels: [Double] + let trackingState: String + let confidenceLevel: String + let expectedWord: String + let nextCue: String + let manualAsideActive: Bool + + init( + words: [String], + highlightedCharCount: Int, + totalCharCount: Int, + isActive: Bool, + isDone: Bool, + isListening: Bool, + fontColor: String, + cueColor: String, + lastSpokenText: String, + audioLevels: [Double], + trackingState: String, + confidenceLevel: String, + expectedWord: String, + nextCue: String, + manualAsideActive: Bool + ) { + self.words = words + self.highlightedCharCount = highlightedCharCount + self.totalCharCount = totalCharCount + self.isActive = isActive + self.isDone = isDone + self.isListening = isListening + self.fontColor = fontColor + self.cueColor = cueColor + self.lastSpokenText = lastSpokenText + self.audioLevels = audioLevels + self.trackingState = trackingState + self.confidenceLevel = confidenceLevel + self.expectedWord = expectedWord + self.nextCue = nextCue + self.manualAsideActive = manualAsideActive + } + + private enum CodingKeys: String, CodingKey { + case words + case highlightedCharCount + case totalCharCount + case isActive + case isDone + case isListening + case fontColor + case cueColor + case lastSpokenText + case audioLevels + case trackingState + case confidenceLevel + case expectedWord + case nextCue + case manualAsideActive + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + words = try container.decode([String].self, forKey: .words) + highlightedCharCount = try container.decode(Int.self, forKey: .highlightedCharCount) + totalCharCount = try container.decode(Int.self, forKey: .totalCharCount) + isActive = try container.decode(Bool.self, forKey: .isActive) + isDone = try container.decode(Bool.self, forKey: .isDone) + isListening = try container.decode(Bool.self, forKey: .isListening) + fontColor = try container.decode(String.self, forKey: .fontColor) + cueColor = try container.decode(String.self, forKey: .cueColor) + lastSpokenText = try container.decode(String.self, forKey: .lastSpokenText) + audioLevels = try container.decode([Double].self, forKey: .audioLevels) + trackingState = try container.decodeIfPresent(String.self, forKey: .trackingState) ?? TrackingState.tracking.rawValue + confidenceLevel = try container.decodeIfPresent(String.self, forKey: .confidenceLevel) ?? TrackingConfidence.low.rawValue + expectedWord = try container.decodeIfPresent(String.self, forKey: .expectedWord) ?? "" + nextCue = try container.decodeIfPresent(String.self, forKey: .nextCue) ?? "" + manualAsideActive = try container.decodeIfPresent(Bool.self, forKey: .manualAsideActive) ?? false + } +} + +// MARK: - Director Command (Web → App) + +struct DirectorCommand: Codable { + let type: String + let text: String? + let readCharCount: Int? +} diff --git a/Textream/Textream/SettingsView.swift b/Textream/Textream/SettingsView.swift index 581526b..4d9970a 100644 --- a/Textream/Textream/SettingsView.swift +++ b/Textream/Textream/SettingsView.swift @@ -150,6 +150,24 @@ struct NotchPreviewContent: View { // Phase 2: detach from top (0=stuck to top, 1=moved down + rounded) @State private var offsetPhase: CGFloat = 0 + private var previewHUDItems: [HUDPresentationItem] { + PersistentHUDPresenter.items( + input: HUDPresentationInput( + trackingState: .tracking, + expectedWord: "[wave]", + nextCue: "smile and continue the next line", + attachedRequiresAttention: false, + attachedDiagnosticState: .inactive, + attachedStatusLine: "" + ), + isListening: settings.listeningMode != .classic, + configuration: HUDPresentationConfiguration( + isEnabled: settings.persistentHUDEnabled, + modules: settings.hudModules + ) + ) + } + var body: some View { GeometryReader { geo in let topPadding = menuBarHeight * (1 - offsetPhase) + 14 * offsetPhase @@ -193,6 +211,12 @@ struct NotchPreviewContent: View { } .frame(height: topPadding) + if !previewHUDItems.isEmpty { + PersistentHUDStripView(items: previewHUDItems, compact: true) + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + SpeechScrollView( words: Self.loremWords, highlightedCharCount: settings.listeningMode == .wordTracking ? highlightedCount : Self.loremWords.count * 5, @@ -218,7 +242,7 @@ struct NotchPreviewContent: View { .animation(.easeInOut(duration: 0.15), value: settings.textAreaHeight) } .onChange(of: settings.overlayMode) { _, mode in - if mode == .floating { + if mode == .floating || mode == .attached { // Phase 1: flatten corners while at top withAnimation(.easeInOut(duration: 0.25)) { cornerPhase = 1 @@ -243,7 +267,7 @@ struct NotchPreviewContent: View { } } .onAppear { - let isFloating = settings.overlayMode == .floating + let isFloating = settings.overlayMode == .floating || settings.overlayMode == .attached cornerPhase = isFloating ? 1 : 0 offsetPhase = isFloating ? 1 : 0 } @@ -266,7 +290,7 @@ struct NotchPreviewContent: View { // MARK: - Settings Tabs enum SettingsTab: String, CaseIterable, Identifiable { - case appearance, guidance, teleprompter, external, browser, director + case appearance, guidance, teleprompter, layout, external, browser, director, qa var id: String { rawValue } @@ -275,9 +299,11 @@ enum SettingsTab: String, CaseIterable, Identifiable { case .appearance: return "Appearance" case .guidance: return "Guidance" case .teleprompter: return "Teleprompter" + case .layout: return "HUD" case .external: return "External" case .browser: return "Remote" case .director: return "Director" + case .qa: return "QA & Debug" } } @@ -286,9 +312,11 @@ enum SettingsTab: String, CaseIterable, Identifiable { case .appearance: return "paintpalette" case .guidance: return "waveform" case .teleprompter: return "macwindow" + case .layout: return "rectangle.3.group" case .external: return "rectangle.on.rectangle" case .browser: return "antenna.radiowaves.left.and.right" case .director: return "megaphone" + case .qa: return "ladybug" } } } @@ -296,13 +324,55 @@ enum SettingsTab: String, CaseIterable, Identifiable { // MARK: - Settings View struct SettingsView: View { + private struct OverlayRefreshFingerprint: Equatable { + let notchWidth: CGFloat + let textAreaHeight: CGFloat + let notchDisplayMode: NotchDisplayMode + let pinnedScreenID: UInt32 + let fullscreenScreenID: UInt32 + let attachedAnchorCorner: AttachedAnchorCorner + let attachedMarginX: Double + let attachedMarginY: Double + let attachedFallbackBehavior: AttachedFallbackBehavior + let attachedHideWhenWindowUnavailable: Bool + let hideFromScreenShare: Bool + } + + private enum FloatingPlacementOption: String, CaseIterable, Identifiable { + case dragFreely + case followPointer + + var id: String { rawValue } + + var label: String { + switch self { + case .dragFreely: + return "Free Drag" + case .followPointer: + return "Follow Pointer" + } + } + } + @Bindable var settings: NotchSettings @Environment(\.dismiss) private var dismiss @State private var previewController = NotchPreviewController() - @State private var selectedTab: SettingsTab = .appearance + @State private var selectedTab: SettingsTab @State private var showResetConfirmation = false + @State private var attachableWindows: [AttachedWindowInfo] = [] + @State private var attachedDiagnostics = AttachedDiagnosticsStore.shared + private let qaDebug = QADebugStore.shared + + init(settings: NotchSettings, initialTab: SettingsTab = .appearance) { + self.settings = settings + _selectedTab = State(initialValue: initialTab) + } var body: some View { + configuredSettingsWindow(settingsRoot) + } + + private var settingsRoot: some View { HStack(spacing: 0) { // Sidebar VStack(alignment: .leading, spacing: 2) { @@ -337,7 +407,7 @@ struct SettingsView: View { Spacer() } .padding(12) - .frame(width: 155) + .frame(width: 185) .frame(maxHeight: .infinity) .background(Color.primary.opacity(0.04)) @@ -352,12 +422,16 @@ struct SettingsView: View { guidanceTab case .teleprompter: teleprompterTab + case .layout: + layoutTab case .external: externalTab case .browser: browserTab case .director: directorTab + case .qa: + qaTab } Divider() @@ -383,64 +457,139 @@ struct SettingsView: View { } .frame(maxWidth: .infinity) } - .frame(width: 500) - .frame(minHeight: 280, maxHeight: 500) - .background(.ultraThinMaterial) - .alert("Reset All Settings?", isPresented: $showResetConfirmation) { - Button("Cancel", role: .cancel) { } - Button("Reset", role: .destructive) { - withAnimation(.easeInOut(duration: 0.2)) { - resetAllSettings() - } + } + + private var overlayRefreshFingerprint: OverlayRefreshFingerprint { + OverlayRefreshFingerprint( + notchWidth: settings.notchWidth, + textAreaHeight: settings.textAreaHeight, + notchDisplayMode: settings.notchDisplayMode, + pinnedScreenID: settings.pinnedScreenID, + fullscreenScreenID: settings.fullscreenScreenID, + attachedAnchorCorner: settings.attachedAnchorCorner, + attachedMarginX: settings.attachedMarginX, + attachedMarginY: settings.attachedMarginY, + attachedFallbackBehavior: settings.attachedFallbackBehavior, + attachedHideWhenWindowUnavailable: settings.attachedHideWhenWindowUnavailable, + hideFromScreenShare: settings.hideFromScreenShare + ) + } + + private var attachedTargetFingerprint: String { + "\(settings.attachedTargetWindowID)|\(settings.attachedTargetWindowLabel)" + } + + private var floatingPlacementSelection: Binding { + Binding( + get: { + settings.followCursorWhenUndocked ? .followPointer : .dragFreely + }, + set: { selection in + settings.followCursorWhenUndocked = selection == .followPointer } - } message: { - Text("This will restore all settings to their defaults.") - } - .onAppear { - if settings.overlayMode != .fullscreen { - previewController.show(settings: settings) - if settings.followCursorWhenUndocked && settings.overlayMode == .floating { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - previewController.animateToCursor(settings: settings) + ) + } + + private var shouldShowDetachedOverlayPreview: Bool { + selectedTab == .teleprompter && settings.overlayMode != .fullscreen + } + + private func configuredSettingsWindow(_ content: Content) -> some View { + content + .frame(minWidth: 820, idealWidth: 860, maxWidth: 940, minHeight: 560, idealHeight: 620, maxHeight: 760) + .background(.ultraThinMaterial) + .alert("Reset All Settings?", isPresented: $showResetConfirmation) { + Button("Cancel", role: .cancel) { } + Button("Reset", role: .destructive) { + withAnimation(.easeInOut(duration: 0.2)) { + resetAllSettings() } } + } message: { + Text("This will restore all settings to their defaults.") } - } - .onDisappear { - previewController.dismiss() - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in - previewController.hide() - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - if settings.overlayMode != .fullscreen { - previewController.show(settings: settings) - if settings.followCursorWhenUndocked && settings.overlayMode == .floating { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - previewController.animateToCursor(settings: settings) - } - } + .onAppear(perform: handleSettingsAppear) + .onDisappear { + previewController.dismiss() + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in + previewController.hide() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + handleAppDidBecomeActive() + } + .onChange(of: settings.followCursorWhenUndocked) { _, follow in + handleFollowCursorChanged(follow) + } + .onChange(of: settings.overlayMode) { _, mode in + handleOverlayModeChanged(mode) + } + .onChange(of: selectedTab) { _, _ in + handleSelectedTabChanged() + } + .onChange(of: overlayRefreshFingerprint) { _, _ in + refreshRunningOverlayLayout() + } + .onChange(of: attachedTargetFingerprint) { _, _ in + handleAttachedTargetChanged() + } + } + + private func handleSettingsAppear() { + refreshAttachedDiagnostics() + showPreviewIfNeeded() + } + + private func handleAppDidBecomeActive() { + refreshAttachedDiagnostics() + showPreviewIfNeeded() + } + + private func showPreviewIfNeeded() { + guard shouldShowDetachedOverlayPreview else { + previewController.dismiss() + return } - .onChange(of: settings.followCursorWhenUndocked) { _, follow in - if follow && settings.overlayMode == .floating { + + previewController.show(settings: settings) + + if settings.followCursorWhenUndocked, settings.overlayMode == .floating { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { previewController.animateToCursor(settings: settings) - } else { - previewController.animateFromCursor() } + } else if previewController.isAtCursor { + previewController.animateFromCursor() } - .onChange(of: settings.overlayMode) { _, mode in - if mode == .fullscreen { - previewController.hide() - } else { - previewController.show(settings: settings) - if mode == .floating && settings.followCursorWhenUndocked { - previewController.animateToCursor(settings: settings) - } else if previewController.isAtCursor { - previewController.animateFromCursor() - } - } + } + + private func handleFollowCursorChanged(_ follow: Bool) { + guard shouldShowDetachedOverlayPreview else { + previewController.dismiss() + refreshRunningOverlayLayout() + return + } + + if follow && settings.overlayMode == .floating { + previewController.animateToCursor(settings: settings) + } else { + previewController.animateFromCursor() } + refreshRunningOverlayLayout() + } + + private func handleOverlayModeChanged(_ mode: OverlayMode) { + refreshAttachedDiagnostics() + showPreviewIfNeeded() + refreshRunningOverlayLayout() + } + + private func handleAttachedTargetChanged() { + refreshAttachedDiagnostics() + refreshRunningOverlayLayout() + } + + private func handleSelectedTabChanged() { + showPreviewIfNeeded() } // MARK: - Appearance Tab @@ -619,133 +768,190 @@ struct SettingsView: View { } .pickerStyle(.segmented) .labelsHidden() + } + .padding(16) + } + } - Divider() + // MARK: - Guidance Tab - // Dimensions - Text("Dimensions") - .font(.system(size: 13, weight: .medium)) + private var guidanceTab: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 14) { + Picker("", selection: $settings.listeningMode) { + ForEach(ListeningMode.allCases) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() - VStack(spacing: 10) { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Width") - .font(.system(size: 12)) + Text(settings.listeningMode.description) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + if settings.listeningMode == .wordTracking { + Divider() + + VStack(alignment: .leading, spacing: 6) { + Text("Speech Language") + .font(.system(size: 13, weight: .medium)) + Picker("", selection: $settings.speechLocale) { + ForEach(SFSpeechRecognizer.supportedLocales().sorted(by: { $0.identifier < $1.identifier }), id: \.identifier) { locale in + Text(Locale.current.localizedString(forIdentifier: locale.identifier) ?? locale.identifier) + .tag(locale.identifier) + } + } + .labelsHidden() + } + + Divider() + + Toggle(isOn: $settings.strictTrackingEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Strict Tracking Guard") + .font(.system(size: 13, weight: .medium)) + Text("Freeze instead of drifting when speech does not confidently match the script.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + + Toggle(isOn: $settings.legacyTrackingFallbackEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Allow Legacy Fallback") + .font(.system(size: 13, weight: .medium)) + Text("Keep the old matcher available when strict guard is turned off.") + .font(.system(size: 11)) .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Match Window") + .font(.system(size: 13, weight: .medium)) Spacer() - Text("\(Int(settings.notchWidth))px") + Text("\(settings.matchWindowSize) words") .font(.system(size: 11, weight: .regular, design: .monospaced)) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) } Slider( - value: $settings.notchWidth, - in: NotchSettings.minWidth...NotchSettings.maxWidth, - step: 10 + value: Binding( + get: { Double(settings.matchWindowSize) }, + set: { settings.matchWindowSize = Int($0.rounded()) } + ), + in: 5...12, + step: 1 ) - } - VStack(alignment: .leading, spacing: 4) { HStack { - Text("Height") - .font(.system(size: 12)) + Text("Advance Threshold") + .font(.system(size: 13, weight: .medium)) + Spacer() + Text(String(format: "%.1f", settings.advanceThreshold)) + .font(.system(size: 11, weight: .regular, design: .monospaced)) .foregroundStyle(.secondary) + } + Slider( + value: $settings.advanceThreshold, + in: 1.8...5.2, + step: 0.1 + ) + + HStack { + Text("Off-script Freeze Delay") + .font(.system(size: 13, weight: .medium)) Spacer() - Text("\(Int(settings.textAreaHeight))px") + Text(String(format: "%.1fs", settings.offScriptFreezeDelay)) .font(.system(size: 11, weight: .regular, design: .monospaced)) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) } Slider( - value: $settings.textAreaHeight, - in: NotchSettings.minHeight...NotchSettings.maxHeight, - step: 10 + value: $settings.offScriptFreezeDelay, + in: 0.6...2.5, + step: 0.1 ) } - } - } - .padding(16) - } - } - - // MARK: - Guidance Tab - private var guidanceTab: some View { - VStack(alignment: .leading, spacing: 14) { - Picker("", selection: $settings.listeningMode) { - ForEach(ListeningMode.allCases) { mode in - Text(mode.label).tag(mode) - } - } - .pickerStyle(.segmented) - .labelsHidden() + Divider() - Text(settings.listeningMode.description) - .font(.system(size: 11)) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 6) { + Text("Manual Aside Controls") + .font(.system(size: 13, weight: .medium)) - if settings.listeningMode == .wordTracking { - Divider() + HStack { + Text("Toggle Aside") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text(settings.manualAsideHotkey.label) + .font(.system(size: 12, weight: .medium)) + } - VStack(alignment: .leading, spacing: 6) { - Text("Speech Language") - .font(.system(size: 13, weight: .medium)) - Picker("", selection: $settings.speechLocale) { - ForEach(SFSpeechRecognizer.supportedLocales().sorted(by: { $0.identifier < $1.identifier }), id: \.identifier) { locale in - Text(Locale.current.localizedString(forIdentifier: locale.identifier) ?? locale.identifier) - .tag(locale.identifier) + HStack { + Text("Hold to Ignore") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text(settings.temporaryIgnoreHotkey.label) + .font(.system(size: 12, weight: .medium)) } } - .labelsHidden() } - } - if settings.listeningMode != .classic { - Divider() + if settings.listeningMode != .classic { + Divider() - VStack(alignment: .leading, spacing: 6) { - Text("Microphone") - .font(.system(size: 13, weight: .medium)) - Picker("", selection: $settings.selectedMicUID) { - Text("System Default").tag("") - ForEach(availableMics) { mic in - Text(mic.name).tag(mic.uid) + VStack(alignment: .leading, spacing: 6) { + Text("Microphone") + .font(.system(size: 13, weight: .medium)) + Picker("", selection: $settings.selectedMicUID) { + Text("System Default").tag("") + ForEach(availableMics) { mic in + Text(mic.name).tag(mic.uid) + } } + .labelsHidden() } - .labelsHidden() } - } - if settings.listeningMode != .wordTracking { - Divider() + if settings.listeningMode != .wordTracking { + Divider() - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("Scroll Speed") - .font(.system(size: 13, weight: .medium)) - Spacer() - Text(String(format: "%.1f words/s", settings.scrollSpeed)) - .font(.system(size: 12, weight: .regular, design: .monospaced)) - .foregroundStyle(.secondary) - } - Slider( - value: $settings.scrollSpeed, - in: 0.5...8, - step: 0.5 - ) - HStack { - Text("Slower") - .font(.system(size: 10)) - .foregroundStyle(.tertiary) - Spacer() - Text("Faster") - .font(.system(size: 10)) - .foregroundStyle(.tertiary) + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Scroll Speed") + .font(.system(size: 13, weight: .medium)) + Spacer() + Text(String(format: "%.1f words/s", settings.scrollSpeed)) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .foregroundStyle(.secondary) + } + Slider( + value: $settings.scrollSpeed, + in: 0.5...8, + step: 0.5 + ) + HStack { + Text("Slower") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + Spacer() + Text("Faster") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } } } - } - Spacer() + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(16) .onAppear { availableMics = AudioInputDevice.allInputDevices() } } @@ -758,23 +964,51 @@ struct SettingsView: View { private var teleprompterTab: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 14) { - // Overlay mode picker - Picker("", selection: $settings.overlayMode) { - ForEach(OverlayMode.allCases) { mode in - Text(mode.label).tag(mode) + Text("Choose where the teleprompter lives and tune only the controls that belong to the current mode. The HUD tab only manages the lightweight status strip.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 8) { + Text("Overlay Mode") + .font(.system(size: 13, weight: .medium)) + + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), + ], + spacing: 10 + ) { + ForEach(OverlayMode.allCases) { mode in + overlayModeCard(for: mode) + } } } - .pickerStyle(.segmented) - .labelsHidden() Text(settings.overlayMode.description) .font(.system(size: 11)) .foregroundStyle(.secondary) + if settings.overlayMode == .floating { + Divider() + + floatingPointerQuickSection + } + + Divider() + + layoutPresetSection + + if settings.overlayMode != .attached { + Divider() + + teleprompterSizeSection() + } + if settings.overlayMode == .pinned { Divider() - Text("Display") + Text("Pinned Placement") .font(.system(size: 13, weight: .medium)) Picker("", selection: $settings.notchDisplayMode) { @@ -789,6 +1023,10 @@ struct SettingsView: View { .font(.system(size: 11)) .foregroundStyle(.secondary) + Text("Pinned mode only follows the display your mouse is on. If you want the prompter to chase the pointer itself, switch to Floating Window and turn on Follow Cursor below.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + if settings.notchDisplayMode == .fixedDisplay { displayPicker( screens: overlayScreens, @@ -801,19 +1039,6 @@ struct SettingsView: View { if settings.overlayMode == .floating { Divider() - Toggle(isOn: $settings.followCursorWhenUndocked) { - Text("Follow Cursor") - .font(.system(size: 13, weight: .medium)) - } - .toggleStyle(.switch) - .controlSize(.small) - - Text("The window follows your cursor and sticks to its bottom-right.") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - - Divider() - Toggle(isOn: $settings.floatingGlassEffect) { Text("Glass Effect") .font(.system(size: 13, weight: .medium)) @@ -841,24 +1066,110 @@ struct SettingsView: View { } } - if settings.overlayMode == .fullscreen { + if settings.overlayMode == .attached { Divider() - Text("Display") - .font(.system(size: 13, weight: .medium)) + VStack(alignment: .leading, spacing: 10) { + if !settings.hasSeenAttachedOnboarding { + attachedOnboardingCard + } - displayPicker( - screens: overlayScreens, - selectedID: $settings.fullscreenScreenID, - onRefresh: { refreshOverlayScreens() } - ) + attachedDiagnosticsCard - HStack(spacing: 6) { - Image(systemName: "escape") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - Text("Press Esc to stop the teleprompter.") - .font(.system(size: 11)) + HStack { + Text("Target Window") + .font(.system(size: 13, weight: .medium)) + Spacer() + Button("Refresh") { + refreshAttachableWindows() + } + .buttonStyle(.borderless) + .font(.system(size: 11, weight: .medium)) + } + + windowPicker + + teleprompterSizeSection( + title: "Attached Size", + description: "Resize the attached teleprompter here, or drag its edges directly. Textream saves the new size and keeps the selected window corner locked." + ) + + Text("Attached follows the selected app window instead of the mouse pointer. If you want the teleprompter to chase the mouse again, switch the mode card back to Floating.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + Text("Corner") + .font(.system(size: 13, weight: .medium)) + Picker("", selection: $settings.attachedAnchorCorner) { + ForEach(AttachedAnchorCorner.allCases) { corner in + Text(corner.label).tag(corner) + } + } + .pickerStyle(.segmented) + .labelsHidden() + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Horizontal Margin") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(settings.attachedMarginX))px") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Slider(value: $settings.attachedMarginX, in: 0...40, step: 1) + + HStack { + Text("Vertical Margin") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(settings.attachedMarginY))px") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Slider(value: $settings.attachedMarginY, in: 0...40, step: 1) + } + + Picker("Fallback", selection: $settings.attachedFallbackBehavior) { + ForEach(AttachedFallbackBehavior.allCases) { behavior in + Text(behavior.label).tag(behavior) + } + } + .pickerStyle(.segmented) + + Toggle(isOn: $settings.attachedHideWhenWindowUnavailable) { + VStack(alignment: .leading, spacing: 2) { + Text("Hide When Window Is Missing") + .font(.system(size: 13, weight: .medium)) + Text("Hide instead of showing the fallback position when the target window disappears.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + } + } + + if settings.overlayMode == .fullscreen { + Divider() + + Text("Display") + .font(.system(size: 13, weight: .medium)) + + displayPicker( + screens: overlayScreens, + selectedID: $settings.fullscreenScreenID, + onRefresh: { refreshOverlayScreens() } + ) + + HStack(spacing: 6) { + Image(systemName: "escape") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + Text("Press Esc to stop the teleprompter.") + .font(.system(size: 11)) .foregroundStyle(.secondary) } .padding(10) @@ -871,6 +1182,10 @@ struct SettingsView: View { Divider() + customPresetsSection + + Divider() + // Options Toggle(isOn: $settings.showElapsedTime) { VStack(alignment: .leading, spacing: 2) { @@ -927,7 +1242,10 @@ struct SettingsView: View { } .padding(16) } - .onAppear { refreshOverlayScreens() } + .onAppear { + refreshOverlayScreens() + refreshAttachableWindows() + } } // MARK: - External Tab @@ -990,6 +1308,288 @@ struct SettingsView: View { .onAppear { refreshScreens() } } + // MARK: - Layout & HUD Tab + + private var layoutTab: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 14) { + Text("Persistent HUD only controls the small always-visible status strip. Teleprompter placement and layout presets now live in the Teleprompter tab.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + Toggle(isOn: $settings.persistentHUDEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Persistent HUD") + .font(.system(size: 13, weight: .medium)) + Text("Keep only the most important tracking context visible while the prompter is active.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + + if settings.persistentHUDEnabled { + VStack(alignment: .leading, spacing: 8) { + Text("HUD Modules") + .font(.system(size: 13, weight: .medium)) + + ForEach(HUDModule.allCases) { module in + Toggle(isOn: Binding( + get: { settings.hudModules.contains(module) }, + set: { enabled in + if enabled { + if !settings.hudModules.contains(module) { + settings.hudModules.append(module) + } + } else { + settings.hudModules.removeAll { $0 == module } + } + } + )) { + Text(module.label) + .font(.system(size: 12)) + } + .toggleStyle(.checkbox) + } + } + } + + hudPreviewCard + } + .padding(16) + } + } + + private var floatingPointerQuickSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Pointer Follow") + .font(.system(size: 13, weight: .medium)) + + Text("Movement") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + Picker("", selection: floatingPlacementSelection) { + ForEach(FloatingPlacementOption.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.segmented) + .labelsHidden() + + Text(settings.followCursorWhenUndocked + ? "The floating teleprompter follows your mouse pointer itself. Use this when you want the prompter to travel with the pointer instead of staying near the top camera area." + : "The floating teleprompter stays wherever you drag it. Switch to Follow Pointer if you want the prompter to move with the mouse instead.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + + private func teleprompterSizeSection( + title: String = "Window Size", + description: String? = nil + ) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.system(size: 13, weight: .medium)) + + Text(description ?? teleprompterSizeDescription) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + VStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Width") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(settings.notchWidth))px") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Slider( + value: $settings.notchWidth, + in: NotchSettings.minWidth...NotchSettings.maxWidth, + step: 10 + ) + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Height") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(settings.textAreaHeight))px") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Slider( + value: $settings.textAreaHeight, + in: NotchSettings.minHeight...NotchSettings.maxHeight, + step: 10 + ) + } + } + } + } + + private var teleprompterSizeDescription: String { + switch settings.overlayMode { + case .attached: + return "Resize the attached teleprompter here, or drag its edges directly. Textream will save the new size and keep the selected window corner anchored." + case .floating: + return settings.followCursorWhenUndocked + ? "Resize the follow-pointer teleprompter here. It keeps tracking the pointer while staying inside the visible screen." + : "Resize the floating teleprompter here, or drag its edges directly. It will keep its current position unless you drag it again." + case .fullscreen: + return "Resize the fullscreen layout baseline here. The content refreshes on the selected display immediately." + case .pinned: + return "Resize the pinned teleprompter here. Width and reading height stay aligned with the notch placement." + } + } + + private var layoutPresetSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Layout Presets") + .font(.system(size: 13, weight: .medium)) + + Text("These presets shape the main teleprompter window, not the small HUD strip.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + ForEach(LayoutPreset.recommendedCases) { preset in + Button { + withAnimation(.easeInOut(duration: 0.2)) { + settings.applyBuiltInPreset(preset) + } + } label: { + VStack(spacing: 6) { + Text(preset.label) + .font(.system(size: 12, weight: .semibold)) + Text(preset == settings.activeLayoutPreset ? "Active" : "Apply") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(settings.activeLayoutPreset == preset ? Color.accentColor.opacity(0.12) : Color.primary.opacity(0.05)) + ) + } + .buttonStyle(.plain) + } + } + + if settings.activeLayoutPreset.isLegacyBuiltIn { + Text("Current layout preset was kept for compatibility and is no longer shown as a recommended default. Save it as a custom preset if you want to keep editing it.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + } + + private var customPresetsSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Custom Presets") + .font(.system(size: 13, weight: .medium)) + Spacer() + Button("Save Current") { + settings.saveCurrentAsCustomPreset() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + if settings.customPresets.isEmpty { + Text("No custom presets yet.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } else { + VStack(spacing: 8) { + ForEach(settings.customPresets) { preset in + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(preset.name) + .font(.system(size: 12, weight: .semibold)) + Text("\(preset.overlayMode.label) • \(Int(preset.notchWidth))×\(Int(preset.textAreaHeight))") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + Spacer() + Button("Apply") { + settings.applyCustomPreset(preset) + } + .buttonStyle(.bordered) + .controlSize(.small) + Button(role: .destructive) { + settings.deleteCustomPreset(preset) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.04)) + ) + } + } + } + } + } + + private var hudPreviewItems: [HUDPresentationItem] { + PersistentHUDPresenter.items( + input: HUDPresentationInput( + trackingState: .tracking, + expectedWord: "[wave]", + nextCue: "smile and continue the next line", + attachedRequiresAttention: settings.overlayMode == .attached && attachedDiagnostics.isDegraded, + attachedDiagnosticState: settings.overlayMode == .attached ? attachedDiagnostics.state : .inactive, + attachedStatusLine: settings.overlayMode == .attached ? attachedDiagnostics.statusLine : "" + ), + isListening: true, + configuration: HUDPresentationConfiguration( + isEnabled: settings.persistentHUDEnabled, + modules: settings.hudModules + ) + ) + } + + private var hudPreviewCard: some View { + VStack(alignment: .leading, spacing: 10) { + Text("HUD Preview") + .font(.system(size: 13, weight: .medium)) + + Text("Updates instantly as you toggle modules, so you can see what will actually stay visible in the overlay.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.82)) + + if hudPreviewItems.isEmpty { + Text("Persistent HUD is off. Turn it on to preview the live status strip.") + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.65)) + .padding(.horizontal, 14) + } else { + PersistentHUDStripView(items: hudPreviewItems, compact: false) + .padding(.horizontal, 14) + } + } + .frame(maxWidth: .infinity) + .frame(height: 64) + } + } + // MARK: - Remote Tab @State private var localIP: String = BrowserServer.localIPAddress() ?? "localhost" @@ -1227,8 +1827,328 @@ struct SettingsView: View { .onAppear { directorLocalIP = BrowserServer.localIPAddress() ?? "localhost" } } + // MARK: - QA Tab + + private var qaTab: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 14) { + Text("Use this panel during productization passes to inspect attached anchor source, tracking freeze reasons, and recent QA logs without attaching Xcode.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 10) { + Text("Overlay Debug") + .font(.system(size: 13, weight: .medium)) + + Toggle(isOn: $settings.qaDebugOverlayEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Show Debug Overlay") + .font(.system(size: 13, weight: .medium)) + Text("Displays tracking and anchor source labels directly inside the teleprompter overlay.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + + Toggle(isOn: $settings.trackingDebugLoggingEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Tracking Logs") + .font(.system(size: 13, weight: .medium)) + Text("Writes TrackingGuard state transitions and freeze reasons into the in-app QA log stream.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + + Toggle(isOn: $settings.anchorDebugLoggingEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Anchor Logs") + .font(.system(size: 13, weight: .medium)) + Text("Writes AX / Quartz / fallback resolution changes into the in-app QA log stream.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + } + + Divider() + + VStack(alignment: .leading, spacing: 10) { + Text("Live Tracking") + .font(.system(size: 13, weight: .medium)) + qaValueRow("State", qaDebug.trackingStateLabel) + qaValueRow("Expected", qaDebug.trackingExpectedWord.isEmpty ? "-" : qaDebug.trackingExpectedWord) + qaValueRow("Confidence", qaDebug.trackingConfidenceLabel) + qaValueRow("Freeze Reason", qaDebug.trackingFreezeReason) + qaValueRow("Detail", qaDebug.trackingDebugSummary) + qaValueRow("Partial", qaDebug.trackingPartialText.isEmpty ? "-" : qaDebug.trackingPartialText) + } + + Divider() + + VStack(alignment: .leading, spacing: 10) { + Text("Live Anchor") + .font(.system(size: 13, weight: .medium)) + qaValueRow("Source", qaDebug.anchorSourceLabel) + qaValueRow("AX Trusted", qaDebug.anchorAccessibilityTrusted ? "Yes" : "No") + qaValueRow("Window", qaDebug.anchorWindowLabel.isEmpty ? "-" : qaDebug.anchorWindowLabel) + qaValueRow("Frame", qaDebug.anchorFrameLabel) + qaValueRow("Detail", qaDebug.anchorMessage) + } + + Divider() + + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Recent QA Logs") + .font(.system(size: 13, weight: .medium)) + Spacer() + Button("Clear") { + qaDebug.clearLogs() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + if qaDebug.recentLogs.isEmpty { + Text("No QA logs yet. Turn on Tracking Logs or Anchor Logs and interact with the overlay.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.04)) + ) + } else { + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 8) { + ForEach(qaDebug.recentLogs) { entry in + VStack(alignment: .leading, spacing: 3) { + Text("\(entry.timestamp.formatted(date: .omitted, time: .standard)) • \(entry.category.uppercased())") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(.secondary) + Text(entry.message) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.04)) + ) + } + } + } + .frame(minHeight: 220) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 6) { + Text("Regression Checklist") + .font(.system(size: 13, weight: .medium)) + Text("See `docs/qa/regression-checklist.md` for the current macOS regression matrix, known issues, and exit criteria.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + .padding(16) + } + } + + private var attachedOnboardingCard: some View { + VStack(alignment: .leading, spacing: 10) { + Label("Before you use Attached Overlay", systemImage: "hand.raised.square.on.square") + .font(.system(size: 13, weight: .semibold)) + + Text("1. Grant Accessibility so Textream can read app window geometry. 2. Choose the window you want to follow. 3. If macOS cannot provide precise geometry, Textream falls back from Accessibility to visible bounds, then to the screen corner.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Button("Open System Settings") { + settings.hasSeenAttachedOnboarding = true + WindowAnchorService.openAccessibilitySettings() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Button("Got It") { + settings.hasSeenAttachedOnboarding = true + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.accentColor.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.accentColor.opacity(0.16), lineWidth: 1) + ) + } + + private var attachedDiagnosticsCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text("Attached Diagnostics") + .font(.system(size: 13, weight: .semibold)) + Text(attachedDiagnostics.anchorMessage) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer() + if attachedDiagnostics.shouldOfferSystemSettings { + Button("Open System Settings") { + WindowAnchorService.openAccessibilitySettings() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + qaValueRow("Permission", attachedDiagnostics.permissionState.label) + qaValueRow("State", attachedDiagnostics.state.label) + qaValueRow("Anchor", attachedDiagnostics.anchorSourceLabel) + qaValueRow("Target", attachedTargetDiagnosticLabel) + qaValueRow("Frame", attachedDiagnostics.frameLabel) + qaValueRow("Status", attachedDiagnostics.statusLine.isEmpty ? "Healthy" : attachedDiagnostics.statusLine) + qaValueRow("Details", attachedDiagnostics.detailLine.isEmpty ? "No active fallback" : attachedDiagnostics.detailLine) + + Text(attachedDiagnostics.userFacingExplanation) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.primary.opacity(0.04)) + ) + } + // MARK: - Shared Components + private var windowPicker: some View { + VStack(alignment: .leading, spacing: 8) { + if attachableWindows.isEmpty { + HStack(spacing: 6) { + Image(systemName: "macwindow.badge.plus") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Text("No attachable windows found. Open the app you want to attach to, then refresh.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.04)) + ) + } else { + ForEach(attachableWindows.prefix(8)) { window in + Button { + settings.attachedTargetWindowID = window.id + settings.attachedTargetWindowLabel = window.displayName + refreshAttachedDiagnostics() + } label: { + HStack(spacing: 10) { + Image(systemName: "macwindow") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(settings.attachedTargetWindowID == window.id ? Color.accentColor : .secondary) + VStack(alignment: .leading, spacing: 2) { + Text(window.displayName) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(settings.attachedTargetWindowID == window.id ? Color.accentColor : .primary) + Text("\(Int(window.bounds.width))×\(Int(window.bounds.height))") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + Spacer() + if settings.attachedTargetWindowID == window.id { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.accentColor) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(settings.attachedTargetWindowID == window.id ? Color.accentColor.opacity(0.1) : Color.primary.opacity(0.04)) + ) + } + .buttonStyle(.plain) + } + } + } + } + + private func overlayModeCard(for mode: OverlayMode) -> some View { + Button { + settings.overlayMode = mode + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: mode.icon) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(settings.overlayMode == mode ? Color.accentColor : .secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 3) { + Text(mode.label) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(settings.overlayMode == mode ? Color.accentColor : .primary) + .lineLimit(2) + Text(overlayModeSubtitle(for: mode)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(settings.overlayMode == mode ? Color.accentColor.opacity(0.12) : Color.primary.opacity(0.05)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(settings.overlayMode == mode ? Color.accentColor.opacity(0.4) : Color.clear, lineWidth: 1.5) + ) + } + .buttonStyle(.plain) + } + + private func overlayModeSubtitle(for mode: OverlayMode) -> String { + switch mode { + case .pinned: + return "Top center only; follows display, not pointer" + case .floating: + return settings.overlayMode == .floating && settings.followCursorWhenUndocked + ? "Currently chasing the pointer" + : "Drag freely or make it chase the pointer" + case .fullscreen: + return "Dedicated fullscreen teleprompter" + case .attached: + return settings.overlayMode == .attached + ? "\(settings.attachedAnchorCorner.label) corner, resizable, with fallback" + : "Window corner, resizable, with fallback" + } + } + private func displayPicker( screens: [NSScreen], selectedID: Binding, @@ -1329,6 +2249,20 @@ struct SettingsView: View { settings.floatingGlassEffect = false settings.glassOpacity = 0.15 settings.followCursorWhenUndocked = false + settings.strictTrackingEnabled = NotchSettings.defaultStrictTrackingEnabled + settings.legacyTrackingFallbackEnabled = NotchSettings.defaultLegacyTrackingFallbackEnabled + settings.matchWindowSize = NotchSettings.defaultMatchWindowSize + settings.advanceThreshold = NotchSettings.defaultAdvanceThreshold + settings.offScriptFreezeDelay = NotchSettings.defaultOffScriptFreezeDelay + settings.attachedAnchorCorner = NotchSettings.defaultAttachedAnchorCorner + settings.attachedMarginX = NotchSettings.defaultAttachedMarginX + settings.attachedMarginY = NotchSettings.defaultAttachedMarginY + settings.attachedFallbackBehavior = NotchSettings.defaultAttachedFallbackBehavior + settings.attachedTargetWindowID = 0 + settings.attachedTargetWindowLabel = "" + settings.attachedHideWhenWindowUnavailable = NotchSettings.defaultAttachedHideWhenWindowUnavailable + settings.hasSeenAttachedOnboarding = false + settings.hasSeenAccessibilityLaunchGuide = false settings.fullscreenScreenID = 0 settings.externalDisplayMode = .off settings.externalScreenID = 0 @@ -1336,6 +2270,13 @@ struct SettingsView: View { settings.listeningMode = .wordTracking settings.scrollSpeed = 3 settings.showElapsedTime = true + settings.activeLayoutPreset = NotchSettings.defaultActiveLayoutPreset + settings.customPresets = [] + settings.persistentHUDEnabled = NotchSettings.defaultPersistentHUDEnabled + settings.hudModules = NotchSettings.defaultHUDModules + settings.qaDebugOverlayEnabled = false + settings.trackingDebugLoggingEnabled = false + settings.anchorDebugLoggingEnabled = false settings.selectedMicUID = "" settings.autoNextPage = false settings.autoNextPageDelay = 3 @@ -1343,6 +2284,33 @@ struct SettingsView: View { settings.browserServerPort = 7373 settings.directorModeEnabled = false settings.directorServerPort = 7575 + qaDebug.clearLogs() + refreshAttachedDiagnostics() + } + + private func qaValueRow(_ label: String, _ value: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 86, alignment: .leading) + Text(value) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var attachedTargetDiagnosticLabel: String { + let label = attachedDiagnostics.targetWindowLabel.isEmpty + ? settings.attachedTargetWindowLabel + : attachedDiagnostics.targetWindowLabel + if settings.attachedTargetWindowID == 0 { + return "None selected" + } + let displayLabel = label.isEmpty ? "Window #\(settings.attachedTargetWindowID)" : label + return "#\(settings.attachedTargetWindowID) • \(displayLabel)" } private func refreshScreens() { @@ -1361,4 +2329,37 @@ struct SettingsView: View { settings.fullscreenScreenID = main.displayID } } + + private func refreshAttachableWindows() { + attachableWindows = WindowAnchorService.visibleWindows() + if settings.attachedTargetWindowID == 0, let first = attachableWindows.first { + settings.attachedTargetWindowID = first.id + settings.attachedTargetWindowLabel = first.displayName + } + refreshAttachedDiagnostics() + } + + private func refreshAttachedDiagnostics() { + attachedDiagnostics.refreshPermissionState() + attachedDiagnostics.syncSelection( + targetWindowID: settings.attachedTargetWindowID, + targetWindowLabel: settings.attachedTargetWindowLabel + ) + if settings.overlayMode != .attached { + attachedDiagnostics.markInactive( + message: "Attached mode inactive", + targetWindowID: settings.attachedTargetWindowID, + targetWindowLabel: settings.attachedTargetWindowLabel + ) + } else if !TextreamService.shared.overlayController.isShowing || TextreamService.shared.overlayController.overlayContent.attachedDiagnosticState == .inactive { + attachedDiagnostics.beginAttachedSession( + targetWindowID: settings.attachedTargetWindowID, + targetWindowLabel: settings.attachedTargetWindowLabel + ) + } + } + + private func refreshRunningOverlayLayout() { + TextreamService.shared.overlayController.refreshPresentationForSettingsChange() + } } diff --git a/Textream/Textream/SpeechRecognizer.swift b/Textream/Textream/SpeechRecognizer.swift index 0730b1c..8582974 100644 --- a/Textream/Textream/SpeechRecognizer.swift +++ b/Textream/Textream/SpeechRecognizer.swift @@ -48,7 +48,10 @@ struct AudioInputDevice: Identifiable, Hashable { ) var uid: CFString = "" as CFString var uidSize = UInt32(MemoryLayout.size) - guard AudioObjectGetPropertyData(deviceID, &uidAddress, 0, nil, &uidSize, &uid) == noErr else { continue } + let uidStatus = withUnsafeMutableBytes(of: &uid) { buffer in + AudioObjectGetPropertyData(deviceID, &uidAddress, 0, nil, &uidSize, buffer.baseAddress!) + } + guard uidStatus == noErr else { continue } // Get name var nameAddress = AudioObjectPropertyAddress( @@ -58,7 +61,10 @@ struct AudioInputDevice: Identifiable, Hashable { ) var name: CFString = "" as CFString var nameSize = UInt32(MemoryLayout.size) - guard AudioObjectGetPropertyData(deviceID, &nameAddress, 0, nil, &nameSize, &name) == noErr else { continue } + let nameStatus = withUnsafeMutableBytes(of: &name) { buffer in + AudioObjectGetPropertyData(deviceID, &nameAddress, 0, nil, &nameSize, buffer.baseAddress!) + } + guard nameStatus == noErr else { continue } result.append(AudioInputDevice(id: deviceID, uid: uid as String, name: name as String)) } @@ -77,8 +83,20 @@ class SpeechRecognizer { var error: String? var audioLevels: [CGFloat] = Array(repeating: 0, count: 30) var lastSpokenText: String = "" + var partialText: String = "" + var latestSegments: [SpeechSegmentSnapshot] = [] + var trackingState: TrackingState = .tracking + var expectedWord: String = "" + var nextCue: String = "" + var confidenceLevel: TrackingConfidence = .low + var confidenceScore: Double = 0 + var statusLine: String = "" + var manualAsideMode: ManualAsideMode = .inactive + var trackingFreezeReason: String = "None" + var trackingDebugSummary: String = "Waiting for speech" var shouldDismiss: Bool = false var shouldAdvancePage: Bool = false + var onTrackingSnapshot: ((TrackingSnapshot, SpeechRecognitionFrame?) -> Void)? /// True when recent audio levels indicate the user is actively speaking var isSpeaking: Bool { @@ -95,12 +113,22 @@ class SpeechRecognizer { private var sourceText: String = "" private var normalizedSource: String = "" private var matchStartOffset: Int = 0 // char offset to start matching from + private var trackingGuard = TrackingGuard() + private var latchedAsideEnabled = false + private var temporaryIgnoreActive = false private var retryCount: Int = 0 private let maxRetries: Int = 10 private var configurationChangeObserver: Any? private var pendingRestart: DispatchWorkItem? private var sessionGeneration: Int = 0 private var suppressConfigChange: Bool = false + private var hasInstalledInputTap = false + + deinit { + pendingRestart?.cancel() + cleanupRecognition() + onTrackingSnapshot = nil + } /// Update the source text while preserving the current recognized char count. /// Used by Director Mode to live-edit unread text without resetting read progress. @@ -111,6 +139,13 @@ class SpeechRecognizer { normalizedSource = Self.normalize(collapsed) recognizedCharCount = min(preservingCharCount, collapsed.count) matchStartOffset = recognizedCharCount + partialText = "" + latestSegments = [] + lastSpokenText = "" + trackingGuard.updateText(collapsed, preservingCharCount: recognizedCharCount) + latchedAsideEnabled = false + temporaryIgnoreActive = false + publishTrackingSnapshot(trackingGuard.snapshot(), frame: nil) } /// Jump highlight to a specific char offset (e.g. when user taps a word) @@ -118,6 +153,8 @@ class SpeechRecognizer { recognizedCharCount = charOffset matchStartOffset = charOffset retryCount = 0 + trackingGuard.jumpTo(charOffset: charOffset) + publishTrackingSnapshot(trackingGuard.snapshot(), frame: nil) if isListening { restartRecognition() } @@ -134,6 +171,12 @@ class SpeechRecognizer { normalizedSource = Self.normalize(collapsed) recognizedCharCount = 0 matchStartOffset = 0 + partialText = "" + latestSegments = [] + trackingGuard.reset(with: collapsed) + latchedAsideEnabled = false + temporaryIgnoreActive = false + publishTrackingSnapshot(trackingGuard.snapshot(), frame: nil) retryCount = 0 error = nil sessionGeneration += 1 @@ -209,6 +252,16 @@ class SpeechRecognizer { beginRecognition() } + func toggleAsideMode() { + latchedAsideEnabled.toggle() + publishTrackingSnapshot(refreshManualAsideMode(), frame: nil) + } + + func setTemporaryIgnoreActive(_ active: Bool) { + temporaryIgnoreActive = active + publishTrackingSnapshot(refreshManualAsideMode(), frame: nil) + } + private func cleanupRecognition() { // Cancel any pending restart to prevent overlapping beginRecognition calls pendingRestart?.cancel() @@ -225,7 +278,11 @@ class SpeechRecognizer { if audioEngine.isRunning { audioEngine.stop() } - audioEngine.inputNode.removeTap(onBus: 0) + if hasInstalledInputTap { + audioEngine.inputNode.removeTap(onBus: 0) + hasInstalledInputTap = false + } + speechRecognizer = nil } /// Coalesces all delayed beginRecognition() calls into a single pending work item. @@ -249,6 +306,7 @@ class SpeechRecognizer { // AVAudioEngine caches the device format internally and reset() alone // does not reliably flush it after a mic switch. audioEngine = AVAudioEngine() + hasInstalledInputTap = false // Set selected microphone if configured let micUID = NotchSettings.shared.selectedMicUID @@ -312,9 +370,6 @@ class SpeechRecognizer { self.restartRecognition() } - // Belt-and-suspenders: ensure no stale tap exists before installing - inputNode.removeTap(onBus: 0) - inputNode.installTap(onBus: 0, bufferSize: 1024, format: nil) { [weak self] buffer, _ in recognitionRequest.append(buffer) @@ -334,6 +389,7 @@ class SpeechRecognizer { } } } + hasInstalledInputTap = true let currentGeneration = sessionGeneration recognitionTask = speechRecognizer.recognitionTask(with: recognitionRequest) { [weak self] result, error in @@ -345,7 +401,23 @@ class SpeechRecognizer { guard self.sessionGeneration == currentGeneration else { return } self.retryCount = 0 // Reset on success self.lastSpokenText = spoken - self.matchCharacters(spoken: spoken) + self.partialText = spoken + let segments = result.bestTranscription.segments.map { + SpeechSegmentSnapshot( + text: $0.substring, + confidence: Double($0.confidence), + timestamp: $0.timestamp, + duration: $0.duration + ) + } + self.latestSegments = segments + let frame = SpeechRecognitionFrame( + partialText: spoken, + segments: segments, + isFinal: result.isFinal, + createdAt: Date() + ) + self.consumeRecognitionFrame(frame) } } if error != nil { @@ -388,22 +460,59 @@ class SpeechRecognizer { scheduleBeginRecognition(after: 0.5) } + private func consumeRecognitionFrame(_ frame: SpeechRecognitionFrame) { + let settings = NotchSettings.shared + let snapshot = trackingGuard.process( + frame: frame, + isSpeaking: isSpeaking, + strictTrackingEnabled: settings.strictTrackingEnabled, + advanceThreshold: settings.advanceThreshold, + windowSize: settings.matchWindowSize, + offScriptFreezeDelay: settings.offScriptFreezeDelay, + useLegacyFallback: settings.legacyTrackingFallbackEnabled + ) { [weak self] spoken, startOffset in + self?.legacyAdvance(spoken: spoken, startOffset: startOffset) ?? startOffset + } + matchStartOffset = snapshot.highlightedCharCount + publishTrackingSnapshot(snapshot, frame: frame) + } + + private func refreshManualAsideMode() -> TrackingSnapshot { + let nextMode: ManualAsideMode + if temporaryIgnoreActive { + nextMode = .hold + } else if latchedAsideEnabled { + nextMode = .toggled + } else { + nextMode = .inactive + } + return trackingGuard.setManualAsideMode(nextMode) + } + + private func publishTrackingSnapshot(_ snapshot: TrackingSnapshot, frame: SpeechRecognitionFrame?) { + recognizedCharCount = snapshot.highlightedCharCount + trackingState = snapshot.trackingState + expectedWord = snapshot.expectedWord + nextCue = snapshot.nextCue + confidenceLevel = snapshot.confidenceLevel + confidenceScore = snapshot.confidenceScore + manualAsideMode = snapshot.manualAsideMode + statusLine = snapshot.statusLine + trackingFreezeReason = snapshot.decisionReason.freezeLabel + trackingDebugSummary = snapshot.debugSummary + QADebugStore.shared.recordTracking(snapshot: snapshot, frame: frame) + onTrackingSnapshot?(snapshot, frame) + } + // MARK: - Fuzzy character-level matching - private func matchCharacters(spoken: String) { - // Strategy 1: character-level fuzzy match from the start offset - let charResult = charLevelMatch(spoken: spoken) + private func legacyAdvance(spoken: String, startOffset: Int) -> Int { + matchStartOffset = startOffset - // Strategy 2: word-level match (handles STT word substitutions) + let charResult = charLevelMatch(spoken: spoken) let wordResult = wordLevelMatch(spoken: spoken) - let best = max(charResult, wordResult) - - // Only move forward from the match start offset - let newCount = matchStartOffset + best - if newCount > recognizedCharCount { - recognizedCharCount = min(newCount, sourceText.count) - } + return min(startOffset + best, sourceText.count) } private func charLevelMatch(spoken: String) -> Int { @@ -476,12 +585,6 @@ class SpeechRecognizer { return lastGoodOrigIndex } - private static func isAnnotationWord(_ word: String) -> Bool { - if word.hasPrefix("[") && word.hasSuffix("]") { return true } - let stripped = word.filter { $0.isLetter || $0.isNumber } - return stripped.isEmpty - } - private func wordLevelMatch(spoken: String) -> Int { let remainingSource = String(sourceText.dropFirst(matchStartOffset)) let sourceWords = remainingSource.split(separator: " ").map { String($0) } @@ -492,18 +595,17 @@ class SpeechRecognizer { var matchedCharCount = 0 while si < sourceWords.count && ri < spokenWords.count { - // Auto-skip annotation words in source (brackets, emoji) - if Self.isAnnotationWord(sourceWords[si]) { + // Auto-skip punctuation-only / emoji-only tokens, but keep bracket cues + // like [wave] matchable in the legacy fallback path. + if shouldAutoSkipForTracking(sourceWords[si]) { matchedCharCount += sourceWords[si].count if si < sourceWords.count - 1 { matchedCharCount += 1 } si += 1 continue } - let srcWord = sourceWords[si].lowercased() - .filter { $0.isLetter || $0.isNumber } - let spkWord = spokenWords[ri] - .filter { $0.isLetter || $0.isNumber } + let srcWord = normalizedTrackingToken(sourceWords[si]) + let spkWord = normalizedTrackingToken(spokenWords[ri]) if srcWord == spkWord || isFuzzyMatch(srcWord, spkWord) { // Count original chars including trailing punctuation, plus space @@ -518,7 +620,7 @@ class SpeechRecognizer { var foundSpk = false let maxSpkSkip = min(3, spokenWords.count - ri - 1) for skip in 1...max(1, maxSpkSkip) where skip <= maxSpkSkip { - let nextSpk = spokenWords[ri + skip].filter { $0.isLetter || $0.isNumber } + let nextSpk = normalizedTrackingToken(spokenWords[ri + skip]) if srcWord == nextSpk || isFuzzyMatch(srcWord, nextSpk) { ri += skip foundSpk = true @@ -531,7 +633,7 @@ class SpeechRecognizer { var foundSrc = false let maxSrcSkip = min(3, sourceWords.count - si - 1) for skip in 1...max(1, maxSrcSkip) where skip <= maxSrcSkip { - let nextSrc = sourceWords[si + skip].lowercased().filter { $0.isLetter || $0.isNumber } + let nextSrc = normalizedTrackingToken(sourceWords[si + skip]) if nextSrc == spkWord || isFuzzyMatch(nextSrc, spkWord) { // Add all skipped source words' char counts for s in 0..= 0x4E00 && v <= 0x9FFF) + || (v >= 0x3400 && v <= 0x4DBF) + || (v >= 0x20000 && v <= 0x2A6DF) + || (v >= 0xF900 && v <= 0xFAFF) + || (v >= 0x3040 && v <= 0x309F) + || (v >= 0x30A0 && v <= 0x30FF) + || (v >= 0xAC00 && v <= 0xD7AF) + } +} + +/// Splits text into display-ready words. CJK characters (Chinese, Japanese, Korean) +/// are split into individual characters so the flow layout can wrap them properly. +func splitTextIntoWords(_ text: String) -> [String] { + let tokens = text.replacingOccurrences(of: "\n", with: " ") + .split(omittingEmptySubsequences: true, whereSeparator: { $0.isWhitespace }) + .map { String($0) } + + var result: [String] = [] + for token in tokens { + guard token.unicodeScalars.contains(where: { $0.isCJK }) else { + result.append(token) + continue + } + + var buffer = "" + for char in token { + if char.unicodeScalars.first.map({ $0.isCJK }) == true { + if !buffer.isEmpty { + result.append(buffer) + buffer = "" + } + result.append(String(char)) + } else { + buffer.append(char) + } + } + + if !buffer.isEmpty { + result.append(buffer) + } + } + + return result +} + +func isBracketCueWord(_ word: String) -> Bool { + word.hasPrefix("[") && word.hasSuffix("]") && word.count > 2 +} + +/// Token used by speech matching and cue progression. +func normalizedTrackingToken(_ word: String) -> String { + word.lowercased().filter { $0.isLetter || $0.isNumber } +} + +/// Spoken tracking should ignore stage directions like `[wave]`, while still +/// rendering them distinctly in the teleprompter. +func wordParticipatesInTracking(_ word: String) -> Bool { + !isBracketCueWord(word) && !normalizedTrackingToken(word).isEmpty +} + +func isStyledAnnotationWord(_ word: String) -> Bool { + if isBracketCueWord(word) { return true } + return !wordParticipatesInTracking(word) +} + +func shouldAutoSkipForTracking(_ word: String) -> Bool { + !wordParticipatesInTracking(word) +} diff --git a/Textream/Textream/TextreamApp.swift b/Textream/Textream/TextreamApp.swift index b8e17d2..0a5a68f 100644 --- a/Textream/Textream/TextreamApp.swift +++ b/Textream/Textream/TextreamApp.swift @@ -12,8 +12,18 @@ extension Notification.Name { static let openAbout = Notification.Name("openAbout") } +enum AppRuntime { + static let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + static let isRunningUITests = + ProcessInfo.processInfo.arguments.contains("-ui-testing") || + ProcessInfo.processInfo.environment["TEXTREAM_UI_TESTING"] == "1" + static let isHeadlessTestRuntime = isRunningTests && !isRunningUITests +} + class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func applicationWillFinishLaunching(_ notification: Notification) { + guard !AppRuntime.isHeadlessTestRuntime else { return } + UITestRuntimeSupport.configureIfNeeded() NSWindow.allowsAutomaticWindowTabbing = false let launchedByURL: Bool if let event = NSAppleEventManager.shared().currentAppleEvent { @@ -28,34 +38,50 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { + guard !AppRuntime.isHeadlessTestRuntime else { return } NSApp.servicesProvider = TextreamService.shared - NSUpdateDynamicServices() + if !AppRuntime.isRunningUITests { + NSUpdateDynamicServices() + } + AttachedDiagnosticsStore.shared.refreshPermissionState() + AttachedDiagnosticsStore.shared.markInactive( + message: "Attached mode inactive", + targetWindowID: NotchSettings.shared.attachedTargetWindowID, + targetWindowLabel: NotchSettings.shared.attachedTargetWindowLabel + ) if TextreamService.shared.launchedExternally { TextreamService.shared.hideMainWindow() } - // Silent update check on launch - UpdateChecker.shared.checkForUpdates(silent: true) + if !AppRuntime.isRunningUITests { + // Silent update check on launch + UpdateChecker.shared.checkForUpdates(silent: true) - // Start browser server if enabled - TextreamService.shared.updateBrowserServer() + // Start browser server if enabled + TextreamService.shared.updateBrowserServer() - // Start director server if enabled - TextreamService.shared.updateDirectorServer() + // Start director server if enabled + TextreamService.shared.updateDirectorServer() - // Set window delegate to intercept close, disable tabs and fullscreen - DispatchQueue.main.async { - for window in NSApp.windows where !(window is NSPanel) { - window.delegate = self - window.tabbingMode = .disallowed - window.collectionBehavior.remove(.fullScreenPrimary) - window.collectionBehavior.insert(.fullScreenNone) + // Set window delegate to intercept close, disable tabs and fullscreen + DispatchQueue.main.async { + for window in NSApp.windows where !(window is NSPanel) { + window.delegate = self + window.tabbingMode = .disallowed + window.collectionBehavior.remove(.fullScreenPrimary) + window.collectionBehavior.insert(.fullScreenNone) + } + self.removeUnwantedMenus() } - self.removeUnwantedMenus() } } + func applicationDidBecomeActive(_ notification: Notification) { + guard !AppRuntime.isHeadlessTestRuntime else { return } + AttachedDiagnosticsStore.shared.refreshPermissionState() + } + private func removeUnwantedMenus() { guard let mainMenu = NSApp.mainMenu else { return } // Remove View and Window menus (keep Edit for copy/paste) @@ -116,16 +142,25 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { struct TextreamApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + init() { + UITestRuntimeSupport.configureIfNeeded() + } + var body: some Scene { WindowGroup { - ContentView() - .onOpenURL { url in - if url.pathExtension == "textream" { - TextreamService.shared.openFileAtURL(url) - } else { - TextreamService.shared.handleURL(url) + if AppRuntime.isHeadlessTestRuntime { + Color.clear + .frame(width: 1, height: 1) + } else { + rootContentView + .onOpenURL { url in + if url.pathExtension == "textream" { + TextreamService.shared.openFileAtURL(url) + } else { + TextreamService.shared.handleURL(url) + } } - } + } } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) @@ -174,4 +209,13 @@ struct TextreamApp: App { } } } + + @ViewBuilder + private var rootContentView: some View { + if AppRuntime.isRunningUITests { + UITestHarnessRootView() + } else { + ContentView() + } + } } diff --git a/Textream/Textream/TextreamService.swift b/Textream/Textream/TextreamService.swift index df9e9be..58920a0 100644 --- a/Textream/Textream/TextreamService.swift +++ b/Textream/Textream/TextreamService.swift @@ -12,10 +12,10 @@ import UniformTypeIdentifiers class TextreamService: NSObject, ObservableObject { static let shared = TextreamService() - let overlayController = NotchOverlayController() - let externalDisplayController = ExternalDisplayController() - let browserServer = BrowserServer() - let directorServer = DirectorServer() + lazy var overlayController = NotchOverlayController() + lazy var externalDisplayController = ExternalDisplayController() + lazy var browserServer = BrowserServer() + lazy var directorServer = DirectorServer() var onOverlayDismissed: (() -> Void)? var launchedExternally = false @Published var directorIsReading = false @@ -24,6 +24,11 @@ class TextreamService: NSObject, ObservableObject { @Published var currentPageIndex: Int = 0 @Published var readPages: Set = [] + override init() { + UITestRuntimeSupport.configureIfNeeded() + super.init() + } + var hasNextPage: Bool { for i in (currentPageIndex + 1).. TrackingSnapshot { + manualAsideMode = mode + currentState = mode.isActive ? .aside : .tracking + confidenceLevel = mode.isActive ? .medium : .low + confidenceScore = mode.isActive ? 0.5 : 0 + decisionReason = mode.isActive ? .manualAside : .idle + debugSummary = mode.isActive + ? (mode == .hold ? "Temporary ignore is active while Fn is held" : "Latched aside mode is active") + : "Manual aside released" + unmatchedFrameCount = 0 + recoveryStreak = 0 + return snapshot() + } + + mutating func process( + frame: SpeechRecognitionFrame, + isSpeaking: Bool, + strictTrackingEnabled: Bool, + advanceThreshold: Double, + windowSize: Int, + offScriptFreezeDelay: TimeInterval, + useLegacyFallback: Bool, + legacyAdvance: ((String, Int) -> Int)? = nil + ) -> TrackingSnapshot { + guard !sourceText.isEmpty, !tokens.isEmpty else { + return snapshot() + } + + if manualAsideMode.isActive { + currentState = .aside + decisionReason = .manualAside + debugSummary = manualAsideMode == .hold + ? "Ignoring speech while hold-to-ignore is active" + : "Ignoring speech while aside mode is toggled on" + return snapshot() + } + + if !strictTrackingEnabled, useLegacyFallback, let legacyAdvance { + let newCount = advanceHighlightPastSkippedTokens( + from: legacyAdvance(frame.partialText, highlightedCharCount) + ) + if newCount > highlightedCharCount { + highlightedCharCount = min(newCount, sourceText.count) + currentState = .tracking + confidenceLevel = .medium + confidenceScore = 0.5 + decisionReason = .legacyFallbackAdvance + debugSummary = "Legacy fallback advanced highlight to char \(highlightedCharCount)" + unmatchedFrameCount = 0 + recoveryStreak = 0 + lastStrongMatchAt = frame.createdAt + } else if isSpeaking { + transitionToUncertainOrLost( + now: frame.createdAt, + offScriptFreezeDelay: offScriptFreezeDelay, + reason: .legacyFallbackNoAdvance, + summary: "Legacy fallback heard speech but did not advance the cursor" + ) + } + return snapshot() + } + + let recentWords = Self.normalizeSpokenWords(from: frame.segments) + guard !recentWords.isEmpty else { + if isSpeaking { + transitionToUncertainOrLost( + now: frame.createdAt, + offScriptFreezeDelay: offScriptFreezeDelay, + reason: .noSpeechSegments, + summary: "Audio was active but Speech returned no usable segments" + ) + } + return snapshot() + } + + let best = bestCandidate( + for: recentWords, + averageConfidence: frame.averageConfidence, + windowSize: windowSize + ) + let requiredMatches = requiredMatchCount(for: recentWords.count) + + if let best, + best.score >= advanceThreshold, + best.wordCount >= requiredMatches { + let newCharCount = advanceHighlightPastSkippedTokens( + from: min(tokens[best.endIndex].charEnd, sourceText.count) + ) + let canAdvance = newCharCount >= highlightedCharCount + + if canAdvance { + if currentState == .lost || currentState == .aside { + recoveryStreak += 1 + if recoveryStreak < 2 { + currentState = .uncertain + confidenceLevel = confidence(from: best.score) + confidenceScore = best.score + decisionReason = .recoveryPending + debugSummary = "Recovery confirmation 1/2 • score \(Self.format(best.score)) • matched \(best.wordCount) words" + return snapshot() + } + } + + highlightedCharCount = newCharCount + currentState = .tracking + confidenceLevel = confidence(from: best.score) + confidenceScore = best.score + decisionReason = .advanced + debugSummary = "Advanced \(best.wordCount) words • score \(Self.format(best.score)) • cursor \(highlightedCharCount)" + unmatchedFrameCount = 0 + recoveryStreak = 0 + lastStrongMatchAt = frame.createdAt + return snapshot() + } + } + + if isSpeaking { + let spokenSummary = recentWords.joined(separator: " ") + if let best, best.wordCount < requiredMatches, best.score >= advanceThreshold { + transitionToUncertainOrLost( + now: frame.createdAt, + offScriptFreezeDelay: offScriptFreezeDelay, + reason: .insufficientWordMatch, + summary: "Matched only \(best.wordCount)/\(requiredMatches) words for \"\(spokenSummary)\"" + ) + } else if let best { + transitionToUncertainOrLost( + now: frame.createdAt, + offScriptFreezeDelay: offScriptFreezeDelay, + reason: .lowMatchScore, + summary: "Best score \(Self.format(best.score)) stayed below threshold \(Self.format(advanceThreshold)) for \"\(spokenSummary)\"" + ) + } else { + transitionToUncertainOrLost( + now: frame.createdAt, + offScriptFreezeDelay: offScriptFreezeDelay, + reason: .offScriptAudio, + summary: "No aligned script window found for \"\(spokenSummary)\"" + ) + } + } + + return snapshot() + } + + func snapshot() -> TrackingSnapshot { + TrackingSnapshot( + highlightedCharCount: highlightedCharCount, + trackingState: currentState, + expectedWord: expectedWord(), + nextCue: nextCue(), + confidenceLevel: confidenceLevel, + manualAsideMode: manualAsideMode, + statusLine: statusLine(), + confidenceScore: confidenceScore, + decisionReason: decisionReason, + debugSummary: debugSummary + ) + } + + func currentWordIndex() -> Int { + currentParticipatingWordIndex() ?? max(tokens.count - 1, 0) + } + + private func expectedWord() -> String { + guard let index = currentParticipatingWordIndex() else { return "" } + guard tokens.indices.contains(index) else { return "" } + return tokens[index].raw + } + + private func nextCue() -> String { + guard let currentIndex = currentParticipatingWordIndex(), + let currentOrdinal = participatingOrdinal(forTokenIndex: currentIndex) else { + return "" + } + + let startOrdinal = currentOrdinal + 1 + let endOrdinal = min(startOrdinal + 5, participatingTokenIndices.count) + guard startOrdinal < endOrdinal else { return "" } + + return participatingTokenIndices[startOrdinal.. String { + switch currentState { + case .tracking: + let word = expectedWord() + return word.isEmpty ? "Tracking your script" : "Tracking: \(word)" + case .uncertain: + return "Heard you. Checking the script before moving." + case .aside: + return manualAsideMode == .hold + ? "Aside active. Tracking is paused while you hold." + : "Aside mode is on. Tracking is paused." + case .lost: + return "Off script. Waiting to lock back on." + } + } + + private mutating func transitionToUncertainOrLost( + now: Date, + offScriptFreezeDelay: TimeInterval, + reason: TrackingDecisionReason, + summary: String + ) { + unmatchedFrameCount += 1 + recoveryStreak = 0 + confidenceScore = 0.15 + confidenceLevel = .low + decisionReason = reason + debugSummary = summary + let elapsed = now.timeIntervalSince(lastStrongMatchAt) + if unmatchedFrameCount >= 2 && elapsed >= offScriptFreezeDelay { + currentState = .lost + } else { + currentState = .uncertain + } + } + + private func bestCandidate( + for recentWords: [String], + averageConfidence: Double, + windowSize: Int + ) -> CandidateResult? { + guard !tokens.isEmpty, + let cursor = currentParticipatingWordIndex(), + let cursorOrdinal = participatingOrdinal(forTokenIndex: cursor), + !participatingTokenIndices.isEmpty else { + return nil + } + + let maxStart = max(participatingTokenIndices.count - 1, 0) + let startWindow = max(0, cursorOrdinal - 2) + let endWindow = min(maxStart, cursorOrdinal + max(4, windowSize)) + let maxAdvanceOrdinal = min(participatingTokenIndices.count - 1, cursorOrdinal + 6) + let maxAdvanceTokenIndex = participatingTokenIndices[maxAdvanceOrdinal] + + var best: CandidateResult? + + for candidateStart in startWindow...endWindow { + let sliceEnd = min(participatingTokenIndices.count, candidateStart + recentWords.count + 4) + guard candidateStart < sliceEnd else { continue } + + let candidateTokenIndices = Array(participatingTokenIndices[candidateStart..= cursor && endIndex <= maxAdvanceTokenIndex else { continue } + + let distance = abs(candidateStart - cursorOrdinal) + let proximityBonus = max(0, 1.25 - Double(distance) * 0.12) + let score = alignment.score + proximityBonus + averageConfidence * 1.4 + let candidate = CandidateResult(endIndex: endIndex, score: score, wordCount: alignment.matchedWordCount) + + if let currentBest = best { + if candidate.score > currentBest.score { + best = candidate + } + } else { + best = candidate + } + } + + return best + } + + private func confidence(from score: Double) -> TrackingConfidence { + if score >= 3.8 { return .high } + if score >= 2.4 { return .medium } + return .low + } + + private func requiredMatchCount(for spokenWordCount: Int) -> Int { + guard spokenWordCount > 1 else { return 1 } + return max(2, Int(ceil(Double(spokenWordCount) * 0.5))) + } + + private static func makeTokens(from words: [String]) -> [ScriptToken] { + var offset = 0 + return words.enumerated().map { index, word in + let start = offset + offset += word.count + let end = offset + if index < words.count - 1 { + offset += 1 + } + let participates = wordParticipatesInTracking(word) + return ScriptToken( + raw: word, + normalized: participates ? normalizeWord(word) : "", + charStart: start, + charEnd: end, + participatesInTracking: participates + ) + } + } + + private static func normalizeSpokenWords(from segments: [SpeechSegmentSnapshot]) -> [String] { + let allWords = segments + .suffix(5) + .flatMap { segment in + segment.text + .split(whereSeparator: \.isWhitespace) + .map { normalizeWord(String($0)) } + } + .filter { !$0.isEmpty } + if allWords.count <= 5 { return allWords } + return Array(allWords.suffix(5)) + } + + private static func normalizeWord(_ word: String) -> String { + normalizedTrackingToken(word) + } + + private static func wordIndex(for charOffset: Int, in tokens: [ScriptToken]) -> Int { + for (index, token) in tokens.enumerated() where charOffset < token.charEnd { + return index + } + return max(tokens.count - 1, 0) + } + + private func currentParticipatingWordIndex() -> Int? { + guard !tokens.isEmpty, + !participatingTokenIndices.isEmpty, + highlightedCharCount < sourceText.count else { + return nil + } + let rawIndex = tokens.firstIndex(where: { highlightedCharCount < $0.charEnd }) ?? tokens.count + return nextParticipatingTokenIndex(startingAt: rawIndex) + } + + private func nextParticipatingTokenIndex(startingAt index: Int) -> Int? { + guard !participatingTokenIndices.isEmpty else { return nil } + for candidate in max(0, index).. Int? { + participatingTokenIndices.firstIndex(of: index) + } + + private func advanceHighlightPastSkippedTokens(from charCount: Int) -> Int { + guard !tokens.isEmpty else { return max(0, min(charCount, sourceText.count)) } + + var nextCount = max(0, min(charCount, sourceText.count)) + var index = Self.wordIndex(for: nextCount, in: tokens) + + while tokens.indices.contains(index), !tokens[index].participatesInTracking { + nextCount = max(nextCount, tokens[index].charEnd) + index += 1 + } + + if participatingTokenIndices.isEmpty { + return sourceText.count + } + + return min(nextCount, sourceText.count) + } + + private static func format(_ value: Double) -> String { + String(format: "%.2f", value) + } + + private static func align(spoken: [String], script: [String]) -> AlignmentResult { + guard !spoken.isEmpty, !script.isEmpty else { + return AlignmentResult(score: 0, lastMatchedScriptIndex: nil, matchedWordCount: 0) + } + + var scores = Array( + repeating: Array(repeating: 0.0, count: script.count + 1), + count: spoken.count + 1 + ) + var matches = Array( + repeating: Array(repeating: 0, count: script.count + 1), + count: spoken.count + 1 + ) + var lastMatch = Array( + repeating: Array(repeating: -1, count: script.count + 1), + count: spoken.count + 1 + ) + + var bestScore = 0.0 + var bestMatchCount = 0 + var bestLastMatch = -1 + + for i in 1...spoken.count { + for j in 1...script.count { + let wordScore: Double + if spoken[i - 1] == script[j - 1] { + wordScore = 1.3 + } else if fuzzyMatch(spoken[i - 1], script[j - 1]) { + wordScore = 0.9 + } else { + wordScore = -0.45 + } + + let diag = scores[i - 1][j - 1] + wordScore + let up = scores[i - 1][j] - 0.25 + let left = scores[i][j - 1] - 0.15 + + var bestCell = 0.0 + var matchCount = 0 + var last = -1 + + if diag >= up, diag >= left, diag > 0 { + bestCell = diag + matchCount = matches[i - 1][j - 1] + last = lastMatch[i - 1][j - 1] + if wordScore > 0 { + matchCount += 1 + last = j - 1 + } + } else if up >= left, up > 0 { + bestCell = up + matchCount = matches[i - 1][j] + last = lastMatch[i - 1][j] + } else if left > 0 { + bestCell = left + matchCount = matches[i][j - 1] + last = lastMatch[i][j - 1] + } + + scores[i][j] = bestCell + matches[i][j] = matchCount + lastMatch[i][j] = last + + if bestCell > bestScore || (bestCell == bestScore && matchCount > bestMatchCount) { + bestScore = bestCell + bestMatchCount = matchCount + bestLastMatch = last + } + } + } + + return AlignmentResult( + score: bestScore, + lastMatchedScriptIndex: bestLastMatch >= 0 ? bestLastMatch : nil, + matchedWordCount: bestMatchCount + ) + } + + private static func fuzzyMatch(_ a: String, _ b: String) -> Bool { + guard !a.isEmpty, !b.isEmpty else { return false } + if a == b { return true } + if a.hasPrefix(b) || b.hasPrefix(a) { return true } + if a.contains(b) || b.contains(a) { return true } + + let sharedPrefix = zip(a, b).prefix(while: { $0 == $1 }).count + let shorter = min(a.count, b.count) + if shorter >= 2 && sharedPrefix >= max(2, shorter * 3 / 5) { + return true + } + + return editDistance(a, b) <= (shorter <= 4 ? 1 : 2) + } + + private static func editDistance(_ a: String, _ b: String) -> Int { + let lhs = Array(a) + let rhs = Array(b) + var dp = Array(0...rhs.count) + for i in 1...lhs.count { + var previous = dp[0] + dp[0] = i + for j in 1...rhs.count { + let current = dp[j] + if lhs[i - 1] == rhs[j - 1] { + dp[j] = previous + } else { + dp[j] = min(previous, dp[j - 1], dp[j]) + 1 + } + previous = current + } + } + return dp[rhs.count] + } +} diff --git a/Textream/Textream/TrackingHotkeyController.swift b/Textream/Textream/TrackingHotkeyController.swift new file mode 100644 index 0000000..6e18bbb --- /dev/null +++ b/Textream/Textream/TrackingHotkeyController.swift @@ -0,0 +1,126 @@ +// +// TrackingHotkeyController.swift +// Textream +// +// Created by OpenAI Codex on 21.03.2026. +// + +import AppKit + +protocol TrackingHotkeyControlling: AnyObject { + var onToggleAside: (() -> Void)? { get set } + var onHoldIgnoreChanged: ((Bool) -> Void)? { get set } + var isRunning: Bool { get } + + func start() + func stop() +} + +final class TrackingHotkeyController: TrackingHotkeyControlling { + static var shared: any TrackingHotkeyControlling = TrackingHotkeyController() + + var onToggleAside: (() -> Void)? + var onHoldIgnoreChanged: ((Bool) -> Void)? + + private var localFlagsMonitor: Any? + private var globalFlagsMonitor: Any? + private var localKeyMonitor: Any? + private var globalKeyMonitor: Any? + private var lastOptionTapAt: Date? + private var isFnActive = false + private var optionIsDown = false + + static func installShared(_ controller: any TrackingHotkeyControlling) { + shared = controller + } + + static func resetShared() { + shared = TrackingHotkeyController() + } + + var isRunning: Bool { + localFlagsMonitor != nil || globalFlagsMonitor != nil || localKeyMonitor != nil || globalKeyMonitor != nil + } + + func start() { + stop() + + localFlagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + self?.handleFlagsChanged(event) + return event + } + + globalFlagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + self?.handleFlagsChanged(event) + } + + localKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + self?.handleKeyDown(event) + return event + } + + globalKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in + self?.handleKeyDown(event) + } + } + + func stop() { + if let localFlagsMonitor { + NSEvent.removeMonitor(localFlagsMonitor) + } + if let globalFlagsMonitor { + NSEvent.removeMonitor(globalFlagsMonitor) + } + if let localKeyMonitor { + NSEvent.removeMonitor(localKeyMonitor) + } + if let globalKeyMonitor { + NSEvent.removeMonitor(globalKeyMonitor) + } + localFlagsMonitor = nil + globalFlagsMonitor = nil + localKeyMonitor = nil + globalKeyMonitor = nil + if isFnActive { + isFnActive = false + onHoldIgnoreChanged?(false) + } + optionIsDown = false + lastOptionTapAt = nil + } + + private func handleFlagsChanged(_ event: NSEvent) { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let optionDown = flags.contains(.option) + if optionDown != optionIsDown { + if optionDown { + registerOptionTap() + } + optionIsDown = optionDown + } + + let fnDown = flags.contains(.function) + if fnDown != isFnActive { + isFnActive = fnDown + onHoldIgnoreChanged?(fnDown) + } + } + + private func handleKeyDown(_ event: NSEvent) { + guard event.keyCode == 63 else { return } // fn/globe key + if !isFnActive { + isFnActive = true + onHoldIgnoreChanged?(true) + } + } + + private func registerOptionTap() { + let now = Date() + if let lastTap = lastOptionTapAt, now.timeIntervalSince(lastTap) <= 0.35 { + lastOptionTapAt = nil + onToggleAside?() + } else { + lastOptionTapAt = now + } + } +} diff --git a/Textream/Textream/UITestSupport.swift b/Textream/Textream/UITestSupport.swift new file mode 100644 index 0000000..2be8c8c --- /dev/null +++ b/Textream/Textream/UITestSupport.swift @@ -0,0 +1,361 @@ +// +// UITestSupport.swift +// Textream +// +// Created by OpenAI Codex on 21.03.2026. +// + +import AppKit +import SwiftUI + +enum UITestRuntimeSupport { + static let sampleText = "UI testing keeps the overlay visible while we verify attached positioning and HUD state." + static let mockWindowID = 9001 + static let anchorProvider = UITestWindowAnchorProvider() + static let hotkeyController = UITestTrackingHotkeyController() + private static var didConfigure = false + + static func configureIfNeeded() { + guard AppRuntime.isRunningUITests else { return } + guard !didConfigure else { return } + didConfigure = true + WindowAnchorService.installSharedProvider(anchorProvider) + TrackingHotkeyController.installShared(hotkeyController) + anchorProvider.reset() + hotkeyController.reset() + + let settings = NotchSettings.shared + settings.browserServerEnabled = false + settings.directorModeEnabled = false + settings.showElapsedTime = false + settings.qaDebugOverlayEnabled = false + settings.trackingDebugLoggingEnabled = false + settings.anchorDebugLoggingEnabled = false + settings.persistentHUDEnabled = true + settings.hudModules = [.trackingState, .expectedWord, .nextCue, .microphoneStatus] + settings.overlayMode = .floating + settings.listeningMode = .classic + settings.attachedAnchorCorner = .topRight + settings.attachedMarginX = 16 + settings.attachedMarginY = 14 + settings.attachedTargetWindowID = mockWindowID + settings.attachedTargetWindowLabel = "UITest Anchor" + settings.attachedHideWhenWindowUnavailable = false + settings.attachedFallbackBehavior = .screenCorner + settings.hasSeenAttachedOnboarding = true + } + + static func resetIfNeeded() { + guard AppRuntime.isRunningUITests else { return } + didConfigure = false + WindowAnchorService.resetSharedProvider() + TrackingHotkeyController.resetShared() + } +} + +final class UITestWindowAnchorProvider: WindowAnchorProviding { + struct MockWindow { + var info: AttachedWindowInfo + var accessibilityFrame: CGRect? + } + + var accessibilityTrusted = true + var didRequestAccessibilityPrompt = false + var didOpenAccessibilitySettings = false + private var windowsByID: [Int: MockWindow] = [:] + + init() { + reset() + } + + func reset() { + accessibilityTrusted = true + didRequestAccessibilityPrompt = false + didOpenAccessibilitySettings = false + let frame = CGRect(x: 360, y: 420, width: 720, height: 520) + let window = AttachedWindowInfo( + id: UITestRuntimeSupport.mockWindowID, + ownerName: "UITestHost", + title: "Mock Target", + pid: 42, + bounds: frame, + layer: 0, + isOnScreen: true + ) + windowsByID = [ + window.id: MockWindow(info: window, accessibilityFrame: frame), + ] + } + + func isAccessibilityTrusted(prompt: Bool) -> Bool { + if prompt { + didRequestAccessibilityPrompt = true + } + return accessibilityTrusted + } + + func openAccessibilitySettings() { + didOpenAccessibilitySettings = true + } + + func visibleWindows() -> [AttachedWindowInfo] { + windowsByID.values.map(\.info) + } + + func accessibilityFrame(for target: AttachedWindowInfo) -> CGRect? { + windowsByID[target.id]?.accessibilityFrame + } + + func updateWindow(frame: CGRect, axFrame: CGRect? = nil) { + guard var window = windowsByID[UITestRuntimeSupport.mockWindowID] else { return } + window.info = AttachedWindowInfo( + id: window.info.id, + ownerName: window.info.ownerName, + title: window.info.title, + pid: window.info.pid, + bounds: frame, + layer: window.info.layer, + isOnScreen: window.info.isOnScreen + ) + window.accessibilityFrame = axFrame ?? frame + windowsByID[UITestRuntimeSupport.mockWindowID] = window + } + + func shiftWindow(dx: CGFloat, dy: CGFloat) { + guard let current = windowsByID[UITestRuntimeSupport.mockWindowID] else { return } + let nextFrame = current.info.bounds.offsetBy(dx: dx, dy: dy) + let nextAXFrame = current.accessibilityFrame?.offsetBy(dx: dx, dy: dy) + updateWindow(frame: nextFrame, axFrame: nextAXFrame) + } + + func setAccessibilityFrameAvailable(_ isAvailable: Bool) { + guard var window = windowsByID[UITestRuntimeSupport.mockWindowID] else { return } + window.accessibilityFrame = isAvailable ? window.info.bounds : nil + windowsByID[UITestRuntimeSupport.mockWindowID] = window + } +} + +final class UITestTrackingHotkeyController: TrackingHotkeyControlling { + var onToggleAside: (() -> Void)? + var onHoldIgnoreChanged: ((Bool) -> Void)? + + private(set) var startCount = 0 + private(set) var stopCount = 0 + private(set) var isRunning = false + + func start() { + startCount += 1 + isRunning = true + } + + func stop() { + stopCount += 1 + isRunning = false + onHoldIgnoreChanged?(false) + } + + func reset() { + onToggleAside = nil + onHoldIgnoreChanged = nil + startCount = 0 + stopCount = 0 + isRunning = false + } + + func simulateToggleAside() { + onToggleAside?() + } + + func simulateHoldIgnore(_ active: Bool) { + onHoldIgnoreChanged?(active) + } +} + +struct UITestHarnessContainer: View { + let content: Content + private let diagnostics = AttachedDiagnosticsStore.shared + + var body: some View { + TimelineView(.periodic(from: .now, by: 0.1)) { _ in + ZStack(alignment: .bottomLeading) { + content + harness + } + } + } + + private var harness: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Button("Pinned") { launchOverlay(mode: .pinned) } + .accessibilityIdentifier("uiharness.run.pinned") + Button("Floating") { launchOverlay(mode: .floating) } + .accessibilityIdentifier("uiharness.run.floating") + Button("Attached") { launchOverlay(mode: .attached) } + .accessibilityIdentifier("uiharness.run.attached") + Button("Fullscreen") { launchOverlay(mode: .fullscreen) } + .accessibilityIdentifier("uiharness.run.fullscreen") + Button("Dismiss") { TextreamService.shared.overlayController.dismiss() } + .accessibilityIdentifier("uiharness.dismiss") + } + + HStack(spacing: 8) { + Button("HUD On") { + NotchSettings.shared.persistentHUDEnabled = true + } + .accessibilityIdentifier("uiharness.hud.on") + Button("HUD Off") { + NotchSettings.shared.persistentHUDEnabled = false + } + .accessibilityIdentifier("uiharness.hud.off") + Button("Hotkeys On") { + NotchSettings.shared.listeningMode = .wordTracking + TextreamService.shared.overlayController.debugUpdateHotkeyRegistration(for: .wordTracking) + } + .accessibilityIdentifier("uiharness.hotkeys.on") + Button("Hotkeys Off") { + TextreamService.shared.overlayController.debugUpdateHotkeyRegistration(for: .classic) + } + .accessibilityIdentifier("uiharness.hotkeys.off") + } + + HStack(spacing: 8) { + Button("Aside Toggle") { + UITestRuntimeSupport.hotkeyController.simulateToggleAside() + } + .accessibilityIdentifier("uiharness.hotkeys.toggleAside") + Button("Hold On") { + UITestRuntimeSupport.hotkeyController.simulateHoldIgnore(true) + } + .accessibilityIdentifier("uiharness.hotkeys.holdOn") + Button("Hold Off") { + UITestRuntimeSupport.hotkeyController.simulateHoldIgnore(false) + } + .accessibilityIdentifier("uiharness.hotkeys.holdOff") + Button("Move Anchor") { + UITestRuntimeSupport.anchorProvider.shiftWindow(dx: 48, dy: -24) + TextreamService.shared.overlayController.debugRefreshAttachedResolution() + } + .accessibilityIdentifier("uiharness.anchor.move") + Button("Quartz") { + UITestRuntimeSupport.anchorProvider.setAccessibilityFrameAvailable(false) + TextreamService.shared.overlayController.debugRefreshAttachedResolution() + } + .accessibilityIdentifier("uiharness.anchor.quartz") + Button("AX") { + UITestRuntimeSupport.anchorProvider.setAccessibilityFrameAvailable(true) + TextreamService.shared.overlayController.debugRefreshAttachedResolution() + } + .accessibilityIdentifier("uiharness.anchor.ax") + } + + HStack(spacing: 8) { + Button("Inset") { + NotchSettings.shared.attachedMarginX = 44 + NotchSettings.shared.attachedMarginY = 30 + TextreamService.shared.overlayController.debugRefreshAttachedResolution() + } + .accessibilityIdentifier("uiharness.settings.attachedInset") + Button("Inset Reset") { + NotchSettings.shared.attachedMarginX = 16 + NotchSettings.shared.attachedMarginY = 14 + TextreamService.shared.overlayController.debugRefreshAttachedResolution() + } + .accessibilityIdentifier("uiharness.settings.attachedInsetReset") + Button("HUD Minimal") { + NotchSettings.shared.hudModules = [.trackingState] + } + .accessibilityIdentifier("uiharness.settings.hud.minimal") + Button("HUD Full") { + NotchSettings.shared.hudModules = [.trackingState, .expectedWord, .nextCue, .microphoneStatus] + } + .accessibilityIdentifier("uiharness.settings.hud.full") + } + + Divider() + + statusLine("overlay.mode", NotchSettings.shared.overlayMode.rawValue) + statusLine("overlay.status", TextreamService.shared.overlayController.overlayContent.statusLine) + statusLine("tracking.state", TextreamService.shared.overlayController.overlayContent.trackingState.rawValue) + statusLine("tracking.expected", TextreamService.shared.overlayController.overlayContent.expectedWord) + statusLine("hud.enabled", NotchSettings.shared.persistentHUDEnabled ? "on" : "off") + statusLine("hud.count", "\(hudItemCount)") + statusLine("attached.margin", "\(Int(NotchSettings.shared.attachedMarginX)),\(Int(NotchSettings.shared.attachedMarginY))") + statusLine("anchor.source", diagnostics.anchorSourceLabel) + statusLine("anchor.state", diagnostics.state.rawValue) + statusLine("anchor.frame", panelFrameLabel) + statusLine("hotkeys.running", TextreamService.shared.overlayController.debugHotkeysRunning ? "true" : "false") + } + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .padding(12) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(12) + .frame(maxWidth: 720, alignment: .leading) + } + + private var hudItemCount: Int { + PersistentHUDPresenter.items( + content: TextreamService.shared.overlayController.overlayContent, + isListening: TextreamService.shared.overlayController.speechRecognizer.isListening, + configuration: HUDPresentationConfiguration( + isEnabled: NotchSettings.shared.persistentHUDEnabled, + modules: NotchSettings.shared.hudModules + ) + ).count + } + + private var panelFrameLabel: String { + guard let frame = TextreamService.shared.overlayController.debugPanelFrame else { return "-" } + return "\(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))x\(Int(frame.height))" + } + + @ViewBuilder + private func statusLine(_ id: String, _ value: String) -> some View { + let resolvedValue = value.isEmpty ? "-" : value + Text(resolvedValue) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityLabel(resolvedValue) + .accessibilityValue(resolvedValue) + .accessibilityIdentifier("uiharness.\(id)") + } + + private func launchOverlay(mode: OverlayMode) { + let settings = NotchSettings.shared + settings.overlayMode = mode + settings.listeningMode = .classic + settings.attachedTargetWindowID = UITestRuntimeSupport.mockWindowID + settings.attachedTargetWindowLabel = "UITest Anchor" + UITestRuntimeSupport.anchorProvider.reset() + if TextreamService.shared.pages.isEmpty { + TextreamService.shared.pages = [UITestRuntimeSupport.sampleText] + } else { + TextreamService.shared.pages = [UITestRuntimeSupport.sampleText] + } + TextreamService.shared.currentPageIndex = 0 + TextreamService.shared.readCurrentPage() + } +} + +struct UITestHarnessRootView: View { + var body: some View { + UITestHarnessContainer( + content: + ZStack { + Color(nsColor: .windowBackgroundColor) + Text("Textream UI Harness") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + ) + .onAppear { + DispatchQueue.main.async { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first(where: { !($0 is NSPanel) })?.makeKeyAndOrderFront(nil) + } + } + } +} diff --git a/Textream/Textream/WindowAnchorService.swift b/Textream/Textream/WindowAnchorService.swift new file mode 100644 index 0000000..9c9d43a --- /dev/null +++ b/Textream/Textream/WindowAnchorService.swift @@ -0,0 +1,792 @@ +// +// WindowAnchorService.swift +// Textream +// +// Created by OpenAI Codex on 21.03.2026. +// + +import AppKit +import ApplicationServices +import Combine +import Foundation +import Observation + +enum WindowAnchorSource: String, Codable, CaseIterable, Identifiable { + case accessibility + case quartz + case fallback + case unavailable + + var id: String { rawValue } + + var label: String { + switch self { + case .accessibility: return "AX" + case .quartz: return "Quartz" + case .fallback: return "Fallback" + case .unavailable: return "Unavailable" + } + } +} + +struct AttachedWindowInfo: Identifiable, Hashable { + let id: Int + let ownerName: String + let title: String + let pid: pid_t + let bounds: CGRect + let layer: Int + let isOnScreen: Bool + + var displayName: String { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedTitle.isEmpty { + return ownerName + } + return "\(ownerName) — \(trimmedTitle)" + } +} + +struct WindowAnchorResolution { + let frame: CGRect? + let window: AttachedWindowInfo? + let source: WindowAnchorSource + let isAccessibilityTrusted: Bool + let message: String + + var frameLabel: String { + guard let frame else { return "-" } + return "\(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))x\(Int(frame.height))" + } + + func with( + source: WindowAnchorSource? = nil, + frame: CGRect? = nil, + message: String? = nil + ) -> WindowAnchorResolution { + WindowAnchorResolution( + frame: frame ?? self.frame, + window: window, + source: source ?? self.source, + isAccessibilityTrusted: isAccessibilityTrusted, + message: message ?? self.message + ) + } +} + +enum AccessibilityPermissionState: String, Codable { + case unknown + case notGranted + case granted + + var label: String { + switch self { + case .unknown: return "Checking" + case .notGranted: return "Not Granted" + case .granted: return "Granted" + } + } + + var detail: String { + switch self { + case .unknown: + return "Textream is still checking whether Accessibility access is available." + case .notGranted: + return "Attached Overlay can only follow app windows precisely after Accessibility access is enabled." + case .granted: + return "Accessibility access is available for precise window geometry when macOS exposes it." + } + } +} + +enum AttachedDiagnosticState: String, Codable { + case inactive + case noTargetSelected + case permissionRequired + case attachedLive + case quartzFallback + case targetUnreadable + case targetLostFallback + case hiddenFallback + + var label: String { + switch self { + case .inactive: return "Inactive" + case .noTargetSelected: return "No Target" + case .permissionRequired: return "Accessibility Off" + case .attachedLive: return "Attached" + case .quartzFallback: return "Visible Bounds Fallback" + case .targetUnreadable: return "Window Unreadable" + case .targetLostFallback: return "Window Lost" + case .hiddenFallback: return "Hidden by Fallback" + } + } +} + +private enum AttachedCopy { + static let noTargetMessage = "Choose a target window to attach to. Until then, Textream will stay in the screen corner." + static let noTargetStatusLine = "No target window selected; using screen corner" + static let noTargetDetailLine = "Fallback • No window selected" + + static let permissionRequiredMessage = "Attached Overlay needs Accessibility access before it can follow another app window. Textream will stay in the screen corner until access is granted." + static let permissionRequiredStatusLine = "Accessibility off; using screen corner" + static let permissionRequiredDetailLine = "Open System Settings to allow Attached Overlay" + + static let quartzStatusLine = "Using visible window bounds (AX fallback)" + + static let targetLostStatusLine = "Target window lost; back to screen corner" + static let targetUnreadableStatusLine = "Can't read selected window; using screen corner" + static let hiddenStatusLine = "Target window unavailable; overlay hidden" + + static func liveMessage(for target: String) -> String { + "Attached using Accessibility geometry for \(target)." + } + + static func quartzMessage(for target: String) -> String { + "Accessibility geometry is unavailable for \(target). Textream is following the visible window bounds instead." + } + + static func quartzDetailLine(for target: String) -> String { + "Quartz fallback • \(target)" + } + + static func targetLostMessage(for target: String) -> String { + "The selected window is no longer available. Textream moved back to the screen corner." + } + + static func targetUnreadableMessage(for target: String) -> String { + "Accessibility is enabled, but macOS is not exposing usable geometry for \(target). Textream is staying in the screen corner." + } + + static func fallbackDetailLine(for target: String) -> String { + "Fallback • \(target)" + } + + static func hiddenMessage(for target: String) -> String { + "The selected window is unavailable, so the overlay was hidden by the attached fallback setting." + } + + static func hiddenDetailLine(for target: String) -> String { + "Hidden fallback • \(target)" + } +} + +@Observable +final class AttachedDiagnosticsStore { + static let shared = AttachedDiagnosticsStore() + + var permissionState: AccessibilityPermissionState = .unknown + var state: AttachedDiagnosticState = .inactive + var anchorSource: WindowAnchorSource = .unavailable + var anchorSourceLabel: String = "Inactive" + var anchorMessage: String = "Attached mode inactive" + var statusLine: String = "" + var detailLine: String = "" + var frameLabel: String = "-" + var targetWindowID: Int = 0 + var targetWindowLabel: String = "" + var isDegraded: Bool = false + var shouldOfferSystemSettings: Bool = false + + private var trackedTargetWindowID: Int = 0 + private var hasResolvedGeometryForTarget = false + + private var displayTargetLabel: String { + let trimmed = targetWindowLabel.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "the selected window" : trimmed + } + + var userFacingExplanation: String { + switch state { + case .inactive: + return permissionState.detail + case .noTargetSelected: + return "Pick a target window when you're ready. Until then, Attached Overlay stays in the screen corner." + case .permissionRequired: + return "Grant Accessibility in System Settings to let Textream follow another app window precisely. Until then, Attached Overlay stays in the screen corner." + case .attachedLive: + return "Textream is locked to \(displayTargetLabel) using Accessibility geometry." + case .quartzFallback: + return "macOS did not return Accessibility geometry for \(displayTargetLabel), so Textream is following the visible window bounds instead. Positioning can be approximate." + case .targetUnreadable: + return "Accessibility is on, but macOS is not exposing a usable frame for \(displayTargetLabel). Textream is staying in the screen corner so the overlay remains visible." + case .targetLostFallback: + return "The selected window disappeared or moved out of the available window list. Textream moved back to the screen corner so you can keep reading." + case .hiddenFallback: + return "The selected window disappeared and your fallback setting hides the overlay instead of pinning it to the screen corner." + } + } + + @discardableResult + func refreshPermissionState(prompt: Bool = false) -> AccessibilityPermissionState { + let trusted = WindowAnchorService.isAccessibilityTrusted(prompt: prompt) + permissionState = trusted ? .granted : .notGranted + return permissionState + } + + func syncSelection(targetWindowID: Int, targetWindowLabel: String) { + if trackedTargetWindowID != targetWindowID { + trackedTargetWindowID = targetWindowID + hasResolvedGeometryForTarget = false + } + self.targetWindowID = targetWindowID + if !targetWindowLabel.isEmpty { + self.targetWindowLabel = targetWindowLabel + } else if targetWindowID == 0 { + self.targetWindowLabel = "" + } + } + + func markInactive(message: String, targetWindowID: Int, targetWindowLabel: String) { + syncSelection(targetWindowID: targetWindowID, targetWindowLabel: targetWindowLabel) + refreshPermissionState() + state = .inactive + anchorSource = .unavailable + anchorSourceLabel = "Inactive" + anchorMessage = message + statusLine = "" + detailLine = "" + frameLabel = "-" + isDegraded = false + shouldOfferSystemSettings = permissionState == .notGranted + } + + func beginAttachedSession(targetWindowID: Int, targetWindowLabel: String) { + syncSelection(targetWindowID: targetWindowID, targetWindowLabel: targetWindowLabel) + let permission = refreshPermissionState() + if targetWindowID == 0 { + apply( + state: .noTargetSelected, + anchorSource: .fallback, + message: AttachedCopy.noTargetMessage, + statusLine: AttachedCopy.noTargetStatusLine, + detailLine: AttachedCopy.noTargetDetailLine, + frameLabel: "-", + isDegraded: true, + shouldOfferSystemSettings: permission == .notGranted + ) + } else if permission == .notGranted { + apply( + state: .permissionRequired, + anchorSource: .fallback, + message: AttachedCopy.permissionRequiredMessage, + statusLine: AttachedCopy.permissionRequiredStatusLine, + detailLine: AttachedCopy.permissionRequiredDetailLine, + frameLabel: "-", + isDegraded: true, + shouldOfferSystemSettings: true + ) + } else { + apply( + state: .inactive, + anchorSource: .unavailable, + message: "Looking for the selected window geometry.", + statusLine: "", + detailLine: "", + frameLabel: "-", + isDegraded: false, + shouldOfferSystemSettings: false + ) + } + } + + func updateResolution( + _ resolution: WindowAnchorResolution, + targetWindowID: Int, + targetWindowLabel: String, + overlayHidden: Bool + ) { + syncSelection(targetWindowID: targetWindowID, targetWindowLabel: targetWindowLabel) + let permission = refreshPermissionState() + + anchorSource = resolution.source + anchorSourceLabel = resolution.source == .unavailable && state == .inactive ? "Inactive" : resolution.source.label + frameLabel = resolution.frameLabel + if let window = resolution.window { + self.targetWindowLabel = window.displayName + self.targetWindowID = window.id + } + + if targetWindowID == 0 { + apply( + state: .noTargetSelected, + anchorSource: resolution.source == .unavailable ? .fallback : resolution.source, + message: AttachedCopy.noTargetMessage, + statusLine: AttachedCopy.noTargetStatusLine, + detailLine: AttachedCopy.noTargetDetailLine, + frameLabel: resolution.frameLabel, + isDegraded: true, + shouldOfferSystemSettings: permission == .notGranted + ) + return + } + + if permission == .notGranted { + hasResolvedGeometryForTarget = false + apply( + state: .permissionRequired, + anchorSource: .fallback, + message: AttachedCopy.permissionRequiredMessage, + statusLine: AttachedCopy.permissionRequiredStatusLine, + detailLine: AttachedCopy.permissionRequiredDetailLine, + frameLabel: resolution.frameLabel, + isDegraded: true, + shouldOfferSystemSettings: true + ) + return + } + + let displayTarget = self.targetWindowLabel.isEmpty ? "Selected window" : self.targetWindowLabel + + switch resolution.source { + case .accessibility: + hasResolvedGeometryForTarget = true + apply( + state: .attachedLive, + anchorSource: .accessibility, + message: AttachedCopy.liveMessage(for: displayTarget), + statusLine: "", + detailLine: "", + frameLabel: resolution.frameLabel, + isDegraded: false, + shouldOfferSystemSettings: false + ) + case .quartz: + hasResolvedGeometryForTarget = true + apply( + state: .quartzFallback, + anchorSource: .quartz, + message: AttachedCopy.quartzMessage(for: displayTarget), + statusLine: AttachedCopy.quartzStatusLine, + detailLine: AttachedCopy.quartzDetailLine(for: displayTarget), + frameLabel: resolution.frameLabel, + isDegraded: true, + shouldOfferSystemSettings: false + ) + case .fallback, .unavailable: + let message: String + let statusLine: String + let detailLine: String + let nextState: AttachedDiagnosticState + + if overlayHidden { + nextState = .hiddenFallback + message = AttachedCopy.hiddenMessage(for: displayTarget) + statusLine = AttachedCopy.hiddenStatusLine + detailLine = AttachedCopy.hiddenDetailLine(for: displayTarget) + } else if hasResolvedGeometryForTarget { + nextState = .targetLostFallback + message = AttachedCopy.targetLostMessage(for: displayTarget) + statusLine = AttachedCopy.targetLostStatusLine + detailLine = AttachedCopy.fallbackDetailLine(for: displayTarget) + } else { + nextState = .targetUnreadable + message = AttachedCopy.targetUnreadableMessage(for: displayTarget) + statusLine = AttachedCopy.targetUnreadableStatusLine + detailLine = AttachedCopy.fallbackDetailLine(for: displayTarget) + } + + hasResolvedGeometryForTarget = false + apply( + state: nextState, + anchorSource: resolution.source == .unavailable ? .fallback : resolution.source, + message: message, + statusLine: statusLine, + detailLine: detailLine, + frameLabel: resolution.frameLabel, + isDegraded: true, + shouldOfferSystemSettings: false + ) + } + } + + private func apply( + state: AttachedDiagnosticState, + anchorSource: WindowAnchorSource, + message: String, + statusLine: String, + detailLine: String, + frameLabel: String, + isDegraded: Bool, + shouldOfferSystemSettings: Bool + ) { + self.state = state + self.anchorSource = anchorSource + self.anchorSourceLabel = anchorSource == .unavailable && state == .inactive ? "Inactive" : anchorSource.label + self.anchorMessage = message + self.statusLine = statusLine + self.detailLine = detailLine + self.frameLabel = frameLabel + self.isDegraded = isDegraded + self.shouldOfferSystemSettings = shouldOfferSystemSettings + } +} + +protocol WindowAnchorProviding { + func isAccessibilityTrusted(prompt: Bool) -> Bool + func openAccessibilitySettings() + func visibleWindows() -> [AttachedWindowInfo] + func accessibilityFrame(for target: AttachedWindowInfo) -> CGRect? +} + +struct LiveWindowAnchorProvider: WindowAnchorProviding { + static func desktopFrame(for screens: [NSScreen] = NSScreen.screens) -> CGRect { + screens.reduce(CGRect.null) { partial, screen in + partial.isNull ? screen.frame : partial.union(screen.frame) + } + } + + static func normalizeToAppKitCoordinates(_ rawFrame: CGRect, desktopFrame: CGRect? = nil) -> CGRect { + guard !rawFrame.isEmpty else { return rawFrame } + let desktopFrame = desktopFrame ?? Self.desktopFrame() + guard !desktopFrame.isNull, !desktopFrame.isEmpty else { return rawFrame } + + // AX and Quartz window geometry use an upper-left desktop origin, while + // AppKit panels are positioned in a lower-left desktop coordinate space. + return CGRect( + x: rawFrame.minX, + y: desktopFrame.maxY - rawFrame.maxY, + width: rawFrame.width, + height: rawFrame.height + ) + } + + func isAccessibilityTrusted(prompt: Bool) -> Bool { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: prompt] as CFDictionary + return AXIsProcessTrustedWithOptions(options) + } + + func openAccessibilitySettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } + + func visibleWindows() -> [AttachedWindowInfo] { + guard let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { + return [] + } + let desktopFrame = Self.desktopFrame() + + return list.compactMap { entry in + guard let layer = entry[kCGWindowLayer as String] as? Int, layer == 0 else { return nil } + guard let windowID = entry[kCGWindowNumber as String] as? Int else { return nil } + guard let ownerPID = entry[kCGWindowOwnerPID as String] as? pid_t else { return nil } + let ownerName = (entry[kCGWindowOwnerName as String] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown" + let title = (entry[kCGWindowName as String] as? String) ?? "" + let boundsDict = entry[kCGWindowBounds as String] as? NSDictionary + let rawBounds = boundsDict.flatMap(CGRect.init(dictionaryRepresentation:)) ?? .zero + let bounds = Self.normalizeToAppKitCoordinates(rawBounds, desktopFrame: desktopFrame) + let isOnScreen = (entry[kCGWindowIsOnscreen as String] as? Bool) ?? true + + guard !bounds.isEmpty, bounds.width > 120, bounds.height > 60 else { return nil } + guard ownerName != "Window Server" else { return nil } + + return AttachedWindowInfo( + id: windowID, + ownerName: ownerName, + title: title, + pid: ownerPID, + bounds: bounds, + layer: layer, + isOnScreen: isOnScreen + ) + } + } + + func accessibilityFrame(for target: AttachedWindowInfo) -> CGRect? { + let appElement = AXUIElementCreateApplication(target.pid) + var axWindowsValue: CFTypeRef? + let result = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &axWindowsValue) + guard result == .success, let axWindows = axWindowsValue as? [AXUIElement] else { + return nil + } + let desktopFrame = Self.desktopFrame() + + let normalizedTitle = target.title.trimmingCharacters(in: .whitespacesAndNewlines) + var bestFrame: CGRect? + var bestScore = CGFloat.greatestFiniteMagnitude + + for window in axWindows { + var titleValue: CFTypeRef? + _ = AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &titleValue) + let title = (titleValue as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + var positionValue: CFTypeRef? + var sizeValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &positionValue) == .success, + AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeValue) == .success, + CFGetTypeID(positionValue) == AXValueGetTypeID(), + CFGetTypeID(sizeValue) == AXValueGetTypeID() else { + continue + } + let positionAX = unsafeBitCast(positionValue, to: AXValue.self) + let sizeAX = unsafeBitCast(sizeValue, to: AXValue.self) + + var point = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(positionAX, .cgPoint, &point), + AXValueGetValue(sizeAX, .cgSize, &size) else { + continue + } + if size.width > 0, size.height > 0 { + let frame = Self.normalizeToAppKitCoordinates( + CGRect(origin: point, size: size), + desktopFrame: desktopFrame + ) + let titlePenalty: CGFloat + if normalizedTitle.isEmpty { + titlePenalty = 0 + } else { + titlePenalty = title == normalizedTitle ? 0 : 1200 + } + let score = titlePenalty + frameDistance(frame, target.bounds) + if score < bestScore { + bestScore = score + bestFrame = frame + } + } + } + + return bestFrame + } + + private func frameDistance(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { + let centerDelta = abs(lhs.midX - rhs.midX) + abs(lhs.midY - rhs.midY) + let sizeDelta = abs(lhs.width - rhs.width) + abs(lhs.height - rhs.height) + return centerDelta + sizeDelta + } +} + +final class WindowAnchorService { + static var sharedProvider: any WindowAnchorProviding = LiveWindowAnchorProvider() + static let trackingInterval: TimeInterval = 0.05 + + var onResolutionChanged: ((WindowAnchorResolution) -> Void)? + + private var timer: AnyCancellable? + private var lastWindowID: Int? + private let provider: any WindowAnchorProviding + + init(provider: (any WindowAnchorProviding)? = nil) { + self.provider = provider ?? Self.sharedProvider + } + + deinit { + stopTracking() + onResolutionChanged = nil + } + + static func installSharedProvider(_ provider: any WindowAnchorProviding) { + sharedProvider = provider + } + + static func resetSharedProvider() { + sharedProvider = LiveWindowAnchorProvider() + } + + static func isAccessibilityTrusted(prompt: Bool) -> Bool { + sharedProvider.isAccessibilityTrusted(prompt: prompt) + } + + static func openAccessibilitySettings() { + sharedProvider.openAccessibilitySettings() + } + + static func visibleWindows() -> [AttachedWindowInfo] { + sharedProvider.visibleWindows() + } + + func startTracking(windowID: Int) { + stopTracking() + lastWindowID = windowID + timer = Timer.publish(every: Self.trackingInterval, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.emitFrame() + } + emitFrame() + } + + func stopTracking() { + timer?.cancel() + timer = nil + lastWindowID = nil + } + + var trackedWindowID: Int? { lastWindowID } + + func currentWindow() -> AttachedWindowInfo? { + guard let lastWindowID else { return nil } + return provider.visibleWindows().first(where: { $0.id == lastWindowID }) + } + + func resolution(for windowID: Int) -> WindowAnchorResolution { + let trusted = provider.isAccessibilityTrusted(prompt: false) + let visibleWindow = provider.visibleWindows().first(where: { $0.id == windowID }) + + guard let visibleWindow else { + return WindowAnchorResolution( + frame: nil, + window: nil, + source: .unavailable, + isAccessibilityTrusted: trusted, + message: "Target window is no longer visible; using fallback placement" + ) + } + + if trusted, let axFrame = provider.accessibilityFrame(for: visibleWindow) { + return WindowAnchorResolution( + frame: axFrame, + window: visibleWindow, + source: .accessibility, + isAccessibilityTrusted: true, + message: "Using Accessibility geometry for the selected window" + ) + } + + let message = trusted + ? "Accessibility lookup failed; using Quartz window bounds" + : "Accessibility not authorized; using Quartz window bounds" + return WindowAnchorResolution( + frame: visibleWindow.bounds, + window: visibleWindow, + source: .quartz, + isAccessibilityTrusted: trusted, + message: message + ) + } + + func emitCurrentResolution() { + emitFrame() + } + + func anchoredOrigin( + targetFrame: CGRect, + overlaySize: CGSize, + corner: AttachedAnchorCorner, + marginX: CGFloat, + marginY: CGFloat, + within visibleFrame: CGRect? = nil + ) -> CGPoint { + let visibleFrame = visibleFrame ?? screen(for: targetFrame, corner: corner)?.visibleFrame + let origin: CGPoint + + switch corner { + case .topLeft: + origin = CGPoint(x: targetFrame.minX + marginX, y: targetFrame.maxY - overlaySize.height - marginY) + case .topRight: + origin = CGPoint(x: targetFrame.maxX - overlaySize.width - marginX, y: targetFrame.maxY - overlaySize.height - marginY) + case .bottomLeft: + origin = CGPoint(x: targetFrame.minX + marginX, y: targetFrame.minY + marginY) + case .bottomRight: + origin = CGPoint(x: targetFrame.maxX - overlaySize.width - marginX, y: targetFrame.minY + marginY) + } + + return clampedOverlayOrigin(origin, overlaySize: overlaySize, within: visibleFrame) + } + + func fallbackFrame( + overlaySize: CGSize, + corner: AttachedAnchorCorner, + marginX: CGFloat, + marginY: CGFloat, + on screen: NSScreen? = NSScreen.main, + within visibleFrame: CGRect? = nil + ) -> CGRect { + let activeVisibleFrame = visibleFrame + ?? screen?.visibleFrame + ?? NSScreen.main?.visibleFrame + ?? NSScreen.screens.first?.visibleFrame + ?? .zero + let origin = anchoredOrigin( + targetFrame: activeVisibleFrame, + overlaySize: overlaySize, + corner: corner, + marginX: marginX, + marginY: marginY, + within: activeVisibleFrame + ) + return CGRect(origin: origin, size: overlaySize) + } + + func screen(for targetFrame: CGRect) -> NSScreen? { + let screens = NSScreen.screens + guard !screens.isEmpty else { return nil } + + var bestScreen: NSScreen? + var bestIntersectionArea: CGFloat = 0 + + for screen in screens { + let intersection = screen.frame.intersection(targetFrame) + let area = intersection.isNull ? 0 : intersection.width * intersection.height + if area > bestIntersectionArea { + bestIntersectionArea = area + bestScreen = screen + } + } + + if let bestScreen, bestIntersectionArea > 0 { + return bestScreen + } + + let center = CGPoint(x: targetFrame.midX, y: targetFrame.midY) + return screens.min { lhs, rhs in + squaredDistance(from: center, to: lhs.visibleFrame) < squaredDistance(from: center, to: rhs.visibleFrame) + } + } + + func screen(for targetFrame: CGRect, corner: AttachedAnchorCorner) -> NSScreen? { + let anchorPoint = anchorPoint(for: targetFrame, corner: corner) + let screens = NSScreen.screens + + if let containingScreen = screens.first(where: { $0.frame.contains(anchorPoint) }) { + return containingScreen + } + + return screens.min { lhs, rhs in + squaredDistance(from: anchorPoint, to: lhs.frame) < squaredDistance(from: anchorPoint, to: rhs.frame) + } ?? screen(for: targetFrame) + } + + private func emitFrame() { + guard let lastWindowID else { return } + onResolutionChanged?(resolution(for: lastWindowID)) + } + + private func clampedOverlayOrigin(_ origin: CGPoint, overlaySize: CGSize, within visibleFrame: CGRect?) -> CGPoint { + guard let visibleFrame, !visibleFrame.isEmpty else { return origin } + + let maxX = max(visibleFrame.minX, visibleFrame.maxX - overlaySize.width) + let maxY = max(visibleFrame.minY, visibleFrame.maxY - overlaySize.height) + + return CGPoint( + x: min(max(origin.x, visibleFrame.minX), maxX), + y: min(max(origin.y, visibleFrame.minY), maxY) + ) + } + + private func squaredDistance(from point: CGPoint, to frame: CGRect) -> CGFloat { + let clampedX = min(max(point.x, frame.minX), frame.maxX) + let clampedY = min(max(point.y, frame.minY), frame.maxY) + let dx = point.x - clampedX + let dy = point.y - clampedY + return dx * dx + dy * dy + } + + private func anchorPoint(for targetFrame: CGRect, corner: AttachedAnchorCorner) -> CGPoint { + let insetX = min(1, max(0, targetFrame.width / 4)) + let insetY = min(1, max(0, targetFrame.height / 4)) + + switch corner { + case .topLeft: + return CGPoint(x: targetFrame.minX + insetX, y: targetFrame.maxY - insetY) + case .topRight: + return CGPoint(x: targetFrame.maxX - insetX, y: targetFrame.maxY - insetY) + case .bottomLeft: + return CGPoint(x: targetFrame.minX + insetX, y: targetFrame.minY + insetY) + case .bottomRight: + return CGPoint(x: targetFrame.maxX - insetX, y: targetFrame.minY + insetY) + } + } +} diff --git a/Textream/TextreamIntegrationTests/NotchOverlayControllerIntegrationTests.swift b/Textream/TextreamIntegrationTests/NotchOverlayControllerIntegrationTests.swift new file mode 100644 index 0000000..3dbd63e --- /dev/null +++ b/Textream/TextreamIntegrationTests/NotchOverlayControllerIntegrationTests.swift @@ -0,0 +1,331 @@ +import XCTest +@testable import Textream + +@MainActor +final class NotchOverlayControllerIntegrationTests: XCTestCase { + private static var retainedAnchorServices: [WindowAnchorService] = [] + + private struct SettingsSnapshot { + let overlayMode: OverlayMode + let listeningMode: ListeningMode + let notchWidth: CGFloat + let textAreaHeight: CGFloat + let attachedAnchorCorner: AttachedAnchorCorner + let attachedMarginX: Double + let attachedMarginY: Double + let attachedTargetWindowID: Int + let attachedTargetWindowLabel: String + let persistentHUDEnabled: Bool + let hudModules: [HUDModule] + let followCursorWhenUndocked: Bool + + @MainActor + init(settings: NotchSettings) { + overlayMode = settings.overlayMode + listeningMode = settings.listeningMode + notchWidth = settings.notchWidth + textAreaHeight = settings.textAreaHeight + attachedAnchorCorner = settings.attachedAnchorCorner + attachedMarginX = settings.attachedMarginX + attachedMarginY = settings.attachedMarginY + attachedTargetWindowID = settings.attachedTargetWindowID + attachedTargetWindowLabel = settings.attachedTargetWindowLabel + persistentHUDEnabled = settings.persistentHUDEnabled + hudModules = settings.hudModules + followCursorWhenUndocked = settings.followCursorWhenUndocked + } + + @MainActor + func restore(on settings: NotchSettings) { + settings.overlayMode = overlayMode + settings.listeningMode = listeningMode + settings.notchWidth = notchWidth + settings.textAreaHeight = textAreaHeight + settings.attachedAnchorCorner = attachedAnchorCorner + settings.attachedMarginX = attachedMarginX + settings.attachedMarginY = attachedMarginY + settings.attachedTargetWindowID = attachedTargetWindowID + settings.attachedTargetWindowLabel = attachedTargetWindowLabel + settings.persistentHUDEnabled = persistentHUDEnabled + settings.hudModules = hudModules + settings.followCursorWhenUndocked = followCursorWhenUndocked + } + } + + private var settings: NotchSettings! + private var snapshot: SettingsSnapshot! + private var retainedControllers: [NotchOverlayController] = [] + + override func setUp() { + super.setUp() + _ = NSApplication.shared + settings = NotchSettings.shared + snapshot = SettingsSnapshot(settings: settings) + settings.listeningMode = .classic + settings.notchWidth = 260 + settings.textAreaHeight = 120 + settings.persistentHUDEnabled = true + settings.hudModules = [.trackingState, .expectedWord, .microphoneStatus] + settings.followCursorWhenUndocked = false + UITestRuntimeSupport.anchorProvider.reset() + retainedControllers = [] + } + + override func tearDown() { + retainedControllers.forEach { $0.dismiss() } + waitForSettledPanels() + retainedControllers.removeAll() + Self.retainedAnchorServices.forEach { + $0.stopTracking() + $0.onResolutionChanged = nil + } + Self.retainedAnchorServices.removeAll() + snapshot.restore(on: settings) + WindowAnchorService.resetSharedProvider() + TrackingHotkeyController.resetShared() + super.tearDown() + } + + func testOverlayModesShowPanelAcrossPinnedFloatingAndFullscreen() { + for mode in [OverlayMode.pinned, .floating, .fullscreen] { + settings.overlayMode = mode + let controller = makeController() + controller.show(text: "overlay modes should all present") + + XCTAssertTrue(controller.isShowing, "Expected \(mode.rawValue) to create a panel") + XCTAssertNotNil(controller.debugPanelFrame) + XCTAssertGreaterThan(controller.debugPanelAlpha, 0) + + controller.dismiss() + waitForSettledPanels() + } + } + + func testManualAsideHotkeyLifecycleStartsStopsAndUpdatesSpeechState() { + let hotkeys = UITestTrackingHotkeyController() + let controller = makeController(hotkeyController: hotkeys) + + controller.debugUpdateHotkeyRegistration(for: .wordTracking) + XCTAssertTrue(hotkeys.isRunning) + XCTAssertTrue(controller.debugHotkeysRunning) + + hotkeys.simulateToggleAside() + XCTAssertEqual(controller.speechRecognizer.manualAsideMode, .toggled) + XCTAssertEqual(controller.overlayContent.trackingState, .aside) + + hotkeys.simulateToggleAside() + XCTAssertEqual(controller.speechRecognizer.manualAsideMode, .inactive) + XCTAssertEqual(controller.overlayContent.trackingState, .tracking) + + hotkeys.simulateHoldIgnore(true) + XCTAssertEqual(controller.speechRecognizer.manualAsideMode, .hold) + XCTAssertEqual(controller.overlayContent.trackingState, .aside) + + hotkeys.simulateHoldIgnore(false) + XCTAssertEqual(controller.speechRecognizer.manualAsideMode, .inactive) + XCTAssertEqual(controller.overlayContent.trackingState, .tracking) + + controller.debugUpdateHotkeyRegistration(for: .classic) + XCTAssertFalse(hotkeys.isRunning) + XCTAssertFalse(controller.debugHotkeysRunning) + } + + func testAttachedModeUpdatesPositionFromMockAnchorProvider() throws { + let provider = UITestRuntimeSupport.anchorProvider + provider.reset() + let service = WindowAnchorService(provider: provider) + Self.retainedAnchorServices.append(service) + let controller = makeController(windowAnchorService: service) + + settings.overlayMode = .attached + settings.attachedTargetWindowID = UITestRuntimeSupport.mockWindowID + settings.attachedTargetWindowLabel = "Mock Window" + settings.attachedAnchorCorner = .topRight + settings.attachedMarginX = 16 + settings.attachedMarginY = 12 + + controller.show(text: "attached mode should follow the mock window") + waitForSettledPanels() + + let firstFrame = try XCTUnwrap(controller.debugPanelFrame) + let expectedFirstOrigin = service.anchoredOrigin( + targetFrame: provider.visibleWindows().first!.bounds, + overlaySize: firstFrame.size, + corner: .topRight, + marginX: 16, + marginY: 12 + ) + XCTAssertEqual(firstFrame.origin.x, expectedFirstOrigin.x, accuracy: 6.0) + XCTAssertEqual(firstFrame.origin.y, expectedFirstOrigin.y, accuracy: 6.0) + + provider.shiftWindow(dx: 64, dy: -32) + controller.debugRefreshAttachedResolution() + waitForSettledPanels() + + let secondFrame = try XCTUnwrap(controller.debugPanelFrame) + XCTAssertNotEqual(secondFrame.origin.x, firstFrame.origin.x, accuracy: 0.5) + XCTAssertNotEqual(secondFrame.origin.y, firstFrame.origin.y, accuracy: 0.5) + + controller.dismiss() + waitForSettledPanels() + } + + func testAttachedSettingsChangeRepositionsOverlayOnNextAnchorRefresh() throws { + let provider = UITestRuntimeSupport.anchorProvider + provider.reset() + let service = WindowAnchorService(provider: provider) + Self.retainedAnchorServices.append(service) + let controller = makeController(windowAnchorService: service) + + settings.overlayMode = .attached + settings.attachedTargetWindowID = UITestRuntimeSupport.mockWindowID + settings.attachedTargetWindowLabel = "Mock Window" + settings.attachedAnchorCorner = .topLeft + settings.attachedMarginX = 10 + settings.attachedMarginY = 10 + + controller.show(text: "settings should reposition the attached overlay") + waitForSettledPanels() + let firstFrame = try XCTUnwrap(controller.debugPanelFrame) + + settings.attachedMarginX = 42 + settings.attachedMarginY = 36 + controller.debugRefreshAttachedResolution() + waitForSettledPanels() + + let secondFrame = try XCTUnwrap(controller.debugPanelFrame) + XCTAssertNotEqual(secondFrame.origin.x, firstFrame.origin.x, accuracy: 0.5) + XCTAssertNotEqual(secondFrame.origin.y, firstFrame.origin.y, accuracy: 0.5) + + controller.dismiss() + waitForSettledPanels() + } + + func testFloatingOverlayCanSwitchIntoFollowCursorDuringActiveSession() throws { + settings.overlayMode = .floating + settings.followCursorWhenUndocked = false + + let controller = makeController() + controller.show(text: "floating should switch into follow cursor live") + waitForSettledPanels() + + XCTAssertEqual(controller.debugPresentationMode, "floating") + XCTAssertFalse(controller.debugCursorTrackingRunning) + + settings.followCursorWhenUndocked = true + controller.refreshPresentationForSettingsChange() + waitForSettledPanels() + + XCTAssertEqual(controller.debugPresentationMode, "floatingFollowCursor") + XCTAssertTrue(controller.debugCursorTrackingRunning) + + settings.followCursorWhenUndocked = false + controller.refreshPresentationForSettingsChange() + waitForSettledPanels() + + XCTAssertEqual(controller.debugPresentationMode, "floating") + XCTAssertFalse(controller.debugCursorTrackingRunning) + XCTAssertNotNil(try XCTUnwrap(controller.debugPanelFrame)) + + controller.dismiss() + waitForSettledPanels() + } + + func testAttachedOverlayResizesAndReanchorsDuringActiveSession() throws { + let provider = UITestRuntimeSupport.anchorProvider + provider.reset() + let service = WindowAnchorService(provider: provider) + Self.retainedAnchorServices.append(service) + let controller = makeController(windowAnchorService: service) + + settings.overlayMode = .attached + settings.attachedTargetWindowID = UITestRuntimeSupport.mockWindowID + settings.attachedTargetWindowLabel = "Mock Window" + settings.attachedAnchorCorner = .topRight + settings.attachedMarginX = 16 + settings.attachedMarginY = 12 + settings.notchWidth = 260 + settings.textAreaHeight = 120 + + controller.show(text: "attached should resize live") + waitForSettledPanels() + + let firstFrame = try XCTUnwrap(controller.debugPanelFrame) + + settings.notchWidth = 360 + settings.textAreaHeight = 180 + settings.attachedMarginX = 28 + settings.attachedMarginY = 20 + controller.refreshPresentationForSettingsChange() + waitForSettledPanels() + + let secondFrame = try XCTUnwrap(controller.debugPanelFrame) + XCTAssertNotEqual(secondFrame.width, firstFrame.width, accuracy: 0.5) + XCTAssertNotEqual(secondFrame.height, firstFrame.height, accuracy: 0.5) + XCTAssertNotEqual(secondFrame.origin.x, firstFrame.origin.x, accuracy: 0.5) + XCTAssertNotEqual(secondFrame.origin.y, firstFrame.origin.y, accuracy: 0.5) + + controller.dismiss() + waitForSettledPanels() + } + + func testAttachedOverlayDirectResizePersistsBackIntoSettings() throws { + let provider = UITestRuntimeSupport.anchorProvider + provider.reset() + let service = WindowAnchorService(provider: provider) + Self.retainedAnchorServices.append(service) + let controller = makeController(windowAnchorService: service) + + settings.overlayMode = .attached + settings.attachedTargetWindowID = UITestRuntimeSupport.mockWindowID + settings.attachedTargetWindowLabel = "Mock Window" + settings.attachedAnchorCorner = .topRight + settings.attachedMarginX = 16 + settings.attachedMarginY = 12 + settings.notchWidth = 260 + settings.textAreaHeight = 120 + settings.followCursorWhenUndocked = true + + controller.show(text: "attached should persist direct resize") + waitForSettledPanels() + + controller.debugSimulateUserResize(to: CGSize(width: 340, height: 180)) + waitForSettledPanels() + + XCTAssertEqual(settings.notchWidth, 340, accuracy: 0.5) + XCTAssertEqual(settings.textAreaHeight, 180, accuracy: 0.5) + + let frame = try XCTUnwrap(controller.debugPanelFrame) + let targetFrame = try XCTUnwrap(provider.visibleWindows().first?.bounds) + let expectedOrigin = service.anchoredOrigin( + targetFrame: targetFrame, + overlaySize: frame.size, + corner: .topRight, + marginX: 16, + marginY: 12 + ) + XCTAssertEqual(frame.origin.x, expectedOrigin.x, accuracy: 6.0) + XCTAssertEqual(frame.origin.y, expectedOrigin.y, accuracy: 6.0) + + controller.dismiss() + waitForSettledPanels() + } + + private func makeController( + windowAnchorService: WindowAnchorService? = nil, + hotkeyController: (any TrackingHotkeyControlling)? = nil + ) -> NotchOverlayController { + let controller = NotchOverlayController( + windowAnchorService: windowAnchorService ?? WindowAnchorService(), + hotkeyController: hotkeyController ?? UITestTrackingHotkeyController(), + attachedDiagnostics: AttachedDiagnosticsStore(), + disablePermissionOnboarding: true + ) + retainedControllers.append(controller) + return controller + } + + private func waitForSettledPanels() { + RunLoop.main.run(until: Date().addingTimeInterval(0.7)) + } +} diff --git a/Textream/TextreamIntegrationTests/PerformanceBaselineTests.swift b/Textream/TextreamIntegrationTests/PerformanceBaselineTests.swift new file mode 100644 index 0000000..4a58b62 --- /dev/null +++ b/Textream/TextreamIntegrationTests/PerformanceBaselineTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import Textream + +@MainActor +final class PerformanceBaselineTests: XCTestCase { + private static let retainedAnchorService = WindowAnchorService(provider: UITestRuntimeSupport.anchorProvider) + + func testTrackingDecisionLatencyBaseline() { + var guarder = TrackingGuard(text: "one two three four five six seven eight nine ten eleven twelve") + let frame = SpeechRecognitionFrame( + partialText: "one two three four five", + segments: [ + SpeechSegmentSnapshot(text: "one", confidence: 0.93, timestamp: 0.0, duration: 0.08), + SpeechSegmentSnapshot(text: "two", confidence: 0.94, timestamp: 0.1, duration: 0.08), + SpeechSegmentSnapshot(text: "three", confidence: 0.95, timestamp: 0.2, duration: 0.08), + SpeechSegmentSnapshot(text: "four", confidence: 0.94, timestamp: 0.3, duration: 0.08), + SpeechSegmentSnapshot(text: "five", confidence: 0.93, timestamp: 0.4, duration: 0.08), + ], + isFinal: false, + createdAt: Date() + ) + + measure(metrics: [XCTClockMetric()]) { + guarder.reset(with: "one two three four five six seven eight nine ten eleven twelve") + _ = guarder.process( + frame: frame, + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 1.0, + useLegacyFallback: false + ) + } + } + + func testOverlayStatePropagationLatencyBaseline() { + let snapshot = TrackingSnapshot( + highlightedCharCount: 24, + trackingState: .tracking, + expectedWord: "overlay", + nextCue: "state propagation stays fast", + confidenceLevel: .high, + manualAsideMode: .inactive, + statusLine: "Tracking: overlay", + confidenceScore: 4.1, + decisionReason: .advanced, + debugSummary: "Performance baseline" + ) + let frame = SpeechRecognitionFrame( + partialText: "overlay state propagation", + segments: [ + SpeechSegmentSnapshot(text: "overlay", confidence: 0.9, timestamp: 0, duration: 0.1), + ], + isFinal: false, + createdAt: Date() + ) + + measure(metrics: [XCTClockMetric()]) { + _ = OverlayStateProjector.projected(snapshot: snapshot, frame: frame) + } + } + + func testWindowAnchorUpdateFrequencyCPUCostBaseline() { + let provider = UITestRuntimeSupport.anchorProvider + provider.reset() + let service = Self.retainedAnchorService + let overlaySize = CGSize(width: 260, height: 120) + + measure(metrics: [XCTClockMetric(), XCTCPUMetric()]) { + for _ in 0..<120 { + provider.shiftWindow(dx: 4, dy: -2) + let resolution = service.resolution(for: UITestRuntimeSupport.mockWindowID) + guard let frame = resolution.frame else { + XCTFail("Expected mock anchor frame during performance baseline") + return + } + _ = service.anchoredOrigin( + targetFrame: frame, + overlaySize: overlaySize, + corner: .topRight, + marginX: 16, + marginY: 12 + ) + } + } + } +} diff --git a/Textream/TextreamIntegrationTests/PersistentHUDIntegrationTests.swift b/Textream/TextreamIntegrationTests/PersistentHUDIntegrationTests.swift new file mode 100644 index 0000000..7f30105 --- /dev/null +++ b/Textream/TextreamIntegrationTests/PersistentHUDIntegrationTests.swift @@ -0,0 +1,201 @@ +import XCTest +@testable import Textream + +@MainActor +final class PersistentHUDIntegrationTests: XCTestCase { + func testHUDPresenterFollowsVisibilityAndModuleChanges() { + let input = HUDPresentationInput( + trackingState: .tracking, + expectedWord: "teleprompter", + nextCue: "keeps moving smoothly" + ) + + let disabled = PersistentHUDPresenter.items( + input: input, + isListening: true, + configuration: HUDPresentationConfiguration( + isEnabled: false, + modules: [.trackingState, .expectedWord] + ) + ) + XCTAssertTrue(disabled.isEmpty) + + let enabled = PersistentHUDPresenter.items( + input: input, + isListening: true, + configuration: HUDPresentationConfiguration( + isEnabled: true, + modules: [.trackingState, .expectedWord, .microphoneStatus] + ) + ) + + XCTAssertEqual(enabled.map(\.text), ["Tracking", "Now: teleprompter", "Mic On"]) + } + + func testHUDPresenterSurfacesAttachedFallbackStateAheadOfRegularModules() { + let input = HUDPresentationInput( + trackingState: .lost, + expectedWord: "again", + attachedRequiresAttention: true, + attachedDiagnosticState: .targetLostFallback, + attachedStatusLine: "Target window lost; using screen corner" + ) + + let items = PersistentHUDPresenter.items( + input: input, + isListening: false, + configuration: HUDPresentationConfiguration( + isEnabled: true, + modules: [.trackingState, .expectedWord] + ) + ) + + XCTAssertEqual(items.first?.text, "Target window lost; using screen corner") + XCTAssertEqual(items.dropFirst().map(\.text), ["Off Script", "Now: again"]) + } +} + +@MainActor +final class RemoteStateCompatibilityIntegrationTests: XCTestCase { + func testBrowserStateDecodesLegacyPayloadWithDefaultedTrackingFields() throws { + let payload = """ + { + "words": ["hello", "world"], + "highlightedCharCount": 7, + "totalCharCount": 11, + "audioLevels": [0.2, 0.3], + "isListening": true, + "isDone": false, + "fontColor": "#ffffff", + "cueColor": "#00ffcc", + "hasNextPage": true, + "isActive": true, + "highlightWords": true, + "lastSpokenText": "hello" + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(BrowserState.self, from: payload) + + XCTAssertEqual(decoded.words, ["hello", "world"]) + XCTAssertEqual(decoded.highlightedCharCount, 7) + XCTAssertEqual(decoded.trackingState, TrackingState.tracking.rawValue) + XCTAssertEqual(decoded.confidenceLevel, TrackingConfidence.low.rawValue) + XCTAssertEqual(decoded.expectedWord, "") + XCTAssertEqual(decoded.nextCue, "") + XCTAssertFalse(decoded.manualAsideActive) + } + + func testDirectorStateDecodesLegacyPayloadWithDefaultedTrackingFields() throws { + let payload = """ + { + "words": ["director"], + "highlightedCharCount": 4, + "totalCharCount": 8, + "isActive": true, + "isDone": false, + "isListening": true, + "fontColor": "#ffffff", + "cueColor": "#f5f5f5", + "lastSpokenText": "dire", + "audioLevels": [0.5] + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(DirectorState.self, from: payload) + + XCTAssertEqual(decoded.words, ["director"]) + XCTAssertEqual(decoded.highlightedCharCount, 4) + XCTAssertEqual(decoded.trackingState, TrackingState.tracking.rawValue) + XCTAssertEqual(decoded.confidenceLevel, TrackingConfidence.low.rawValue) + XCTAssertEqual(decoded.expectedWord, "") + XCTAssertEqual(decoded.nextCue, "") + XCTAssertFalse(decoded.manualAsideActive) + } + + func testLegacyBrowserClientCanDecodeCurrentServerPayload() throws { + let payload = try JSONEncoder().encode( + BrowserState( + words: ["legacy", "client"], + highlightedCharCount: 6, + totalCharCount: 13, + audioLevels: [0.1, 0.6], + isListening: true, + isDone: false, + fontColor: "#ffffff", + cueColor: "#00ffcc", + hasNextPage: false, + isActive: true, + highlightWords: true, + lastSpokenText: "legacy", + trackingState: TrackingState.uncertain.rawValue, + confidenceLevel: TrackingConfidence.medium.rawValue, + expectedWord: "client", + nextCue: "keeps old clients stable", + manualAsideActive: true + ) + ) + + let decoded = try JSONDecoder().decode(LegacyBrowserState.self, from: payload) + + XCTAssertEqual(decoded.words, ["legacy", "client"]) + XCTAssertEqual(decoded.highlightedCharCount, 6) + XCTAssertEqual(decoded.lastSpokenText, "legacy") + } + + func testLegacyDirectorClientCanDecodeCurrentServerPayload() throws { + let payload = try JSONEncoder().encode( + DirectorState( + words: ["legacy", "director"], + highlightedCharCount: 5, + totalCharCount: 15, + isActive: true, + isDone: false, + isListening: true, + fontColor: "#ffffff", + cueColor: "#00ffcc", + lastSpokenText: "legacy", + audioLevels: [0.2], + trackingState: TrackingState.tracking.rawValue, + confidenceLevel: TrackingConfidence.high.rawValue, + expectedWord: "director", + nextCue: "remains compatible", + manualAsideActive: false + ) + ) + + let decoded = try JSONDecoder().decode(LegacyDirectorState.self, from: payload) + + XCTAssertEqual(decoded.words, ["legacy", "director"]) + XCTAssertEqual(decoded.highlightedCharCount, 5) + XCTAssertEqual(decoded.lastSpokenText, "legacy") + } +} + +private struct LegacyBrowserState: Decodable { + let words: [String] + let highlightedCharCount: Int + let totalCharCount: Int + let audioLevels: [Double] + let isListening: Bool + let isDone: Bool + let fontColor: String + let cueColor: String + let hasNextPage: Bool + let isActive: Bool + let highlightWords: Bool + let lastSpokenText: String +} + +private struct LegacyDirectorState: Decodable { + let words: [String] + let highlightedCharCount: Int + let totalCharCount: Int + let isActive: Bool + let isDone: Bool + let isListening: Bool + let fontColor: String + let cueColor: String + let lastSpokenText: String + let audioLevels: [Double] +} diff --git a/Textream/TextreamTests/AttachedOverlayTypes.swift b/Textream/TextreamTests/AttachedOverlayTypes.swift new file mode 120000 index 0000000..8fbe247 --- /dev/null +++ b/Textream/TextreamTests/AttachedOverlayTypes.swift @@ -0,0 +1 @@ +../Textream/AttachedOverlayTypes.swift \ No newline at end of file diff --git a/Textream/TextreamTests/RemoteStateCompatibilityTests.swift b/Textream/TextreamTests/RemoteStateCompatibilityTests.swift new file mode 100644 index 0000000..6a4dd66 --- /dev/null +++ b/Textream/TextreamTests/RemoteStateCompatibilityTests.swift @@ -0,0 +1,90 @@ +import XCTest + +private struct LegacyBrowserState: Decodable { + let words: [String] + let highlightedCharCount: Int + let totalCharCount: Int + let audioLevels: [Double] + let isListening: Bool + let isDone: Bool + let fontColor: String + let cueColor: String + let hasNextPage: Bool + let isActive: Bool + let highlightWords: Bool + let lastSpokenText: String +} + +private struct LegacyDirectorState: Decodable { + let words: [String] + let highlightedCharCount: Int + let totalCharCount: Int + let isActive: Bool + let isDone: Bool + let isListening: Bool + let fontColor: String + let cueColor: String + let lastSpokenText: String + let audioLevels: [Double] +} + +@MainActor +final class RemoteStateCompatibilityTests: XCTestCase { + func testLegacyBrowserClientCanDecodeStateWithExtraFields() throws { + let state = BrowserState( + words: ["hello", "world"], + highlightedCharCount: 5, + totalCharCount: 11, + audioLevels: [0.1, 0.2], + isListening: true, + isDone: false, + fontColor: "#ffffff", + cueColor: "#cccccc", + hasNextPage: true, + isActive: true, + highlightWords: true, + lastSpokenText: "hello", + trackingState: "tracking", + confidenceLevel: "medium", + expectedWord: "world", + nextCue: "again later", + manualAsideActive: false + ) + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(LegacyBrowserState.self, from: data) + + XCTAssertEqual(decoded.words, state.words) + XCTAssertEqual(decoded.highlightedCharCount, state.highlightedCharCount) + XCTAssertEqual(decoded.totalCharCount, state.totalCharCount) + XCTAssertEqual(decoded.lastSpokenText, state.lastSpokenText) + } + + func testLegacyDirectorClientCanDecodeStateWithExtraFields() throws { + let state = DirectorState( + words: ["hello", "world"], + highlightedCharCount: 5, + totalCharCount: 11, + isActive: true, + isDone: false, + isListening: true, + fontColor: "#ffffff", + cueColor: "#cccccc", + lastSpokenText: "hello", + audioLevels: [0.1, 0.2], + trackingState: "tracking", + confidenceLevel: "medium", + expectedWord: "world", + nextCue: "again later", + manualAsideActive: false + ) + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(LegacyDirectorState.self, from: data) + + XCTAssertEqual(decoded.words, state.words) + XCTAssertEqual(decoded.highlightedCharCount, state.highlightedCharCount) + XCTAssertEqual(decoded.totalCharCount, state.totalCharCount) + XCTAssertEqual(decoded.lastSpokenText, state.lastSpokenText) + } +} diff --git a/Textream/TextreamTests/RemoteStateTypes.swift b/Textream/TextreamTests/RemoteStateTypes.swift new file mode 120000 index 0000000..6b96c23 --- /dev/null +++ b/Textream/TextreamTests/RemoteStateTypes.swift @@ -0,0 +1 @@ +../Textream/RemoteStateTypes.swift \ No newline at end of file diff --git a/Textream/TextreamTests/TextSegmentation.swift b/Textream/TextreamTests/TextSegmentation.swift new file mode 120000 index 0000000..3bdb0aa --- /dev/null +++ b/Textream/TextreamTests/TextSegmentation.swift @@ -0,0 +1 @@ +../Textream/TextSegmentation.swift \ No newline at end of file diff --git a/Textream/TextreamTests/TrackingGuard.swift b/Textream/TextreamTests/TrackingGuard.swift new file mode 120000 index 0000000..b46cee3 --- /dev/null +++ b/Textream/TextreamTests/TrackingGuard.swift @@ -0,0 +1 @@ +../Textream/TrackingGuard.swift \ No newline at end of file diff --git a/Textream/TextreamTests/TrackingGuardLegacyFallbackTests.swift b/Textream/TextreamTests/TrackingGuardLegacyFallbackTests.swift new file mode 100644 index 0000000..f8a9933 --- /dev/null +++ b/Textream/TextreamTests/TrackingGuardLegacyFallbackTests.swift @@ -0,0 +1,104 @@ +import XCTest + +final class TrackingGuardLegacyFallbackTests: XCTestCase { + func testLegacyFallbackAdvancesWhenStrictTrackingDisabled() { + var guarder = TrackingGuard(text: "legacy fallback should advance here") + + let snapshot = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "legacy fallback", + segments: [ + SpeechSegmentSnapshot(text: "legacy", confidence: 0.8, timestamp: 0, duration: 0.1), + ], + isFinal: false, + createdAt: Date() + ), + isSpeaking: true, + strictTrackingEnabled: false, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 1.0, + useLegacyFallback: true + ) { _, startOffset in + startOffset + 12 + } + + XCTAssertEqual(snapshot.trackingState, .tracking) + XCTAssertEqual(snapshot.decisionReason, .legacyFallbackAdvance) + XCTAssertEqual(snapshot.highlightedCharCount, 12) + } + + func testLegacyFallbackFreezesWhenHeardSpeechButCouldNotAdvance() { + var guarder = TrackingGuard(text: "legacy fallback should freeze") + let firstDate = Date() + + let first = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "side tangent", + segments: [ + SpeechSegmentSnapshot(text: "side", confidence: 0.7, timestamp: 0, duration: 0.1), + ], + isFinal: false, + createdAt: firstDate + ), + isSpeaking: true, + strictTrackingEnabled: false, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 0.8, + useLegacyFallback: true + ) { _, startOffset in + startOffset + } + + let second = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "still tangent", + segments: [ + SpeechSegmentSnapshot(text: "still", confidence: 0.7, timestamp: 0.9, duration: 0.1), + ], + isFinal: false, + createdAt: firstDate.addingTimeInterval(1.0) + ), + isSpeaking: true, + strictTrackingEnabled: false, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 0.8, + useLegacyFallback: true + ) { _, startOffset in + startOffset + } + + XCTAssertEqual(first.trackingState, .uncertain) + XCTAssertEqual(first.decisionReason, .legacyFallbackNoAdvance) + XCTAssertEqual(second.trackingState, .lost) + XCTAssertEqual(second.highlightedCharCount, 0) + } + + func testStrictTrackingDoesNotInvokeLegacyFallbackClosure() { + var guarder = TrackingGuard(text: "strict tracking should ignore legacy fallback") + var didInvokeLegacy = false + + let snapshot = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "", + segments: [], + isFinal: false, + createdAt: Date() + ), + isSpeaking: false, + strictTrackingEnabled: true, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 1.0, + useLegacyFallback: true + ) { _, startOffset in + didInvokeLegacy = true + return startOffset + 20 + } + + XCTAssertFalse(didInvokeLegacy) + XCTAssertEqual(snapshot.highlightedCharCount, 0) + } +} diff --git a/Textream/TextreamTests/TrackingGuardTests.swift b/Textream/TextreamTests/TrackingGuardTests.swift new file mode 100644 index 0000000..cf3afb3 --- /dev/null +++ b/Textream/TextreamTests/TrackingGuardTests.swift @@ -0,0 +1,293 @@ +import XCTest + +final class TrackingGuardTests: XCTestCase { + func testAdvancesOnConfidentAlignedSpeech() { + var guarder = TrackingGuard(text: "hello brave new world again") + + let frame = SpeechRecognitionFrame( + partialText: "hello brave new", + segments: [ + SpeechSegmentSnapshot(text: "hello", confidence: 0.92, timestamp: 0, duration: 0.1), + SpeechSegmentSnapshot(text: "brave", confidence: 0.9, timestamp: 0.2, duration: 0.1), + SpeechSegmentSnapshot(text: "new", confidence: 0.91, timestamp: 0.4, duration: 0.1), + ], + isFinal: false, + createdAt: Date() + ) + + let snapshot = guarder.process( + frame: frame, + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 1.0, + useLegacyFallback: false + ) + + XCTAssertEqual(snapshot.trackingState, .tracking) + XCTAssertGreaterThan(snapshot.highlightedCharCount, 0) + XCTAssertEqual(snapshot.expectedWord, "world") + } + + func testOffScriptSpeechFreezesAndTurnsLost() { + var guarder = TrackingGuard(text: "read only the script words") + let now = Date() + + let first = SpeechRecognitionFrame( + partialText: "totally different tangent", + segments: [ + SpeechSegmentSnapshot(text: "totally", confidence: 0.75, timestamp: 0, duration: 0.1), + SpeechSegmentSnapshot(text: "different", confidence: 0.74, timestamp: 0.2, duration: 0.1), + SpeechSegmentSnapshot(text: "tangent", confidence: 0.73, timestamp: 0.4, duration: 0.1), + ], + isFinal: false, + createdAt: now + ) + + let second = SpeechRecognitionFrame( + partialText: "still off script", + segments: [ + SpeechSegmentSnapshot(text: "still", confidence: 0.74, timestamp: 0.8, duration: 0.1), + SpeechSegmentSnapshot(text: "off", confidence: 0.72, timestamp: 1.0, duration: 0.1), + SpeechSegmentSnapshot(text: "script", confidence: 0.71, timestamp: 1.2, duration: 0.1), + ], + isFinal: false, + createdAt: now.addingTimeInterval(1.3) + ) + + let firstSnapshot = guarder.process( + frame: first, + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 1.0, + useLegacyFallback: false + ) + let secondSnapshot = guarder.process( + frame: second, + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 1.0, + useLegacyFallback: false + ) + + XCTAssertEqual(firstSnapshot.trackingState, .uncertain) + XCTAssertEqual(secondSnapshot.trackingState, .lost) + XCTAssertEqual(secondSnapshot.highlightedCharCount, 0) + } + + func testManualAsideFreezesUntilReleased() { + var guarder = TrackingGuard(text: "this should not advance while aside") + _ = guarder.setManualAsideMode(.hold) + + let snapshot = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "this should", + segments: [ + SpeechSegmentSnapshot(text: "this", confidence: 0.95, timestamp: 0, duration: 0.1), + SpeechSegmentSnapshot(text: "should", confidence: 0.95, timestamp: 0.2, duration: 0.1), + ], + isFinal: false, + createdAt: Date() + ), + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 1.0, + useLegacyFallback: false + ) + + XCTAssertEqual(snapshot.trackingState, .aside) + XCTAssertEqual(snapshot.highlightedCharCount, 0) + } + + func testRecoverRequiresTwoFramesAfterLost() { + var guarder = TrackingGuard(text: "back on script after tangent") + let base = Date() + + _ = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "unrelated tangent", + segments: [ + SpeechSegmentSnapshot(text: "unrelated", confidence: 0.7, timestamp: 0, duration: 0.1), + SpeechSegmentSnapshot(text: "tangent", confidence: 0.7, timestamp: 0.2, duration: 0.1), + ], + isFinal: false, + createdAt: base + ), + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 0.8, + useLegacyFallback: false + ) + + let lost = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "still tangent", + segments: [ + SpeechSegmentSnapshot(text: "still", confidence: 0.71, timestamp: 0.6, duration: 0.1), + SpeechSegmentSnapshot(text: "tangent", confidence: 0.71, timestamp: 0.8, duration: 0.1), + ], + isFinal: false, + createdAt: base.addingTimeInterval(1.0) + ), + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 0.8, + useLegacyFallback: false + ) + + let recovery1 = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "back on script", + segments: [ + SpeechSegmentSnapshot(text: "back", confidence: 0.95, timestamp: 1.2, duration: 0.1), + SpeechSegmentSnapshot(text: "on", confidence: 0.95, timestamp: 1.4, duration: 0.1), + SpeechSegmentSnapshot(text: "script", confidence: 0.95, timestamp: 1.6, duration: 0.1), + ], + isFinal: false, + createdAt: base.addingTimeInterval(1.4) + ), + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 0.8, + useLegacyFallback: false + ) + + let recovery2 = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "back on script after", + segments: [ + SpeechSegmentSnapshot(text: "back", confidence: 0.95, timestamp: 1.8, duration: 0.1), + SpeechSegmentSnapshot(text: "on", confidence: 0.95, timestamp: 2.0, duration: 0.1), + SpeechSegmentSnapshot(text: "script", confidence: 0.95, timestamp: 2.2, duration: 0.1), + SpeechSegmentSnapshot(text: "after", confidence: 0.95, timestamp: 2.4, duration: 0.1), + ], + isFinal: false, + createdAt: base.addingTimeInterval(1.8) + ), + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 3.0, + windowSize: 8, + offScriptFreezeDelay: 0.8, + useLegacyFallback: false + ) + + XCTAssertEqual(lost.trackingState, .lost) + XCTAssertEqual(recovery1.trackingState, .uncertain) + XCTAssertEqual(recovery2.trackingState, .tracking) + XCTAssertGreaterThan(recovery2.highlightedCharCount, 0) + } + + func testCJKInputCanAdvanceOneCharacterAtATime() { + var guarder = TrackingGuard(text: "你 好 世界") + + let snapshot = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "你 好", + segments: [ + SpeechSegmentSnapshot(text: "你", confidence: 0.95, timestamp: 0, duration: 0.1), + SpeechSegmentSnapshot(text: "好", confidence: 0.95, timestamp: 0.2, duration: 0.1), + ], + isFinal: false, + createdAt: Date() + ), + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 2.2, + windowSize: 8, + offScriptFreezeDelay: 1.0, + useLegacyFallback: false + ) + + XCTAssertEqual(snapshot.trackingState, .tracking) + XCTAssertEqual(snapshot.expectedWord, "世") + } + + func testBracketCueAutoSkipsInStrictMatching() { + var guarder = TrackingGuard(text: "hello [wave] there") + let base = Date() + + let first = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "hello", + segments: [ + SpeechSegmentSnapshot(text: "hello", confidence: 0.95, timestamp: 0, duration: 0.1), + ], + isFinal: false, + createdAt: base + ), + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 2.0, + windowSize: 8, + offScriptFreezeDelay: 1.0, + useLegacyFallback: false + ) + + XCTAssertEqual(first.expectedWord, "there") + + let second = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "there", + segments: [ + SpeechSegmentSnapshot(text: "there", confidence: 0.95, timestamp: 0.2, duration: 0.1), + ], + isFinal: false, + createdAt: base.addingTimeInterval(0.4) + ), + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 2.0, + windowSize: 8, + offScriptFreezeDelay: 1.0, + useLegacyFallback: false + ) + + XCTAssertEqual(second.trackingState, .tracking) + XCTAssertEqual(second.expectedWord, "") + } + + func testTrailingBracketCueDoesNotBlockDoneProgress() { + var guarder = TrackingGuard(text: "hello [wave]") + + let snapshot = guarder.process( + frame: SpeechRecognitionFrame( + partialText: "hello", + segments: [ + SpeechSegmentSnapshot(text: "hello", confidence: 0.96, timestamp: 0, duration: 0.1), + ], + isFinal: false, + createdAt: Date() + ), + isSpeaking: true, + strictTrackingEnabled: true, + advanceThreshold: 2.0, + windowSize: 8, + offScriptFreezeDelay: 1.0, + useLegacyFallback: false + ) + + XCTAssertEqual(snapshot.highlightedCharCount, "hello [wave]".count) + XCTAssertEqual(snapshot.expectedWord, "") + } + + func testBracketCueHelpersStayStyledButAutoSkip() { + XCTAssertTrue(isStyledAnnotationWord("[wave]")) + XCTAssertFalse(wordParticipatesInTracking("[wave]")) + XCTAssertTrue(shouldAutoSkipForTracking("[wave]")) + XCTAssertTrue(shouldAutoSkipForTracking("👏")) + } +} diff --git a/Textream/TextreamTests/WindowAnchorService.swift b/Textream/TextreamTests/WindowAnchorService.swift new file mode 120000 index 0000000..7323784 --- /dev/null +++ b/Textream/TextreamTests/WindowAnchorService.swift @@ -0,0 +1 @@ +../Textream/WindowAnchorService.swift \ No newline at end of file diff --git a/Textream/TextreamTests/WindowAnchorServiceTests.swift b/Textream/TextreamTests/WindowAnchorServiceTests.swift new file mode 100644 index 0000000..9edb06f --- /dev/null +++ b/Textream/TextreamTests/WindowAnchorServiceTests.swift @@ -0,0 +1,191 @@ +import XCTest +@testable import Textream + +private struct MockWindowAnchorProvider: WindowAnchorProviding { + var accessibilityTrusted: Bool + var windows: [AttachedWindowInfo] + var accessibilityFrames: [Int: CGRect] + + func isAccessibilityTrusted(prompt _: Bool) -> Bool { + accessibilityTrusted + } + + func openAccessibilitySettings() {} + + func visibleWindows() -> [AttachedWindowInfo] { + windows + } + + func accessibilityFrame(for target: AttachedWindowInfo) -> CGRect? { + accessibilityFrames[target.id] + } +} + +final class WindowAnchorServiceTests: XCTestCase { + func testResolutionUsesAccessibilityGeometryWhenAvailable() { + let visibleBounds = CGRect(x: 240, y: 320, width: 640, height: 420) + let accessibilityBounds = CGRect(x: 248, y: 328, width: 632, height: 408) + let window = AttachedWindowInfo( + id: 7, + ownerName: "Preview", + title: "Presenter Notes", + pid: 42, + bounds: visibleBounds, + layer: 0, + isOnScreen: true + ) + let service = WindowAnchorService( + provider: MockWindowAnchorProvider( + accessibilityTrusted: true, + windows: [window], + accessibilityFrames: [window.id: accessibilityBounds] + ) + ) + + let resolution = service.resolution(for: window.id) + + XCTAssertEqual(resolution.source, .accessibility) + XCTAssertEqual(resolution.window?.id, window.id) + XCTAssertEqual(resolution.frame, accessibilityBounds) + XCTAssertTrue(resolution.isAccessibilityTrusted) + } + + func testResolutionFallsBackToQuartzWhenAccessibilityFrameIsMissing() { + let visibleBounds = CGRect(x: 240, y: 320, width: 640, height: 420) + let window = AttachedWindowInfo( + id: 8, + ownerName: "Preview", + title: "Presenter Notes", + pid: 42, + bounds: visibleBounds, + layer: 0, + isOnScreen: true + ) + let service = WindowAnchorService( + provider: MockWindowAnchorProvider( + accessibilityTrusted: true, + windows: [window], + accessibilityFrames: [:] + ) + ) + + let resolution = service.resolution(for: window.id) + + XCTAssertEqual(resolution.source, .quartz) + XCTAssertEqual(resolution.window?.id, window.id) + XCTAssertEqual(resolution.frame, visibleBounds) + XCTAssertTrue(resolution.isAccessibilityTrusted) + } + + func testAnchoredOriginRespectsTopRightCorner() { + let service = WindowAnchorService() + let origin = service.anchoredOrigin( + targetFrame: CGRect(x: 100, y: 200, width: 500, height: 300), + overlaySize: CGSize(width: 200, height: 100), + corner: .topRight, + marginX: 16, + marginY: 12 + ) + + XCTAssertEqual(origin.x, 384, accuracy: 0.001) + XCTAssertEqual(origin.y, 388, accuracy: 0.001) + } + + func testFallbackFrameKeepsRequestedSize() { + let service = WindowAnchorService() + let frame = service.fallbackFrame( + overlaySize: CGSize(width: 320, height: 180), + corner: .bottomLeft, + marginX: 20, + marginY: 24, + on: nil + ) + + XCTAssertEqual(frame.width, 320, accuracy: 0.001) + XCTAssertEqual(frame.height, 180, accuracy: 0.001) + } + + func testAnchoredOriginClampsTopRightInsideVisibleFrame() { + let service = WindowAnchorService() + let origin = service.anchoredOrigin( + targetFrame: CGRect(x: 900, y: 520, width: 280, height: 260), + overlaySize: CGSize(width: 200, height: 100), + corner: .topRight, + marginX: 16, + marginY: 12, + within: CGRect(x: 0, y: 24, width: 1000, height: 676) + ) + + XCTAssertEqual(origin.x, 800, accuracy: 0.001) + XCTAssertEqual(origin.y, 600, accuracy: 0.001) + } + + func testAnchoredOriginClampsBottomLeftInsideVisibleFrame() { + let service = WindowAnchorService() + let origin = service.anchoredOrigin( + targetFrame: CGRect(x: -80, y: -120, width: 360, height: 260), + overlaySize: CGSize(width: 240, height: 110), + corner: .bottomLeft, + marginX: 18, + marginY: 14, + within: CGRect(x: 0, y: 24, width: 1280, height: 776) + ) + + XCTAssertEqual(origin.x, 0, accuracy: 0.001) + XCTAssertEqual(origin.y, 24, accuracy: 0.001) + } + + func testAnchoredOriginKeepsTopEdgeTightBeforeFinalClamp() { + let service = WindowAnchorService() + let origin = service.anchoredOrigin( + targetFrame: CGRect(x: 100, y: 560, width: 500, height: 180), + overlaySize: CGSize(width: 200, height: 100), + corner: .topRight, + marginX: 16, + marginY: 12, + within: CGRect(x: 0, y: 24, width: 1000, height: 676) + ) + + XCTAssertEqual(origin.x, 384, accuracy: 0.001) + XCTAssertEqual(origin.y, 600, accuracy: 0.001) + } + + func testAnchoredOriginKeepsBottomEdgeTightBeforeFinalClamp() { + let service = WindowAnchorService() + let origin = service.anchoredOrigin( + targetFrame: CGRect(x: 100, y: 10, width: 500, height: 240), + overlaySize: CGSize(width: 200, height: 100), + corner: .bottomLeft, + marginX: 18, + marginY: 14, + within: CGRect(x: 0, y: 24, width: 1280, height: 776) + ) + + XCTAssertEqual(origin.x, 118, accuracy: 0.001) + XCTAssertEqual(origin.y, 24, accuracy: 0.001) + } + + func testNormalizeUpperLeftDesktopCoordinatesToAppKitCoordinates() { + let normalized = LiveWindowAnchorProvider.normalizeToAppKitCoordinates( + CGRect(x: 120, y: 40, width: 500, height: 300), + desktopFrame: CGRect(x: 0, y: 0, width: 1440, height: 900) + ) + + XCTAssertEqual(normalized.origin.x, 120, accuracy: 0.001) + XCTAssertEqual(normalized.origin.y, 560, accuracy: 0.001) + XCTAssertEqual(normalized.width, 500, accuracy: 0.001) + XCTAssertEqual(normalized.height, 300, accuracy: 0.001) + } + + func testNormalizeUpperLeftCoordinatesAcrossDesktopUnion() { + let normalized = LiveWindowAnchorProvider.normalizeToAppKitCoordinates( + CGRect(x: -1500, y: 920, width: 640, height: 360), + desktopFrame: CGRect(x: -1728, y: -1080, width: 3168, height: 1980) + ) + + XCTAssertEqual(normalized.origin.x, -1500, accuracy: 0.001) + XCTAssertEqual(normalized.origin.y, -380, accuracy: 0.001) + XCTAssertEqual(normalized.width, 640, accuracy: 0.001) + XCTAssertEqual(normalized.height, 360, accuracy: 0.001) + } +} diff --git a/Textream/TextreamUITests/TextreamUITests.swift b/Textream/TextreamUITests/TextreamUITests.swift new file mode 100644 index 0000000..1e99a9d --- /dev/null +++ b/Textream/TextreamUITests/TextreamUITests.swift @@ -0,0 +1,165 @@ +import XCTest + +final class TextreamUITests: XCTestCase { + private var app: XCUIApplication! + + override func setUp() { + super.setUp() + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments.append("-ui-testing") + app.launchEnvironment["TEXTREAM_UI_TESTING"] = "1" + if app.state != .notRunning { + app.terminate() + } + app.launch() + bringAppToFront() + XCTAssertTrue(app.staticTexts["uiharness.overlay.mode"].waitForExistence(timeout: 5)) + } + + override func tearDown() { + if app?.state != .notRunning { + app.terminate() + } + super.tearDown() + } + + func testOverlayModeSwitchesExposeCurrentModeInHarness() { + app.buttons["uiharness.run.floating"].click() + assertValue(of: "uiharness.overlay.mode", equals: "floating") + + app.buttons["uiharness.run.pinned"].click() + assertValue(of: "uiharness.overlay.mode", equals: "pinned") + + app.buttons["uiharness.run.attached"].click() + assertValue(of: "uiharness.overlay.mode", equals: "attached") + + app.buttons["uiharness.run.fullscreen"].click() + assertValue(of: "uiharness.overlay.mode", equals: "fullscreen") + } + + func testManualAsideHotkeyLifecycleThroughMockController() { + app.buttons["uiharness.hotkeys.on"].click() + assertValue(of: "uiharness.hotkeys.running", equals: "true") + + app.buttons["uiharness.hotkeys.toggleAside"].click() + assertValue(of: "uiharness.tracking.state", equals: "aside") + + app.buttons["uiharness.hotkeys.toggleAside"].click() + assertValue(of: "uiharness.tracking.state", equals: "tracking") + + app.buttons["uiharness.hotkeys.holdOn"].click() + assertValue(of: "uiharness.tracking.state", equals: "aside") + + app.buttons["uiharness.hotkeys.holdOff"].click() + assertValue(of: "uiharness.tracking.state", equals: "tracking") + + app.buttons["uiharness.hotkeys.off"].click() + assertValue(of: "uiharness.hotkeys.running", equals: "false") + } + + func testHUDVisibilityAndAttachedAnchorPositionUpdate() { + app.buttons["uiharness.run.attached"].click() + let firstFrame = waitForValue(of: "uiharness.anchor.frame", matching: { $0 != "-" }) + XCTAssertFalse(firstFrame.isEmpty) + + app.buttons["uiharness.hud.off"].click() + assertValue(of: "uiharness.hud.enabled", equals: "off") + assertValue(of: "uiharness.hud.count", equals: "0") + + app.buttons["uiharness.hud.on"].click() + assertValue(of: "uiharness.hud.enabled", equals: "on") + + app.buttons["uiharness.anchor.move"].click() + let movedFrame = waitForDifferentValue(of: "uiharness.anchor.frame", from: firstFrame) + XCTAssertNotEqual(firstFrame, movedFrame) + + app.buttons["uiharness.anchor.quartz"].click() + assertValue(of: "uiharness.overlay.mode", equals: "attached") + assertValue(of: "uiharness.hud.enabled", equals: "on") + + app.buttons["uiharness.anchor.ax"].click() + assertValue(of: "uiharness.overlay.mode", equals: "attached") + } + + func testSettingsChangesApplyImmediatelyToAttachedOverlayAndHUD() { + app.buttons["uiharness.run.attached"].click() + let initialFrame = waitForValue(of: "uiharness.anchor.frame") + assertValue(of: "uiharness.attached.margin", equals: "16,14") + + app.buttons["uiharness.settings.attachedInset"].click() + assertValue(of: "uiharness.attached.margin", equals: "44,30") + XCTAssertNotEqual(initialFrame, waitForDifferentValue(of: "uiharness.anchor.frame", from: initialFrame)) + + app.buttons["uiharness.settings.hud.minimal"].click() + assertValue(of: "uiharness.hud.count", equals: "1") + + app.buttons["uiharness.settings.hud.full"].click() + assertValue(of: "uiharness.hud.count", equals: "4") + } + + private func assertValue(of identifier: String, equals expected: String, timeout: TimeInterval = 2.5) { + let actual = waitForValue(of: identifier, matching: { $0 == expected }, timeout: timeout) + XCTAssertEqual(actual, expected) + } + + private func waitForDifferentValue(of identifier: String, from original: String, timeout: TimeInterval = 2.5) -> String { + waitForValue(of: identifier, matching: { $0 != original }, timeout: timeout) + } + + @discardableResult + private func waitForValue( + of identifier: String, + matching predicate: ((String) -> Bool)? = nil, + timeout: TimeInterval = 2.5 + ) -> String { + bringAppToFront() + let text = app.staticTexts[identifier] + XCTAssertTrue(text.waitForExistence(timeout: timeout)) + + let deadline = Date().addingTimeInterval(timeout) + var lastValue = rawValue(of: identifier) + + while Date() < deadline { + if predicate?(lastValue) ?? true { + return lastValue + } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + bringAppToFront() + lastValue = rawValue(of: identifier) + } + + return lastValue + } + + private func rawValue(of identifier: String) -> String { + bringAppToFront() + let text = app.staticTexts[identifier] + XCTAssertTrue(text.waitForExistence(timeout: 2)) + if let value = text.value as? String, !value.isEmpty { + return value + } + return text.label + } + + private func bringAppToFront(timeout: TimeInterval = 5) { + if app.state != .runningForeground { + app.activate() + } + + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + switch app.state { + case .runningForeground: + return + case .notRunning: + XCTFail("UI harness app is not running") + return + default: + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + } + + XCTFail("UI harness app failed to reach foreground state") + } +} diff --git a/docs/defaults.md b/docs/defaults.md new file mode 100644 index 0000000..cceef9e --- /dev/null +++ b/docs/defaults.md @@ -0,0 +1,65 @@ +# Textream Stable Defaults + +This document captures the recommended defaults for the "stable by default" product pass. The intent is simple: + +- stop before drifting +- fall back to the screen corner when attached anchoring fails +- keep the default HUD minimal +- keep built-in presets intentionally small + +## TrackingGuard + +| Parameter | Recommended Default | Notes | +| --- | --- | --- | +| `strictTrackingEnabled` | `true` | Enables Guarded Word Tracking by default. | +| `legacyTrackingFallbackEnabled` | `true` | Keeps the legacy path available, but only when `strictTrackingEnabled = false`. | +| `manualAsideHotkey` | `optionDoubleTap` | Double-tap `Option` to toggle `Aside`. | +| `temporaryIgnoreHotkey` | `fnHold` | Hold `Fn` to freeze tracking temporarily. | +| `matchWindowSize` | `6` | Narrows the matching window to reduce off-script advancement. | +| `advanceThreshold` | `3.4` | Raises the advancement threshold so the system prefers freezing over guessing. | +| `offScriptFreezeDelay` | `0.9` | Enter `lost` after roughly 0.9 seconds of missed matches across consecutive frames. | + +## Attached Overlay + +| Parameter | Recommended Default | Notes | +| --- | --- | --- | +| `attachedAnchorCorner` | `topRight` | Default corner for attached placement. | +| `attachedMarginX` | `16` | Horizontal inset from the target corner. | +| `attachedMarginY` | `14` | Vertical inset from the target corner. | +| `attachedFallbackBehavior` | `screenCorner` | Falls back to the screen corner instead of disappearing. | +| `attachedHideWhenWindowUnavailable` | `false` | Keeps fallback visible so the user can understand what happened. | +| `attachedTargetWindowID` | `0` | No target window is preselected on first launch. | +| `attachedTargetWindowLabel` | `""` | No saved target label on first launch. | +| `hasSeenAttachedOnboarding` | `false` | Show attached onboarding the first time the mode is used. | + +## Layout Presets + +The recommended built-in preset set is intentionally limited to three templates: + +| Preset | Overlay | HUD | Use Case | +| --- | --- | --- | --- | +| `Interview` | `floating` | `trackingState` | Small and restrained, suitable for meetings or interviews. | +| `Live Stream` | `attached` | `trackingState + expectedWord` | Optimized for attached usage where state and current cue matter most. | +| `Presentation` | `pinned` | `trackingState + expectedWord` | Larger and higher contrast for speaking and presenting. | + +Compatibility-only presets remain available: + +- `Dual Display` +- `Sidecar iPad` + +They are still recognized in saved settings, but they are no longer promoted as the recommended starter set. + +## Persistent HUD + +| Parameter | Recommended Default | Notes | +| --- | --- | --- | +| `activeLayoutPreset` | `custom` | The baseline startup experience does not force a preset. | +| `persistentHUDEnabled` | `true` | A minimal HUD is enabled by default to explain tracking and fallback state. | +| `hudModules` | `[trackingState, expectedWord]` | Keep the default HUD focused on state and the current expected word. | + +## Experience Summary + +- The app starts from a neutral `custom` layout instead of forcing a scenario preset. +- The HUD is on by default, but only shows the two highest-value signals. +- Attached mode falls back to the screen corner when it loses a stable anchor. +- Strict tracking is intentionally conservative and freezes sooner than it advances. diff --git a/docs/migration-note.md b/docs/migration-note.md new file mode 100644 index 0000000..6c8bb04 --- /dev/null +++ b/docs/migration-note.md @@ -0,0 +1,70 @@ +# Migration Note: Stable Default Experience + +This note describes how existing users are migrated into the new default behavior introduced in this pass. + +## Migration Principles + +- Existing saved settings win. The app does not overwrite previously chosen values. +- New defaults only apply to keys that were not saved before. +- Older presets and protocol fields remain compatible. No destructive migration is performed. + +## Settings Compatibility + +### TrackingGuard + +- If a user already saved `matchWindowSize`, `advanceThreshold`, or `offScriptFreezeDelay`, those values are preserved. +- If those keys do not exist yet, the app uses the new conservative defaults: + - `matchWindowSize = 6` + - `advanceThreshold = 3.4` + - `offScriptFreezeDelay = 0.9` +- `legacyTrackingFallbackEnabled` remains available and defaults to enabled, but it is only meant as a rollback guardrail. It does not define the default experience while `strictTrackingEnabled = true`. + +### Persistent HUD + +- If a user already configured `persistentHUDEnabled` or `hudModules`, those choices remain unchanged. +- If a user has never configured HUD preferences, the upgrade enables the minimal HUD: + - `persistentHUDEnabled = true` + - `hudModules = [trackingState, expectedWord]` +- In practice, this means users without an explicit HUD preference will see a small but visible HUD after upgrading. + +### Attached Overlay + +- `attachedFallbackBehavior` now defaults to `screenCorner`. +- `attachedHideWhenWindowUnavailable` now defaults to `false`, so attached mode prefers a visible fallback over silently disappearing. +- If a user already saved a target window id or label, the app still tries to rebind to that target. If the target is unavailable, the new behavior becomes an explicit fallback state instead of a silent failure. + +## Preset Compatibility + +- The recommended built-in presets are now limited to: + - `Interview` + - `Live Stream` + - `Presentation` +- `Dual Display` and `Sidecar iPad` are not removed: + - older `activeLayoutPreset` values still decode correctly + - saved custom presets that reference them still work + - they are simply no longer promoted as recommended starter presets +- If an existing user is still using `Dual Display` or `Sidecar iPad`, the app does not rewrite that choice. Settings can surface them as compatibility presets and let the user save them as custom presets if desired. + +## Remote Compatibility + +- Browser and Director payloads remain backward-compatible field extensions. +- Older clients can continue reading the existing fields: + - `highlightedCharCount` + - `words` + - `audioLevels` + - `lastSpokenText` +- Newer clients can additionally read: + - `trackingState` + - `confidenceLevel` + - `expectedWord` + - `nextCue` + - `manualAsideActive` + +## User-Visible Upgrade Impact + +The most noticeable changes for existing users are: + +1. Word tracking is more conservative and will freeze sooner before it guesses. +2. Attached mode is more likely to fall back to the screen corner than to appear to vanish. +3. Users without an explicit HUD preference now get a minimal HUD by default. +4. Settings recommends fewer presets, while preserving older saved presets. diff --git a/docs/product-copy.md b/docs/product-copy.md new file mode 100644 index 0000000..662924e --- /dev/null +++ b/docs/product-copy.md @@ -0,0 +1,70 @@ +# Product Copy + +This document standardizes the user-facing copy introduced in this pass. The goals are: + +- use one primary phrase for each state +- keep HUD, status line, onboarding, and diagnostics aligned +- explain why tracking stopped or why attached mode fell back + +## Tracking States + +| State | UI Label | Status Line | Use Case | +| --- | --- | --- | --- | +| `tracking` | `Tracking` | `Tracking: {word}` or `Tracking your script` | Normal on-script advancement. | +| `uncertain` | `Checking Script` | `Heard you. Checking the script before moving.` | Speech was detected, but it is not yet safe to advance. | +| `aside` | `Aside` | `Aside active. Tracking is paused while you hold.` or `Aside mode is on. Tracking is paused.` | Manual aside mode or temporary ignore. | +| `lost` | `Off Script` | `Off script. Waiting to lock back on.` | Repeated misses have frozen advancement until the script is reacquired. | + +## Attached Short Copy + +These strings are intended for the HUD, `statusLine`, and attached diagnostics. + +| Scenario | Copy | +| --- | --- | +| No target window selected | `No target window selected; using screen corner` | +| Accessibility not granted | `Accessibility off; using screen corner` | +| AX failed but Quartz is available | `Using visible window bounds (AX fallback)` | +| Accessibility is granted but the target window is unreadable | `Can't read selected window; using screen corner` | +| Target window lost | `Target window lost; back to screen corner` | +| User chose hidden fallback | `Target window unavailable; overlay hidden` | + +## Attached Long Copy + +These strings are intended for onboarding, diagnostics, and QA logging. + +| Scenario | Copy | +| --- | --- | +| No target window selected | `Choose a target window to attach to. Until then, Textream will stay in the screen corner.` | +| Accessibility not granted | `Attached Overlay needs Accessibility access before it can follow another app window. Textream will stay in the screen corner until access is granted.` | +| AX attached successfully | `Attached using Accessibility geometry for {target}.` | +| Quartz fallback | `Accessibility geometry is unavailable for {target}. Textream is following the visible window bounds instead.` | +| Accessibility granted but target unreadable | `Accessibility is enabled, but macOS is not exposing usable geometry for {target}. Textream is staying in the screen corner.` | +| Target window lost | `The selected window is no longer available. Textream moved back to the screen corner.` | +| Hidden fallback | `The selected window is unavailable, so the overlay was hidden by the attached fallback setting.` | + +## Onboarding Copy + +| Location | Copy | +| --- | --- | +| Title | `Allow Accessibility for Attached Overlay` | +| Body | `Attached Overlay needs Accessibility access before it can follow another app window. Until access is granted, Textream will stay in the screen corner.` | +| Primary button | `Open System Settings` | +| Secondary button | `Continue with Fallback` | + +## Launch Guide Copy + +| Location | Copy | +| --- | --- | +| Title when not in attached mode | `Accessibility unlocks Attached Overlay` | +| Title when currently in attached mode | `Attached Overlay is using screen-corner fallback` | +| Body when not in attached mode | `If you want Textream to follow another app window, grant Accessibility before you use Attached Overlay. Until then, Textream will fall back to the screen corner instead of silently failing.` | +| Body when currently in attached mode | `Accessibility is still off, so Textream cannot lock onto other app windows yet. Attached Overlay will stay in the screen corner until you allow access.` | +| Secondary button | `Review Attached Setup` | +| Dismiss button | `Later` | + +## Terminology Rules + +- Use `screen corner` consistently instead of mixing it with `screen-corner fallback`. +- Use `Off Script` externally instead of exposing `Lost`. +- Use `Checking Script` externally instead of exposing `Uncertain`. +- The word `fallback` is acceptable in QA and diagnostics, but product-facing copy should prefer phrases such as `using screen corner` or `using visible window bounds`. diff --git a/docs/qa/regression-checklist.md b/docs/qa/regression-checklist.md new file mode 100644 index 0000000..d2230f1 --- /dev/null +++ b/docs/qa/regression-checklist.md @@ -0,0 +1,227 @@ +# Textream Productization Regression Checklist + +## 1. Executable macOS Regression Matrix + +### 1.1 Preparation + +- Build baseline: + `xcodebuild -project Textream/Textream.xcodeproj -scheme Textream -configuration Debug -sdk macosx CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build` +- Test baseline: + `xcodebuild -project Textream/Textream.xcodeproj -scheme Textream -configuration Debug -sdk macosx CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO test` +- Targeted smoke tests: + `xcodebuild -project Textream/Textream.xcodeproj -scheme Textream -configuration Debug -sdk macosx CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO -only-testing:TextreamTests/TrackingGuardTests -only-testing:TextreamTests/WindowAnchorServiceTests -only-testing:TextreamTests/RemoteStateCompatibilityTests -only-testing:TextreamIntegrationTests/RemoteStateCompatibilityIntegrationTests test` +- Open `Settings -> QA & Debug` +- To inspect overlay state directly, enable `Show Debug Overlay` +- To collect decision traces, enable `Tracking Logs` and `Anchor Logs` +- Recommended attached-mode setup: + 1. A mainstream multi-window app such as Finder, Safari, or Chrome + 2. An external display or Sidecar target + 3. A script that includes normal reading, off-script narration, and bracket cues such as `[pause]` + +### 1.2 Signals to Watch + +- Tracking signal: + `TRACK | expected | conf | freeze ` +- Anchor signal: + `ANCHOR | AX on/off | | | ` +- QA panel signal: + `Live Tracking`, `Live Anchor`, `Recent QA Logs` + +### 1.3 Scenario Matrix + +| Scenario | Modes | Steps | Expected Behavior | Debug Signal | +| --- | --- | --- | --- | --- | +| Primary and secondary display switching | pinned / floating / attached / fullscreen | Start on the primary display, move main usage to the secondary display, and change fixed-display preferences | Overlay does not disappear or flicker; fullscreen reattaches to the intended screen | `Live Anchor` or display selection state updates coherently | +| External display unplug | attached / fullscreen / external | Attach to a window on the external display, then unplug the display; also test fullscreen while the external display is active | Attached falls back immediately; fullscreen exits cleanly or returns to the main display without leaving an orphaned panel | Anchor source changes to `Fallback` and logs record the display fallback | +| Fullscreen app target | attached | Select a fullscreen window, then enter and exit fullscreen | If stable geometry is unavailable, the overlay falls back or hides without visible jitter | Anchor source stays explainable across `AX`, `Quartz`, and `Fallback` | +| Stage Manager | attached | Switch active stages, collapse and expand side groups | When the target becomes unavailable the overlay falls back; when the target returns it reattaches | Logs show `window unavailable -> fallback -> relock` | +| Mission Control and Spaces | attached / pinned / floating | Switch Spaces quickly and move windows between Spaces via Mission Control | Overlay does not linger or flash in the wrong Space; attached mode falls back when needed | Logs and `Live Anchor` remain consistent | +| Accessibility not granted | attached | Revoke Accessibility and start attached mode | Attached mode can still open, but it must explain the fallback path | `AX Trusted = No`, source resolves to `Quartz` or `Fallback` | +| Accessibility granted while app is running | attached | Start attached mode, grant Accessibility in System Settings, then return | No restart required; geometry resolution upgrades automatically | Anchor source upgrades from `Quartz` to `AX` | +| Accessibility revoked while app is running | attached | Revoke Accessibility during an attached session | No crash; geometry resolution drops back cleanly | Anchor source downgrades from `AX` to `Quartz` or `Fallback` | +| AX success and Quartz fallback | attached | Use a multi-window app and repeatedly move and resize the selected window | The source remains visible and explainable; AX failure must not snap to the wrong sibling window | QA diagnostics clearly show `AX` or `Quartz` | +| Target window minimized, hidden, closed, or backgrounded | attached | Minimize, hide, close, or background the target app, then restore it | Overlay hides or falls back according to settings, and relocks when the window returns | Logs include unavailable, fallback, and relock transitions | +| Attached move, resize, and cross-screen drag | attached | Drag the target window, resize it, move it across displays, and pin it to screen edges | Overlay stays attached, including top and bottom movement, without drifting off-screen | Anchor frames update continuously and remain explainable | +| Repeated mode switching | all | Switch between pinned, floating, attached, and fullscreen during an active session | No leaked panels, stale hotkeys, or invalid anchor state | `Live Anchor` resets to inactive outside attached mode | +| On-script reading | tracking | Read the script normally | State stays in `Tracking`, `expectedWord` advances steadily | `freeze None` with advancement detail | +| Off-script narration | tracking | Read normally, then speak off-script for two seconds | State moves into `Checking Script` or `Off Script`, and advancement freezes | Freeze reason reports low match or off-script audio | +| Hold to Ignore and Aside | tracking | Hold `Fn`, double-tap `Option`, then resume | Freeze occurs within 100 ms; relock happens within about one second | Freeze reason reports manual aside or recovery pending | +| `[pause]`, skipped words, repeated words, and paraphrasing | tracking | Use a script that includes cue tokens and spoken deviations | Weak matches do not push the script forward blindly; relocking remains possible | QA detail reports low-score and insufficient-match cases | +| `[wave]` and other bracket cues | tracking | Insert `[wave]` or `[smile]` into the script but only speak the main text | Bracket cues remain visual annotations and do not block advancement or completion | `Expected` skips directly to the next spoken token | +| HUD disabled or all modules removed | all overlay modes | Disable Persistent HUD or clear all HUD modules | No empty top spacer remains in preview or the live overlay | HUD strip is not rendered when the item list is empty | +| Teleprompter settings by mode | fullscreen / attached / floating | Inspect the Teleprompter settings page in each mode | Each mode only shows relevant controls | Mode-specific controls match the active overlay mode | +| Browser legacy client compatibility | browser remote | Decode a payload using only the old field set | Older clients ignore the new fields and stay connected | `RemoteStateCompatibilityTests` passes | +| Director legacy client compatibility | director | Decode a payload using only the old field set | Older clients ignore the new fields and stay connected | `RemoteStateCompatibilityTests` passes | + +## 2. QA Panel and Logging Controls + +### 2.1 Entry Point + +- `Settings -> QA & Debug` + +### 2.2 Switches + +- `Show Debug Overlay` + Shows tracking and anchor labels directly inside the teleprompter overlay +- `Tracking Logs` + Writes `TrackingGuard` state changes, freeze reasons, and recovery phases into the QA log stream +- `Anchor Logs` + Writes `WindowAnchorService` decisions for `AX`, `Quartz`, and `Fallback` into the QA log stream + +### 2.3 Reading the QA Surface + +- `Live Tracking` + Confirms `state`, `expectedWord`, `confidence`, `freeze reason`, and detail +- `Live Anchor` + Confirms whether the app is using `AX`, `Quartz`, or `Fallback`, and whether Accessibility is trusted +- `Recent QA Logs` + Preserves decision history across transitions so failures can be reconstructed without Xcode attached + +## 3. Issues Found and Fixes Applied + +### Issue 1: Attached fallback was tied to a stale launch-time screen + +- Reproduction: + 1. Start attached mode on a window that lives on an external display + 2. Minimize the target window or unplug the display +- Expected: + The overlay falls back to the currently visible display corner +- Actual before fix: + Fallback used the original launch-time screen, which could make the overlay appear to disappear after display topology changes +- Fix: + Resolve fallback from the panel's current screen first, then fall back to the main screen +- Current status: + Code path is fixed; real hardware verification is still recommended for unplug plus Space transitions + +### Issue 2: AX window matching relied too heavily on title equality + +- Reproduction: + 1. Open multiple windows from the same app with identical or missing titles + 2. Attach to one of them + 3. Move or resize the intended target +- Expected: + AX matching should choose the window whose geometry is closest to the Quartz candidate +- Actual before fix: + The first title match could win, causing attachment to the wrong sibling window +- Fix: + Use a combined score of title preference plus geometric proximity to the Quartz bounds +- Current status: + Risk is much lower in common multi-window apps, but Finder, Safari, and Chrome should still be checked manually + +### Issue 3: Tracking freeze reasons were not visible enough for QA + +- Reproduction: + 1. Speak off-script for two seconds + 2. Or hold `Fn` to trigger hold-to-ignore +- Expected: + QA should be able to see the current state, expected word, confidence, and freeze reason directly +- Actual before fix: + The overlay only surfaced a generic state and made it hard to tell intentional freeze from a mismatch +- Fix: + Added `decisionReason` and `debugSummary` to `TrackingGuard`, and surfaced them in QA and overlay debug views +- Current status: + Failures are now diagnosable without attaching Xcode + +### Issue 4: Remote protocol expansion lacked explicit compatibility coverage + +- Reproduction: + 1. Decode Browser or Director payloads with an old client that only knows the legacy fields + 2. Feed it a payload with the new tracking fields +- Expected: + Older clients ignore the new fields and remain connected +- Actual before fix: + The design was intended to be backward-compatible, but the assumption was not enforced automatically +- Fix: + Added `RemoteStateCompatibilityTests` to verify both old-client decoding and new-payload compatibility +- Current status: + Protocol compatibility now has automated regression coverage + +### Issue 5: Bracket cues such as `[wave]` could block strict tracking and completion + +- Reproduction: + 1. Use `hello [wave] there` + 2. Speak only `hello there` + 3. Or end the page with a bracket cue +- Expected: + Bracket cues stay visual and do not become required spoken tokens +- Actual before fix: + The normalized cue text was treated as a required tracking token, which could block `expectedWord` and completion +- Fix: + Treat bracket cues as styled annotations that auto-skip in the tracking token stream +- Current status: + Covered by `TrackingGuardTests`, including the trailing-cue completion case + +### Issue 6: Persistent HUD reserved empty space even when nothing was shown + +- Reproduction: + 1. Disable `Persistent HUD` + 2. Or clear all HUD modules + 3. Open preview, notch, floating, or external teleprompter +- Expected: + No empty spacer should remain at the top of the overlay +- Actual before fix: + Preview and live overlays both kept an empty vertical gap +- Fix: + Skip HUD strip rendering entirely when `items.isEmpty`; keep QA overlay independent +- Current status: + Rendering paths are unified; manual confirmation is still recommended for preview versus live parity + +### Issue 7: Teleprompter settings mixed controls from unrelated modes + +- Reproduction: + 1. Switch to `Fullscreen` + 2. Open `Settings -> Teleprompter` + 3. Check whether floating-only controls such as `Pointer Follow` still appear +- Expected: + The settings page should only show controls relevant to the active mode +- Actual before fix: + Floating-specific controls appeared globally and created the wrong mental model +- Fix: + Moved `Pointer Follow` back into the floating-only section; attached mode now focuses on target window, corner, margin, and size; fullscreen focuses on display and exit behavior +- Current status: + The information architecture is much clearer, though smaller window heights should still be checked manually + +### Issue 8: Attached anchoring could pick the screen corner instead of the window corner near edges + +- Reproduction: + 1. Drag the target window to a display edge or across display boundaries + 2. Switch between all four attachment corners + 3. Observe the attached overlay position +- Expected: + Screen selection and clamping should follow the target window's interior corner, not the whole display edge +- Actual before fix: + Corner math could resolve against the wrong display and push the overlay off-screen or away from the top or bottom edge +- Fix: + Use interior probe points for screen selection and keep visible-frame clamping +- Current status: + `WindowAnchorServiceTests` now covers top and bottom clamping; real multi-display and Stage Manager behavior still needs manual review + +## 4. Regression Conclusion and Remaining Risks + +### 4.1 Current Conclusion + +- The P0, P1, and P2 work now has the expected productization support: + - executable regression matrix + - in-app QA panel + - overlay debug labels + - tracking and anchor log streams + - automated Browser and Director compatibility coverage +- The project is now in a good state for manual regression and issue closure rather than further feature expansion +- Code-level validation already completed: + - unsigned Debug build succeeds + - `TrackingGuardTests` passes + - `WindowAnchorServiceTests` passes + - `RemoteStateCompatibilityTests` and `RemoteStateCompatibilityIntegrationTests` pass + +### 4.2 Remaining Risks + +- Fullscreen apps, Stage Manager, Mission Control, and Spaces still depend heavily on platform window-visibility behavior and cannot be covered fully by unit tests alone +- External display unplug behavior is fixed in code, but still benefits from real hardware validation across unplug timing and Space transitions +- `AX -> Quartz -> Fallback` is now observable and explainable, but some third-party apps may still expose unstable window metadata +- The settings window and preview panel should still be checked visually on smaller screens to ensure layout remains readable + +### 4.3 Suggested Exit Criteria + +- Every scenario in the matrix has been exercised at least once manually +- `Recent QA Logs` shows no unexplained source thrash or state thrash +- Remaining attached-mode issues are limited to platform constraints, not unresolved engineering bugs diff --git a/scripts/test-all.sh b/scripts/test-all.sh new file mode 100755 index 0000000..632d4d3 --- /dev/null +++ b/scripts/test-all.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"${SCRIPT_DIR}/test-unit.sh" +"${SCRIPT_DIR}/test-integration.sh" +"${SCRIPT_DIR}/test-ui.sh" diff --git a/scripts/test-common.sh b/scripts/test-common.sh new file mode 100755 index 0000000..cf3c2e7 --- /dev/null +++ b/scripts/test-common.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +PROJECT_PATH="${REPO_ROOT}/Textream/Textream.xcodeproj" +SCHEME="Textream" +CONFIGURATION="${CONFIGURATION:-Debug}" +SDK="${SDK:-macosx}" +BUILD_ROOT="${TEST_BUILD_ROOT:-${TMPDIR%/}/textream-tests}" +ARTIFACT_ROOT="${REPO_ROOT}/.build/tests" + +run_test_layer() { + local layer="$1" + local only_testing_target="$2" + shift 2 + + local derived_data_path="${BUILD_ROOT}/derived-data/${layer}" + local result_bundle_path="${BUILD_ROOT}/results/${layer}.xcresult" + local artifact_result_bundle_path="${ARTIFACT_ROOT}/results/${layer}.xcresult" + local log_path="${ARTIFACT_ROOT}/logs/${layer}.log" + + mkdir -p "${BUILD_ROOT}/derived-data" "${BUILD_ROOT}/results" "${ARTIFACT_ROOT}/results" "${ARTIFACT_ROOT}/logs" + rm -rf "${derived_data_path}" "${result_bundle_path}" "${artifact_result_bundle_path}" + + local -a cmd=( + xcodebuild + -project "${PROJECT_PATH}" + -scheme "${SCHEME}" + -configuration "${CONFIGURATION}" + -sdk "${SDK}" + CODE_SIGNING_ALLOWED=NO + CODE_SIGNING_REQUIRED=NO + -derivedDataPath "${derived_data_path}" + -resultBundlePath "${result_bundle_path}" + -parallel-testing-enabled NO + test + "-only-testing:${only_testing_target}" + ) + + if [[ "$#" -gt 0 ]]; then + cmd+=("$@") + fi + + set +e + ( + cd "${REPO_ROOT}" + "${cmd[@]}" + ) | tee "${log_path}" + local exit_code=${PIPESTATUS[0]} + set -e + + if [[ -d "${result_bundle_path}" ]]; then + cp -R "${result_bundle_path}" "${artifact_result_bundle_path}" + fi + + return "${exit_code}" +} diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh new file mode 100755 index 0000000..013b809 --- /dev/null +++ b/scripts/test-integration.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-common.sh" + +run_test_layer "integration" "TextreamIntegrationTests" diff --git a/scripts/test-ui.sh b/scripts/test-ui.sh new file mode 100755 index 0000000..5f4f5eb --- /dev/null +++ b/scripts/test-ui.sh @@ -0,0 +1,360 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-common.sh" + +UI_LAYER="ui" +UI_SCHEME="${UI_TEST_SCHEME:-TextreamUI}" +UI_TIMEOUT_SECONDS="${UI_TEST_TIMEOUT_SECONDS:-240}" +UI_POLL_SECONDS="${UI_TEST_POLL_SECONDS:-5}" +UI_DIAGNOSTIC_SAMPLE_SECONDS="${UI_DIAGNOSTIC_SAMPLE_SECONDS:-3}" +UI_AUTOMATION_PREFLIGHT_TIMEOUT_SECONDS="${UI_AUTOMATION_PREFLIGHT_TIMEOUT_SECONDS:-8}" +TCC_DIAGNOSTIC_LOG_LOOKBACK="${TCC_DIAGNOSTIC_LOG_LOOKBACK:-10m}" +UI_CODE_SIGNING_ALLOWED="${UI_CODE_SIGNING_ALLOWED:-YES}" +UI_CODE_SIGNING_REQUIRED="${UI_CODE_SIGNING_REQUIRED:-NO}" +UI_CODE_SIGN_IDENTITY="${UI_CODE_SIGN_IDENTITY:--}" + +if [[ -n "${CI:-}" ]]; then + DEFAULT_UI_AUTOMATION_PREFLIGHT_REQUIRED=0 +else + DEFAULT_UI_AUTOMATION_PREFLIGHT_REQUIRED=1 +fi + +UI_AUTOMATION_PREFLIGHT_REQUIRED="${UI_AUTOMATION_PREFLIGHT_REQUIRED:-${DEFAULT_UI_AUTOMATION_PREFLIGHT_REQUIRED}}" + +DERIVED_DATA_PATH="${BUILD_ROOT}/derived-data/${UI_LAYER}" +RESULT_BUNDLE_PATH="${BUILD_ROOT}/results/${UI_LAYER}.xcresult" +ARTIFACT_RESULT_BUNDLE_PATH="${ARTIFACT_ROOT}/results/${UI_LAYER}.xcresult" +LOG_PATH="${ARTIFACT_ROOT}/logs/${UI_LAYER}.log" +DIAGNOSTIC_PATH="${ARTIFACT_ROOT}/logs/${UI_LAYER}-diagnostics.log" +AUTOMATION_PREFLIGHT_LOG_PATH="${ARTIFACT_ROOT}/logs/${UI_LAYER}-automation-preflight.log" +UI_APP_PATH="${DERIVED_DATA_PATH}/Build/Products/Debug/Textream.app" +UI_RUNNER_PATH="${DERIVED_DATA_PATH}/Build/Products/Debug/TextreamUITests-Runner.app" + +cleanup_stale_ui_processes() { + pkill -x Textream >/dev/null 2>&1 || true + pkill -x TextreamUITests-Runner >/dev/null 2>&1 || true + + local stale_textream_pids + stale_textream_pids="$(pgrep -f '/Textream.app/Contents/MacOS/Textream ' || true)" + if [[ -n "${stale_textream_pids}" ]]; then + while IFS= read -r pid; do + [[ -z "${pid}" ]] && continue + local parent_pid + parent_pid="$(ps -o ppid= -p "${pid}" 2>/dev/null | tr -d ' ' || true)" + kill "${pid}" >/dev/null 2>&1 || true + if [[ -n "${parent_pid}" && "${parent_pid}" != "1" ]]; then + kill "${parent_pid}" >/dev/null 2>&1 || true + fi + done <<< "${stale_textream_pids}" + fi + + sleep 1 + pkill -9 -x Textream >/dev/null 2>&1 || true + pkill -9 -x TextreamUITests-Runner >/dev/null 2>&1 || true + pkill -9 -f '/Textream.app/Contents/MacOS/Textream ' >/dev/null 2>&1 || true +} + +resolve_active_xcodebuild_pid() { + local wrapper_pid="$1" + local child_pid + child_pid="$(pgrep -P "${wrapper_pid}" xcodebuild | head -n 1 || true)" + if [[ -n "${child_pid}" ]]; then + printf '%s\n' "${child_pid}" + else + printf '%s\n' "${wrapper_pid}" + fi +} + +append_diagnostic() { + local message="$1" + printf '%s\n' "${message}" | tee -a "${DIAGNOSTIC_PATH}" >> "${LOG_PATH}" +} + +run_ui_automation_preflight() { + if [[ "${UI_TEST_SKIP_AUTOMATION_PREFLIGHT:-0}" == "1" ]]; then + append_diagnostic "Skipping UI automation preflight because UI_TEST_SKIP_AUTOMATION_PREFLIGHT=1" + return 0 + fi + + append_diagnostic "Running UI automation preflight through System Events" + rm -f "${AUTOMATION_PREFLIGHT_LOG_PATH}" + + set +e + /usr/bin/osascript >"${AUTOMATION_PREFLIGHT_LOG_PATH}" 2>&1 <<'APPLESCRIPT' & +tell application "System Events" + count of application processes +end tell +APPLESCRIPT + local preflight_pid=$! + set -e + + local start_epoch + start_epoch="$(date +%s)" + + while kill -0 "${preflight_pid}" >/dev/null 2>&1; do + local now_epoch + now_epoch="$(date +%s)" + + if (( now_epoch - start_epoch >= UI_AUTOMATION_PREFLIGHT_TIMEOUT_SECONDS )); then + kill "${preflight_pid}" >/dev/null 2>&1 || true + wait "${preflight_pid}" >/dev/null 2>&1 || true + append_diagnostic "UI automation preflight timed out after ${UI_AUTOMATION_PREFLIGHT_TIMEOUT_SECONDS}s" + append_diagnostic "This usually means macOS Automation/Accessibility approval has not completed for Terminal/Xcode/System Events." + append_diagnostic "Open System Settings > Privacy & Security > Accessibility and Automation, then allow Terminal or Xcode to control your Mac and System Events." + append_diagnostic "Quick links: open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' and open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation'" + return 1 + fi + + sleep 1 + done + + set +e + wait "${preflight_pid}" + local exit_code=$? + set -e + + if (( exit_code != 0 )); then + append_diagnostic "UI automation preflight failed with exit code ${exit_code}" + if [[ -s "${AUTOMATION_PREFLIGHT_LOG_PATH}" ]]; then + append_diagnostic "Preflight output: $(tr '\n' ' ' < "${AUTOMATION_PREFLIGHT_LOG_PATH}" | tr -s ' ')" + fi + return 1 + fi + + local output + output="$(tr -d '\r\n' < "${AUTOMATION_PREFLIGHT_LOG_PATH}" || true)" + append_diagnostic "UI automation preflight passed (System Events process count: ${output:-unknown})" +} + +capture_ui_diagnostics() { + local runner_pid="${1:-}" + local xcodebuild_pid="${2:-}" + + append_diagnostic "" + append_diagnostic "===== UI diagnostics @ $(date '+%Y-%m-%d %H:%M:%S') =====" + append_diagnostic "ui_scheme=${UI_SCHEME}" + append_diagnostic "derived_data_path=${DERIVED_DATA_PATH}" + append_diagnostic "result_bundle_path=${RESULT_BUNDLE_PATH}" + + { + echo "----- process snapshot -----" + ps -Ao pid,ppid,etime,stat,command | rg 'xcodebuild|TextreamUITests-Runner|/Textream.app/Contents/MacOS/Textream |debugserver|testmanagerd' || true + echo + echo "----- xctestrun + xctestconfiguration -----" + find "${DERIVED_DATA_PATH}" -name '*.xctestrun' -o -name '*.xctestconfiguration' | sort || true + echo + echo "----- runner environment -----" + if [[ -n "${runner_pid}" ]] && ps -p "${runner_pid}" >/dev/null 2>&1; then + ps eww -p "${runner_pid}" || true + else + echo "runner pid unavailable" + fi + echo + echo "----- codesign validation -----" + codesign -vvv --strict "${UI_APP_PATH}" || true + codesign -vvv --strict "${UI_RUNNER_PATH}" || true + echo + echo "----- automation preflight -----" + if [[ -f "${AUTOMATION_PREFLIGHT_LOG_PATH}" ]]; then + cat "${AUTOMATION_PREFLIGHT_LOG_PATH}" || true + else + echo "automation preflight log unavailable" + fi + echo + echo "----- TCC / XCTest logs -----" + /usr/bin/log show --last "${TCC_DIAGNOSTIC_LOG_LOOKBACK}" --style compact --predicate 'process == "tccd" OR process == "testmanagerd" OR process == "xcodebuild" OR process == "TextreamUITests-Runner" OR eventMessage CONTAINS[c] "XCTest" OR eventMessage CONTAINS[c] "TCCAccessRequest" OR eventMessage CONTAINS[c] "System Events" OR eventMessage CONTAINS[c] "kTCCServiceAccessibility"' | tail -n 120 || true + echo + echo "----- recent DiagnosticReports -----" + find "${HOME}/Library/Logs/DiagnosticReports" -maxdepth 1 -type f \( -name 'TextreamUITests-Runner*' -o -name 'XCTRunner*' -o -name 'xctest*' \) -mmin -20 -print | sort || true + } >> "${DIAGNOSTIC_PATH}" + + if [[ -n "${runner_pid}" ]] && ps -p "${runner_pid}" >/dev/null 2>&1; then + append_diagnostic "sampling runner pid ${runner_pid} for ${UI_DIAGNOSTIC_SAMPLE_SECONDS}s" + sample "${runner_pid}" "${UI_DIAGNOSTIC_SAMPLE_SECONDS}" -mayDie >> "${DIAGNOSTIC_PATH}" 2>&1 || true + fi + + if [[ -n "${xcodebuild_pid}" ]] && ps -p "${xcodebuild_pid}" >/dev/null 2>&1; then + append_diagnostic "sampling xcodebuild pid ${xcodebuild_pid} for ${UI_DIAGNOSTIC_SAMPLE_SECONDS}s" + sample "${xcodebuild_pid}" "${UI_DIAGNOSTIC_SAMPLE_SECONDS}" -mayDie >> "${DIAGNOSTIC_PATH}" 2>&1 || true + fi +} + +start_runner_monitor() { + local wrapper_pid="$1" + + ( + local seen_runner_pid="" + + while kill -0 "${wrapper_pid}" >/dev/null 2>&1; do + local runner_pid + runner_pid="$(pgrep -x TextreamUITests-Runner | head -n 1 || true)" + + if [[ -n "${runner_pid}" && "${runner_pid}" != "${seen_runner_pid}" ]]; then + append_diagnostic "observed TextreamUITests-Runner pid ${runner_pid}" + ps eww -p "${runner_pid}" >> "${DIAGNOSTIC_PATH}" 2>&1 || true + sample "${runner_pid}" 1 -mayDie >> "${DIAGNOSTIC_PATH}" 2>&1 || true + seen_runner_pid="${runner_pid}" + fi + + sleep 1 + done + ) & + + printf '%s\n' "$!" +} + +validate_ui_codesign() { + local validation_failed=0 + + if [[ ! -d "${UI_APP_PATH}" ]]; then + append_diagnostic "UI app bundle missing at ${UI_APP_PATH}" + validation_failed=1 + elif ! codesign -vvv --strict "${UI_APP_PATH}" >> "${DIAGNOSTIC_PATH}" 2>&1; then + append_diagnostic "UI app bundle failed codesign validation at ${UI_APP_PATH}" + validation_failed=1 + fi + + if [[ ! -d "${UI_RUNNER_PATH}" ]]; then + append_diagnostic "UI runner bundle missing at ${UI_RUNNER_PATH}" + validation_failed=1 + elif ! codesign -vvv --strict "${UI_RUNNER_PATH}" >> "${DIAGNOSTIC_PATH}" 2>&1; then + append_diagnostic "UI runner bundle failed codesign validation at ${UI_RUNNER_PATH}" + validation_failed=1 + fi + + return "${validation_failed}" +} + +mkdir -p "${BUILD_ROOT}/derived-data" "${BUILD_ROOT}/results" "${ARTIFACT_ROOT}/results" "${ARTIFACT_ROOT}/logs" +rm -rf "${DERIVED_DATA_PATH}" "${RESULT_BUNDLE_PATH}" "${ARTIFACT_RESULT_BUNDLE_PATH}" +rm -f "${LOG_PATH}" "${DIAGNOSTIC_PATH}" "${AUTOMATION_PREFLIGHT_LOG_PATH}" + +# macOS UI tests can stall if a manually launched Textream instance is already +# running under the same bundle identifier. Clear any stale app/runner first so +# XCUIApplication().launch() always gets a fresh AUT session. +cleanup_stale_ui_processes + +if ! run_ui_automation_preflight; then + if [[ "${UI_AUTOMATION_PREFLIGHT_REQUIRED}" == "1" ]]; then + append_diagnostic "Aborting UI tests before build because the UI automation preflight did not complete." + exit 125 + fi + + append_diagnostic "UI automation preflight failed, but continuing because UI_AUTOMATION_PREFLIGHT_REQUIRED=${UI_AUTOMATION_PREFLIGHT_REQUIRED}" +fi + +build_cmd=( + xcodebuild + -project "${PROJECT_PATH}" + -scheme "${UI_SCHEME}" + -configuration "${CONFIGURATION}" + -sdk "${SDK}" + "CODE_SIGNING_ALLOWED=${UI_CODE_SIGNING_ALLOWED}" + "CODE_SIGNING_REQUIRED=${UI_CODE_SIGNING_REQUIRED}" + "CODE_SIGN_IDENTITY=${UI_CODE_SIGN_IDENTITY}" + -derivedDataPath "${DERIVED_DATA_PATH}" + build-for-testing +) + +{ + printf '== UI build-for-testing ==\n' + ( + cd "${REPO_ROOT}" + "${build_cmd[@]}" + ) +} | tee "${LOG_PATH}" + +append_diagnostic "Validating UI build products with codesign --strict" +if ! validate_ui_codesign; then + append_diagnostic "UI build products are not launchable for XCTest. UI tests require a valid locally signed runner bundle." + append_diagnostic "Current settings: CODE_SIGNING_ALLOWED=${UI_CODE_SIGNING_ALLOWED} CODE_SIGNING_REQUIRED=${UI_CODE_SIGNING_REQUIRED} CODE_SIGN_IDENTITY=${UI_CODE_SIGN_IDENTITY}" + exit 1 +fi + +XCTESTRUN_PATH="$(find "${DERIVED_DATA_PATH}/Build/Products" -maxdepth 1 -name '*.xctestrun' | head -n 1)" +if [[ -z "${XCTESTRUN_PATH}" ]]; then + append_diagnostic "Failed to locate .xctestrun under ${DERIVED_DATA_PATH}/Build/Products" + exit 1 +fi + +test_cmd=( + xcodebuild + test-without-building + -xctestrun "${XCTESTRUN_PATH}" + -only-testing:TextreamUITests + -destination "platform=macOS,arch=arm64" + -resultBundlePath "${RESULT_BUNDLE_PATH}" +) + +{ + printf '\n== UI test-without-building ==\n' +} | tee -a "${LOG_PATH}" + +set +e +( + cd "${REPO_ROOT}" + "${test_cmd[@]}" +) >> "${LOG_PATH}" 2>&1 & +test_wrapper_pid=$! +runner_monitor_pid="$(start_runner_monitor "${test_wrapper_pid}")" +set -e + +start_epoch="$(date +%s)" +diagnostics_captured=0 + +while kill -0 "${test_wrapper_pid}" >/dev/null 2>&1; do + now_epoch="$(date +%s)" + elapsed="$((now_epoch - start_epoch))" + active_xcodebuild_pid="$(resolve_active_xcodebuild_pid "${test_wrapper_pid}")" + + if (( diagnostics_captured == 0 )) && (( elapsed >= 45 )); then + runner_pid="$(pgrep -x TextreamUITests-Runner | head -n 1 || true)" + capture_ui_diagnostics "${runner_pid}" "${active_xcodebuild_pid}" + diagnostics_captured=1 + fi + + if (( elapsed >= UI_TIMEOUT_SECONDS )); then + runner_pid="$(pgrep -x TextreamUITests-Runner | head -n 1 || true)" + append_diagnostic "UI tests exceeded timeout (${UI_TIMEOUT_SECONDS}s)" + capture_ui_diagnostics "${runner_pid}" "${active_xcodebuild_pid}" + kill "${active_xcodebuild_pid}" >/dev/null 2>&1 || true + kill "${test_wrapper_pid}" >/dev/null 2>&1 || true + if [[ -n "${runner_pid}" ]]; then + kill "${runner_pid}" >/dev/null 2>&1 || true + fi + if [[ -n "${runner_monitor_pid}" ]]; then + kill "${runner_monitor_pid}" >/dev/null 2>&1 || true + wait "${runner_monitor_pid}" >/dev/null 2>&1 || true + fi + cleanup_stale_ui_processes + wait "${test_wrapper_pid}" >/dev/null 2>&1 || true + exit 124 + fi + + sleep "${UI_POLL_SECONDS}" +done + +set +e +wait "${test_wrapper_pid}" +exit_code=$? +set -e + +if [[ -n "${runner_monitor_pid}" ]]; then + kill "${runner_monitor_pid}" >/dev/null 2>&1 || true + wait "${runner_monitor_pid}" >/dev/null 2>&1 || true +fi + +if [[ -d "${RESULT_BUNDLE_PATH}" ]]; then + cp -R "${RESULT_BUNDLE_PATH}" "${ARTIFACT_RESULT_BUNDLE_PATH}" +fi + +if (( exit_code != 0 )); then + runner_pid="$(pgrep -x TextreamUITests-Runner | head -n 1 || true)" + active_xcodebuild_pid="$(resolve_active_xcodebuild_pid "${test_wrapper_pid}")" + append_diagnostic "UI tests exited with code ${exit_code}" + capture_ui_diagnostics "${runner_pid}" "${active_xcodebuild_pid}" +fi + +exit "${exit_code}" diff --git a/scripts/test-unit.sh b/scripts/test-unit.sh new file mode 100755 index 0000000..72d11b5 --- /dev/null +++ b/scripts/test-unit.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-common.sh" + +run_test_layer "unit" "TextreamTests"