diff --git a/packages/mobile-voice/ios/mobilevoice.xcodeproj/project.pbxproj b/packages/mobile-voice/ios/mobilevoice.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..6a2b20c220 --- /dev/null +++ b/packages/mobile-voice/ios/mobilevoice.xcodeproj/project.pbxproj @@ -0,0 +1,578 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 4318840A4939F9117F5CB295 /* libPods-mobilevoice.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D01178681A4FA33E647BDA6 /* libPods-mobilevoice.a */; }; + 5BCC3D8ACE1241D9ADF08705 /* expo.icon in Resources */ = {isa = PBXBuildFile; fileRef = F1C6EB0F46C84143B321E16B /* expo.icon */; }; + 7F4CD5196803F15ED532DB9D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5883CCA53017A900D5BA6B8 /* ExpoModulesProvider.swift */; }; + A9D355AEC99B2C485E3E171E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = BFF46FE16E7CF5862CD6C307 /* PrivacyInfo.xcprivacy */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 13B07F961A680F5B00A75B9A /* mobilevoice.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mobilevoice.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = mobilevoice/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = mobilevoice/Info.plist; sourceTree = ""; }; + 2EF11B4CD2A527AE1396409B /* Pods-mobilevoice.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-mobilevoice.release.xcconfig"; path = "Target Support Files/Pods-mobilevoice/Pods-mobilevoice.release.xcconfig"; sourceTree = ""; }; + 791CA91656B547FFB3774C02 /* Pods-mobilevoice.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-mobilevoice.debug.xcconfig"; path = "Target Support Files/Pods-mobilevoice/Pods-mobilevoice.debug.xcconfig"; sourceTree = ""; }; + 9D01178681A4FA33E647BDA6 /* libPods-mobilevoice.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-mobilevoice.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = mobilevoice/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + BFF46FE16E7CF5862CD6C307 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = mobilevoice/PrivacyInfo.xcprivacy; sourceTree = ""; }; + E5883CCA53017A900D5BA6B8 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-mobilevoice/ExpoModulesProvider.swift"; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = mobilevoice/AppDelegate.swift; sourceTree = ""; }; + F11748442D0722820044C1D9 /* mobilevoice-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "mobilevoice-Bridging-Header.h"; path = "mobilevoice/mobilevoice-Bridging-Header.h"; sourceTree = ""; }; + F1C6EB0F46C84143B321E16B /* expo.icon */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = expo.icon; path = mobilevoice/expo.icon; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4318840A4939F9117F5CB295 /* libPods-mobilevoice.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* mobilevoice */ = { + isa = PBXGroup; + children = ( + F11748412D0307B40044C1D9 /* AppDelegate.swift */, + F11748442D0722820044C1D9 /* mobilevoice-Bridging-Header.h */, + BB2F792B24A3F905000567C9 /* Supporting */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + F1C6EB0F46C84143B321E16B /* expo.icon */, + BFF46FE16E7CF5862CD6C307 /* PrivacyInfo.xcprivacy */, + ); + name = mobilevoice; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 9D01178681A4FA33E647BDA6 /* libPods-mobilevoice.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 6FA8507500F4DE261E8F6EB0 /* Pods */ = { + isa = PBXGroup; + children = ( + 791CA91656B547FFB3774C02 /* Pods-mobilevoice.debug.xcconfig */, + 2EF11B4CD2A527AE1396409B /* Pods-mobilevoice.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* mobilevoice */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + 6FA8507500F4DE261E8F6EB0 /* Pods */, + 8768F75DE2083C7536A3A210 /* ExpoModulesProviders */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* mobilevoice.app */, + ); + name = Products; + sourceTree = ""; + }; + 8768F75DE2083C7536A3A210 /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + C0F0F32FD747A33E1176E0FD /* mobilevoice */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = mobilevoice/Supporting; + sourceTree = ""; + }; + C0F0F32FD747A33E1176E0FD /* mobilevoice */ = { + isa = PBXGroup; + children = ( + E5883CCA53017A900D5BA6B8 /* ExpoModulesProvider.swift */, + ); + name = mobilevoice; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* mobilevoice */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "mobilevoice" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + 92A85735D2EE37AB89277662 /* [Expo] Configure project */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, + B09C32BB889007E7F37BBC9C /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = mobilevoice; + productName = mobilevoice; + productReference = 13B07F961A680F5B00A75B9A /* mobilevoice.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1250; + DevelopmentTeam = "9G68SMNHEU"; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "mobilevoice" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* mobilevoice */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + 5BCC3D8ACE1241D9ADF08705 /* expo.icon in Resources */, + A9D355AEC99B2C485E3E171E /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env", + "$(SRCROOT)/.xcode.env.local", + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-mobilevoice-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoNotifications/ExpoNotifications_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoTaskManager/ExpoTaskManager_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 92A85735D2EE37AB89277662 /* [Expo] Configure project */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env", + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/mobilevoice/mobilevoice.entitlements", + "$(SRCROOT)/Pods/Target Support Files/Pods-mobilevoice/expo-configure-project.sh", + ); + name = "[Expo] Configure project"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Pods/Target Support Files/Pods-mobilevoice/ExpoModulesProvider.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-mobilevoice/expo-configure-project.sh\"\n"; + }; + B09C32BB889007E7F37BBC9C /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-frameworks.sh", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libavcodec.framework/libavcodec", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libavformat.framework/libavformat", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libavutil.framework/libavutil", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/RNAudioAPI/libswresample.framework/libswresample", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermesvm.framework/hermesvm", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/react-native-executorch/ExecutorchLib.framework/ExecutorchLib", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavcodec.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavformat.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavutil.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libswresample.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermesvm.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ExecutorchLib.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-mobilevoice/Pods-mobilevoice-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, + 7F4CD5196803F15ED532DB9D /* ExpoModulesProvider.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 791CA91656B547FFB3774C02 /* Pods-mobilevoice.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = expo; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = mobilevoice/mobilevoice.entitlements; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = mobilevoice/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.anomalyco.mobilevoice; + PRODUCT_NAME = mobilevoice; + SWIFT_OBJC_BRIDGING_HEADER = "mobilevoice/mobilevoice-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + DEVELOPMENT_TEAM = "9G68SMNHEU"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2EF11B4CD2A527AE1396409B /* Pods-mobilevoice.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = expo; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = mobilevoice/mobilevoice.entitlements; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = mobilevoice/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.anomalyco.mobilevoice; + PRODUCT_NAME = mobilevoice; + SWIFT_OBJC_BRIDGING_HEADER = "mobilevoice/mobilevoice-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + DEVELOPMENT_TEAM = "9G68SMNHEU"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + USE_HERMES = true; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "mobilevoice" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "mobilevoice" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/packages/mobile-voice/ios/mobilevoice/Info.plist b/packages/mobile-voice/ios/mobilevoice/Info.plist new file mode 100644 index 0000000000..223acaf0f5 --- /dev/null +++ b/packages/mobile-voice/ios/mobilevoice/Info.plist @@ -0,0 +1,92 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Control + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0.0 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + mobilevoice + com.anomalyco.mobilevoice + + + + CFBundleVersion + 1 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + NSExceptionDomains + + ts.net + + NSIncludesSubdomains + + NSExceptionAllowsInsecureHTTPLoads + + + + + ITSAppUsesNonExemptEncryption + + NSMicrophoneUsageDescription + This app needs microphone access for live speech-to-text dictation. + NSUserActivityTypes + + $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route + + RCTNewArchEnabled + + UIBackgroundModes + + audio + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIUserInterfaceStyle + Automatic + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/mobile-voice/ios/mobilevoice/mobilevoice.entitlements b/packages/mobile-voice/ios/mobilevoice/mobilevoice.entitlements new file mode 100644 index 0000000000..4cf574453e --- /dev/null +++ b/packages/mobile-voice/ios/mobilevoice/mobilevoice.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.kernel.extended-virtual-addressing + + + diff --git a/packages/mobile-voice/src/app/index.tsx b/packages/mobile-voice/src/app/index.tsx index 523489f83e..c11061ac2d 100644 --- a/packages/mobile-voice/src/app/index.tsx +++ b/packages/mobile-voice/src/app/index.tsx @@ -11,6 +11,7 @@ import { LayoutChangeEvent, Linking, Platform, + Switch, } from "react-native" import Animated, { useSharedValue, @@ -34,8 +35,9 @@ import { AudioPcmStreamAdapter } from "whisper.rn/src/realtime-transcription/ada import { AudioManager } from "react-native-audio-api" import * as FileSystem from "expo-file-system/legacy" import { fetch as expoFetch } from "expo/fetch" +import { buildPermissionCardModel } from "@/lib/pending-permissions" import { unregisterRelayDevice } from "@/lib/relay-client" -import { useMonitoring, type MonitorJob } from "@/hooks/use-monitoring" +import { useMonitoring, type MonitorJob, type PermissionDecision } from "@/hooks/use-monitoring" import { looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions" import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications" @@ -77,15 +79,6 @@ const WHISPER_MODELS = [ "ggml-medium-q5_0.bin", "ggml-medium-q8_0.bin", "ggml-medium.bin", - "ggml-large-v1.bin", - "ggml-large-v2-q5_0.bin", - "ggml-large-v2-q8_0.bin", - "ggml-large-v2.bin", - "ggml-large-v3-q5_0.bin", - "ggml-large-v3-turbo-q5_0.bin", - "ggml-large-v3-turbo-q8_0.bin", - "ggml-large-v3-turbo.bin", - "ggml-large-v3.bin", ] as const type WhisperModelID = (typeof WHISPER_MODELS)[number] @@ -119,15 +112,6 @@ const WHISPER_MODEL_LABELS: Record = { "ggml-medium-q5_0.bin": "medium q5_0", "ggml-medium-q8_0.bin": "medium q8_0", "ggml-medium.bin": "medium", - "ggml-large-v1.bin": "large-v1", - "ggml-large-v2-q5_0.bin": "large-v2 q5_0", - "ggml-large-v2-q8_0.bin": "large-v2 q8_0", - "ggml-large-v2.bin": "large-v2", - "ggml-large-v3-q5_0.bin": "large-v3 q5_0", - "ggml-large-v3-turbo-q5_0.bin": "large-v3 turbo q5_0", - "ggml-large-v3-turbo-q8_0.bin": "large-v3 turbo q8_0", - "ggml-large-v3-turbo.bin": "large-v3 turbo", - "ggml-large-v3.bin": "large-v3", } const WHISPER_MODEL_SIZES: Record = { @@ -155,15 +139,6 @@ const WHISPER_MODEL_SIZES: Record = { "ggml-medium-q5_0.bin": 539212467, "ggml-medium-q8_0.bin": 823369779, "ggml-medium.bin": 1533763059, - "ggml-large-v1.bin": 3094623691, - "ggml-large-v2-q5_0.bin": 1080732091, - "ggml-large-v2-q8_0.bin": 1656129691, - "ggml-large-v2.bin": 3094623691, - "ggml-large-v3-q5_0.bin": 1081140203, - "ggml-large-v3-turbo-q5_0.bin": 574041195, - "ggml-large-v3-turbo-q8_0.bin": 874188075, - "ggml-large-v3-turbo.bin": 1624555275, - "ggml-large-v3.bin": 3095033483, } function isWhisperModelID(value: unknown): value is WhisperModelID { @@ -271,6 +246,7 @@ type Scan = { type WhisperSavedState = { defaultModel: WhisperModelID mode: TranscriptionMode + autoSendOnDictationEnd: boolean } type OnboardingSavedState = { @@ -402,6 +378,7 @@ export default function DictationScreen() { const [downloadProgress, setDownloadProgress] = useState(0) const [isPreparingWhisperModel, setIsPreparingWhisperModel] = useState(true) const [transcriptionMode, setTranscriptionMode] = useState(DEFAULT_TRANSCRIPTION_MODE) + const [autoSendOnDictationEnd, setAutoSendOnDictationEnd] = useState(false) const [isTranscribingBulk, setIsTranscribingBulk] = useState(false) const [whisperError, setWhisperError] = useState("") const [transcribedText, setTranscribedText] = useState("") @@ -413,6 +390,7 @@ export default function DictationScreen() { const [agentStateDismissed, setAgentStateDismissed] = useState(false) const [dropdownMode, setDropdownMode] = useState("none") const [dropdownRenderMode, setDropdownRenderMode] = useState>("server") + const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null) const [scanOpen, setScanOpen] = useState(false) const [camGranted, setCamGranted] = useState(false) const [waveformLevels, setWaveformLevels] = useState(Array.from({ length: 24 }, () => 0)) @@ -437,6 +415,7 @@ export default function DictationScreen() { const bulkAudioChunksRef = useRef([]) const bulkTranscriptionJobRef = useRef(0) const downloadProgressRef = useRef(0) + const autoSendSignatureRef = useRef("") const waveformPulseIntervalRef = useRef | null>(null) const sendSettleTimeoutRef = useRef | null>(null) const scanLockRef = useRef(false) @@ -462,15 +441,20 @@ export default function DictationScreen() { selectSession, removeServer, addServer, + createSession, findServerForSession, } = useServerSessions() const { beginMonitoring, + activePermissionRequest, devicePushToken, latestAssistantResponse, monitorJob, monitorStatus, + pendingPermissionCount, + respondingPermissionID, + respondToPermission, setDevicePushToken, setMonitorStatus, } = useMonitoring({ @@ -727,6 +711,7 @@ export default function DictationScreen() { let nextDefaultModel: WhisperModelID = DEFAULT_WHISPER_MODEL let nextMode: TranscriptionMode = DEFAULT_TRANSCRIPTION_MODE + let nextAutoSendOnDictationEnd = false try { const data = await FileSystem.readAsStringAsync(WHISPER_SETTINGS_FILE) if (data) { @@ -737,6 +722,9 @@ export default function DictationScreen() { if (isTranscriptionMode(parsed.mode)) { nextMode = parsed.mode } + if (parsed.autoSendOnDictationEnd === true) { + nextAutoSendOnDictationEnd = true + } } } catch { // Use default settings if state file is missing or invalid. @@ -747,6 +735,7 @@ export default function DictationScreen() { whisperRestoredRef.current = true setDefaultWhisperModel(nextDefaultModel) setTranscriptionMode(nextMode) + setAutoSendOnDictationEnd(nextAutoSendOnDictationEnd) await refreshInstalledWhisperModels() @@ -768,9 +757,13 @@ export default function DictationScreen() { useEffect(() => { if (!whisperRestoredRef.current) return - const payload: WhisperSavedState = { defaultModel: defaultWhisperModel, mode: transcriptionMode } + const payload: WhisperSavedState = { + defaultModel: defaultWhisperModel, + mode: transcriptionMode, + autoSendOnDictationEnd, + } void FileSystem.writeAsStringAsync(WHISPER_SETTINGS_FILE, JSON.stringify(payload)).catch(() => {}) - }, [defaultWhisperModel, transcriptionMode]) + }, [autoSendOnDictationEnd, defaultWhisperModel, transcriptionMode]) useEffect(() => { return () => { @@ -1140,6 +1133,26 @@ export default function DictationScreen() { setAgentStateDismissed(true) }, []) + const handlePermissionDecision = useCallback( + (reply: PermissionDecision) => { + if (!activePermissionRequest || !activeServerId) return + + void Haptics.selectionAsync().catch(() => {}) + void respondToPermission({ + serverID: activeServerId, + sessionID: activePermissionRequest.sessionID, + requestID: activePermissionRequest.id, + reply, + }).catch((error) => { + Alert.alert( + "Could not send decision", + error instanceof Error ? error.message : "OpenCode did not accept that decision.", + ) + }) + }, + [activePermissionRequest, activeServerId, respondToPermission], + ) + const resetTranscriptState = useCallback(() => { if (isRecordingRef.current) { stopRecording() @@ -1454,6 +1467,7 @@ export default function DictationScreen() { const modelDownloading = downloadingModelID !== null const modelLoading = isPreparingWhisperModel || activeWhisperModel == null || modelDownloading || isTranscribingBulk + const dictationSettingsLocked = isRecording || isTranscribingBulk || isSending let modelLoadingState: "downloading" | "loading" | "ready" = "ready" if (modelDownloading) { modelLoadingState = "downloading" @@ -1466,20 +1480,29 @@ export default function DictationScreen() { : WHISPER_MODEL_LABELS[defaultWhisperModel] const hasTranscript = transcribedText.trim().length > 0 const hasAssistantResponse = latestAssistantResponse.trim().length > 0 + const activePermissionCard = activePermissionRequest ? buildPermissionCardModel(activePermissionRequest) : null + const hasPendingPermission = activePermissionRequest !== null && activePermissionCard !== null const hasAgentActivity = hasAssistantResponse || monitorStatus.trim().length > 0 || monitorJob !== null - const shouldShowAgentStateCard = hasAgentActivity && !agentStateDismissed + const shouldShowAgentStateCard = !hasPendingPermission && hasAgentActivity && !agentStateDismissed const showsCompleteState = monitorStatus.toLowerCase().includes("complete") let agentStateIcon: "loading" | "done" = "loading" if (monitorJob === null && (hasAssistantResponse || showsCompleteState)) { agentStateIcon = "done" } const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…" - const shouldShowSend = hasCompletedSession && hasTranscript + const shouldShowSend = hasCompletedSession && hasTranscript && !hasPendingPermission const activeServer = servers.find((s) => s.id === activeServerId) ?? null const activeSession = activeServer?.sessions.find((s) => s.id === activeSessionId) ?? null const canSendToSession = !!activeServer && activeServer.status === "online" && !!activeSession + const isReplyingToActivePermission = + activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id + const displayedTranscript = isSending ? "" : transcribedText const isDropdownOpen = dropdownMode !== "none" const effectiveDropdownMode = isDropdownOpen ? dropdownMode : dropdownRenderMode + const isCreatingSession = sessionCreateMode !== null + const showSessionCreationChoices = + effectiveDropdownMode === "session" && !!activeServer && activeServer.status === "online" + const sessionCreationChoiceCount = showSessionCreationChoices ? (activeSession ? 2 : 1) : 0 const headerTitle = activeServer?.name ?? "No server configured" let headerDotStyle = styles.serverStatusOffline if (activeServer?.status === "online") { @@ -1534,6 +1557,46 @@ export default function DictationScreen() { }) }, [shouldShowSend, sendVisibility]) + useEffect(() => { + const text = transcribedText.trim() + if (!hasCompletedSession || text.length === 0) { + autoSendSignatureRef.current = "" + return + } + + if ( + !autoSendOnDictationEnd || + isRecording || + isTranscribingBulk || + isSending || + hasPendingPermission || + !activeServerId || + !activeSessionId + ) { + return + } + + const signature = `${activeServerId}:${activeSessionId}:${transcriptionMode}:${text}` + if (autoSendSignatureRef.current === signature) { + return + } + + autoSendSignatureRef.current = signature + void handleSendTranscript() + }, [ + activeServerId, + activeSessionId, + autoSendOnDictationEnd, + handleSendTranscript, + hasCompletedSession, + hasPendingPermission, + isRecording, + isSending, + isTranscribingBulk, + transcriptionMode, + transcribedText, + ]) + // Parent clips outer half of center-stroke, so only inner half is visible. // borderWidth 6 → 3px visible inward, borderWidth 12 → 6px visible inward. const animatedBorderStyle = useAnimatedStyle(() => { @@ -1590,8 +1653,15 @@ export default function DictationScreen() { const menuRows = effectiveDropdownMode === "server" ? Math.max(servers.length, 1) : Math.max(activeServer?.sessions.length ?? 0, 1) const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42 - const addServerExtraHeight = effectiveDropdownMode === "server" ? 38 : 8 - const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + addServerExtraHeight + const dropdownFooterExtraHeight = + effectiveDropdownMode === "server" + ? 38 + : sessionCreationChoiceCount === 2 + ? 72 + : sessionCreationChoiceCount === 1 + ? 38 + : 8 + const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + dropdownFooterExtraHeight const animatedHeaderStyle = useAnimatedStyle(() => ({ height: interpolate(serverMenuProgress.value, [0, 1], [51, expandedHeaderHeight], Extrapolation.CLAMP), @@ -1711,6 +1781,49 @@ export default function DictationScreen() { [selectSession], ) + const handleCreateRootSession = useCallback(() => { + if (!activeServer || activeServer.status !== "online" || isCreatingSession) { + return + } + + setSessionCreateMode("root") + void createSession(activeServer.id) + .then((created) => { + if (!created) { + Alert.alert("Could not create session", "Please check that your server is online and try again.") + return + } + + setDropdownMode("none") + }) + .finally(() => { + setSessionCreateMode(null) + }) + }, [activeServer, createSession, isCreatingSession]) + + const handleCreateSessionLikeCurrent = useCallback(() => { + if (!activeServer || activeServer.status !== "online" || !activeSession || isCreatingSession) { + return + } + + setSessionCreateMode("same") + void createSession(activeServer.id, { + directory: activeSession.directory, + workspaceID: activeSession.workspaceID, + }) + .then((created) => { + if (!created) { + Alert.alert("Could not create session", "Please check that your server is online and try again.") + return + } + + setDropdownMode("none") + }) + .finally(() => { + setSessionCreateMode(null) + }) + }, [activeServer, activeSession, createSession, isCreatingSession]) + const handleDeleteServer = useCallback( (id: string) => { const server = serversRef.current.find((s) => s.id === id) @@ -2212,6 +2325,55 @@ export default function DictationScreen() { void handleStartScan()} style={styles.addServerButton}> Add server by scanning QR code + ) : effectiveDropdownMode === "session" && activeServer?.status === "online" ? ( + + {activeSession ? ( + [ + styles.serverRow, + styles.sessionMenuActionRow, + isCreatingSession && styles.sessionMenuActionButtonDisabled, + pressed && styles.clearButtonPressed, + ]} + > + + + + + + {sessionCreateMode === "same" ? "Creating workspace session..." : "New session with workspace"} + + + + ) : null} + + [ + styles.serverRow, + styles.sessionMenuActionRow, + styles.serverRowLast, + isCreatingSession && styles.sessionMenuActionButtonDisabled, + pressed && styles.clearButtonPressed, + ]} + > + + + + + + {sessionCreateMode === "root" ? "Creating new session..." : "New session"} + + + + ) : null} @@ -2219,7 +2381,91 @@ export default function DictationScreen() { {/* Transcription area */} - {shouldShowAgentStateCard ? ( + {hasPendingPermission && activePermissionCard ? ( + + + + + Permission + + {isReplyingToActivePermission + ? monitorStatus || "Sending decision…" + : pendingPermissionCount > 1 + ? `${pendingPermissionCount} requests pending` + : "Action needed"} + + + + + + {activePermissionCard.eyebrow} + {activePermissionCard.title} + {activePermissionCard.body} + + {activePermissionCard.sections.map((section, index) => ( + + {section.label} + + {section.text} + + + ))} + + + + handlePermissionDecision("once")} + disabled={isReplyingToActivePermission} + style={({ pressed }) => [ + styles.permissionPrimaryButton, + isReplyingToActivePermission && styles.permissionActionDisabled, + pressed && styles.clearButtonPressed, + ]} + > + {isReplyingToActivePermission ? ( + + ) : ( + Allow once + )} + + + + {activePermissionRequest.always.length > 0 ? ( + handlePermissionDecision("always")} + disabled={isReplyingToActivePermission} + style={({ pressed }) => [ + styles.permissionSecondaryButton, + isReplyingToActivePermission && styles.permissionActionDisabled, + pressed && styles.clearButtonPressed, + ]} + > + Always allow + + ) : null} + + handlePermissionDecision("reject")} + disabled={isReplyingToActivePermission} + style={({ pressed }) => [ + styles.permissionRejectButton, + activePermissionRequest.always.length === 0 && styles.permissionRejectButtonWide, + isReplyingToActivePermission && styles.permissionActionDisabled, + pressed && styles.clearButtonPressed, + ]} + > + Reject + + + + + ) : shouldShowAgentStateCard ? ( @@ -2282,9 +2528,9 @@ export default function DictationScreen() { onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })} > - {transcribedText ? ( - {transcribedText} - ) : ( + {displayedTranscript ? ( + {displayedTranscript} + ) : isSending ? null : ( Your transcription will appear here… )} @@ -2342,9 +2588,9 @@ export default function DictationScreen() { onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })} > - {transcribedText ? ( - {transcribedText} - ) : ( + {displayedTranscript ? ( + {displayedTranscript} + ) : isSending ? null : ( Your transcription will appear here… )} @@ -2367,60 +2613,61 @@ export default function DictationScreen() { )} - {/* Record button */} - - - - {isTranscribingBulk ? ( - - - - ) : modelLoadingState !== "ready" ? ( - <> - - - - {modelLoadingState === "downloading" - ? `Downloading ${loadingModelLabel} ${pct}%` - : `Loading ${loadingModelLabel}`} - - - - ) : ( - <> - - - - )} - - - - + {hasPendingPermission ? null : ( + [ - styles.sendButton, - (isSending || !hasTranscript || !canSendToSession) && styles.sendButtonDisabled, - pressed && styles.clearButtonPressed, - ]} - disabled={isSending || !hasTranscript || !canSendToSession} - hitSlop={8} + onPressIn={handlePressIn} + onPressOut={handlePressOut} + disabled={!permissionGranted || modelLoading} + style={[styles.recordPressable, !permissionGranted && styles.recordButtonDisabled]} > - + + {isTranscribingBulk ? ( + + + + ) : modelLoadingState !== "ready" ? ( + <> + + + + {modelLoadingState === "downloading" + ? `Downloading ${loadingModelLabel} ${pct}%` + : `Loading ${loadingModelLabel}`} + + + + ) : ( + <> + + + + )} + - - + + + [ + styles.sendButton, + (isSending || !hasTranscript || !canSendToSession) && styles.sendButtonDisabled, + pressed && styles.clearButtonPressed, + ]} + disabled={isSending || !hasTranscript || !canSendToSession} + hitSlop={8} + > + + + + + )} {WHISPER_MODEL_LABELS[defaultWhisperModel]} - setTranscriptionMode("bulk")} - disabled={isRecording || isTranscribingBulk} - style={({ pressed }) => [ - styles.settingsTextRow, - (isRecording || isTranscribingBulk) && styles.settingsInlinePressableDisabled, - pressed && styles.clearButtonPressed, - ]} - > + - On Release - Transcribe after release + Realtime dictation + Turn off to transcribe after release - - {transcriptionMode === "bulk" ? "Selected" : "Use"} - - + setTranscriptionMode(enabled ? "realtime" : "bulk")} + disabled={dictationSettingsLocked} + trackColor={{ false: "#2D2D31", true: "#6A3A33" }} + thumbColor={transcriptionMode === "realtime" ? "#FF6B56" : "#F2F2F2"} + ios_backgroundColor="#2D2D31" + /> + - setTranscriptionMode("realtime")} - disabled={isRecording || isTranscribingBulk} - style={({ pressed }) => [ - styles.settingsTextRow, - (isRecording || isTranscribingBulk) && styles.settingsInlinePressableDisabled, - pressed && styles.clearButtonPressed, - ]} - > + - Realtime - Transcribe while you speak + Auto send on dictation end + Send the transcript as soon as recording finishes - - {transcriptionMode === "realtime" ? "Selected" : "Use"} - - + + MODELS: + + Mobile devices currently support models up to `medium`. + {WHISPER_MODELS.map((modelID) => { const installed = installedWhisperModels.includes(modelID) const isDefault = defaultWhisperModel === modelID @@ -2995,6 +3229,35 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: "600", }, + sessionMenuActions: { + marginTop: 2, + borderTopWidth: 1, + borderTopColor: "#222733", + }, + sessionMenuActionRow: { + paddingVertical: 9, + }, + sessionMenuActionInner: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + sessionMenuActionIconSlot: { + width: 9, + height: 9, + alignItems: "center", + justifyContent: "center", + }, + sessionMenuActionButtonDisabled: { + opacity: 0.55, + }, + sessionMenuActionText: { + flex: 1, + color: "#D6DAE4", + fontSize: 14, + fontWeight: "500", + }, statusLeft: { flexDirection: "row", alignItems: "center", @@ -3060,6 +3323,152 @@ const styles = StyleSheet.create({ replyCard: { paddingTop: 16, }, + permissionCard: { + paddingTop: 16, + }, + permissionHeaderRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + marginHorizontal: 20, + marginBottom: 12, + }, + permissionHeaderCopy: { + flex: 1, + gap: 2, + }, + permissionStatusDot: { + width: 10, + height: 10, + borderRadius: 999, + backgroundColor: "#FFB347", + }, + permissionEyebrow: { + color: "#FFB347", + fontSize: 11, + fontWeight: "800", + letterSpacing: 1.1, + }, + permissionStatusText: { + color: "#9099AA", + fontSize: 13, + fontWeight: "600", + }, + permissionScroll: { + flex: 1, + }, + permissionContent: { + paddingHorizontal: 20, + paddingBottom: 20, + gap: 14, + }, + permissionTitle: { + color: "#F7F8FB", + fontSize: 30, + fontWeight: "800", + lineHeight: 36, + letterSpacing: -0.7, + }, + permissionBody: { + color: "#B2BDCF", + fontSize: 17, + fontWeight: "500", + lineHeight: 24, + }, + permissionSection: { + gap: 6, + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: "#242424", + }, + permissionSectionLast: { + borderBottomWidth: 0, + }, + permissionSectionLabel: { + color: "#7F8798", + fontSize: 11, + fontWeight: "700", + letterSpacing: 0.9, + textTransform: "uppercase", + }, + permissionSectionText: { + color: "#E7E7E7", + fontSize: 14, + fontWeight: "500", + lineHeight: 20, + }, + permissionSectionTextMono: { + fontFamily: Platform.select({ ios: "Menlo", android: "monospace", web: "monospace" }), + fontSize: 12, + lineHeight: 18, + color: "#D4D7DE", + }, + permissionFooter: { + gap: 10, + paddingHorizontal: 20, + paddingBottom: 18, + paddingTop: 8, + borderTopWidth: 1, + borderTopColor: "#21252F", + }, + permissionPrimaryButton: { + minHeight: 54, + borderRadius: 16, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#1D6FF4", + borderWidth: 2, + borderColor: "#1557C3", + paddingHorizontal: 16, + }, + permissionPrimaryButtonText: { + color: "#FFFFFF", + fontSize: 16, + fontWeight: "800", + letterSpacing: 0.2, + }, + permissionSecondaryRow: { + flexDirection: "row", + gap: 10, + }, + permissionSecondaryButton: { + flex: 1, + minHeight: 48, + borderRadius: 14, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#1C1E22", + borderWidth: 1, + borderColor: "#32353D", + paddingHorizontal: 12, + }, + permissionSecondaryButtonText: { + color: "#E0E3EA", + fontSize: 14, + fontWeight: "700", + }, + permissionRejectButton: { + flex: 1, + minHeight: 48, + borderRadius: 14, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#31181C", + borderWidth: 1, + borderColor: "#5E2B34", + paddingHorizontal: 12, + }, + permissionRejectButtonWide: { + flex: 1, + }, + permissionRejectButtonText: { + color: "#FFCCD2", + fontSize: 14, + fontWeight: "700", + }, + permissionActionDisabled: { + opacity: 0.6, + }, transcriptionPanel: { flex: 1, position: "relative", @@ -3282,6 +3691,9 @@ const styles = StyleSheet.create({ borderBottomColor: "#242424", paddingVertical: 10, }, + settingsToggleRow: { + alignItems: "flex-start", + }, settingsMutedText: { color: "#868686", fontSize: 12, @@ -3318,6 +3730,38 @@ const styles = StyleSheet.create({ settingsTextRowActionActive: { color: "#FFD8D2", }, + settingsModeToggle: { + flexDirection: "row", + backgroundColor: "#17181B", + borderWidth: 1, + borderColor: "#292A2E", + borderRadius: 14, + padding: 4, + gap: 4, + alignSelf: "stretch", + }, + settingsModeToggleOption: { + flex: 1, + minHeight: 40, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 12, + }, + settingsModeToggleOptionActive: { + backgroundColor: "#3F201B", + }, + settingsModeToggleOptionPressed: { + opacity: 0.82, + }, + settingsModeToggleText: { + color: "#9A9A9A", + fontSize: 13, + fontWeight: "700", + }, + settingsModeToggleTextActive: { + color: "#FFF0EC", + }, settingsInlineRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/mobile-voice/src/hooks/use-monitoring.ts b/packages/mobile-voice/src/hooks/use-monitoring.ts index febb09df93..5a8694dfbb 100644 --- a/packages/mobile-voice/src/hooks/use-monitoring.ts +++ b/packages/mobile-voice/src/hooks/use-monitoring.ts @@ -21,6 +21,11 @@ import { type OpenCodeEvent, type MonitorEventType, } from "@/lib/opencode-events" +import { + parsePendingPermissionRequest, + parsePendingPermissionRequests, + type PendingPermissionRequest, +} from "@/lib/pending-permissions" import { registerRelayDevice, unregisterRelayDevice } from "@/lib/relay-client" import { parseSSEStream } from "@/lib/sse" import { getDevicePushToken, onPushTokenChange } from "@/notifications/monitoring-notifications" @@ -33,6 +38,8 @@ export type MonitorJob = { startedAt: number } +export type PermissionDecision = "once" | "always" | "reject" + type SessionRuntimeStatus = "idle" | "busy" | "retry" type PermissionPromptState = "idle" | "pending" | "granted" | "denied" @@ -114,6 +121,8 @@ export function useMonitoring({ const [monitorJob, setMonitorJob] = useState(null) const [monitorStatus, setMonitorStatus] = useState("") const [latestAssistantResponse, setLatestAssistantResponse] = useState("") + const [pendingPermissions, setPendingPermissions] = useState([]) + const [replyingPermissionID, setReplyingPermissionID] = useState(null) const [appState, setAppState] = useState(AppState.currentState) const foregroundMonitorAbortRef = useRef(null) @@ -127,6 +136,19 @@ export function useMonitoring({ const previousPushTokenRef = useRef(null) const previousAppStateRef = useRef(AppState.currentState) const latestAssistantRequestRef = useRef(0) + const latestPermissionRequestRef = useRef(0) + + const upsertPendingPermission = useCallback( + (request: PendingPermissionRequest) => { + setPendingPermissions((current) => { + const next = current.filter((item) => item.id !== request.id) + return [request, ...next] + }) + closeDropdown() + setAgentStateDismissed(false) + }, + [closeDropdown, setAgentStateDismissed], + ) useEffect(() => { monitorJobRef.current = monitorJob @@ -243,6 +265,38 @@ export function useMonitoring({ [activeSessionIdRef, setAgentStateDismissed], ) + const loadPendingPermissions = useCallback( + async (baseURL: string, sessionID: string) => { + const requestID = latestPermissionRequestRef.current + 1 + latestPermissionRequestRef.current = requestID + + const base = baseURL.replace(/\/+$/, "") + + try { + const response = await fetch(`${base}/permission`) + if (!response.ok) { + throw new Error(`Permission list failed (${response.status})`) + } + + const payload = (await response.json()) as unknown + const requests = parsePendingPermissionRequests(payload).filter((item) => item.sessionID === sessionID) + + if (latestPermissionRequestRef.current !== requestID) return + if (activeSessionIdRef.current !== sessionID) return + + setPendingPermissions(requests) + if (requests.length > 0) { + closeDropdown() + setAgentStateDismissed(false) + } + } catch { + if (latestPermissionRequestRef.current !== requestID) return + if (activeSessionIdRef.current !== sessionID) return + } + }, + [activeSessionIdRef, closeDropdown, setAgentStateDismissed], + ) + const fetchSessionRuntimeStatus = useCallback( async (baseURL: string, sessionID: string): Promise => { const base = baseURL.replace(/\/+$/, "") @@ -278,6 +332,7 @@ export function useMonitoring({ if (eventType === "permission") { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {}) + void loadPendingPermissions(job.opencodeBaseURL, job.sessionID) return } @@ -295,7 +350,7 @@ export function useMonitoring({ stopForegroundMonitor() setMonitorJob(null) }, - [completePlayer, loadLatestAssistantResponse, stopForegroundMonitor], + [completePlayer, loadLatestAssistantResponse, loadPendingPermissions, stopForegroundMonitor], ) const startForegroundMonitor = useCallback( @@ -333,6 +388,13 @@ export function useMonitoring({ const sessionID = extractSessionID(parsed) if (sessionID !== job.sessionID) continue + if (parsed.type === "permission.asked") { + const request = parsePendingPermissionRequest(parsed.properties) + if (request) { + upsertPendingPermission(request) + } + } + const eventType = classifyMonitorEvent(parsed) if (!eventType) continue @@ -345,7 +407,7 @@ export function useMonitoring({ } })() }, - [handleMonitorEvent, stopForegroundMonitor], + [handleMonitorEvent, stopForegroundMonitor, upsertPendingPermission], ) const beginMonitoring = useCallback( @@ -381,13 +443,22 @@ export function useMonitoring({ useEffect(() => { setLatestAssistantResponse("") + setPendingPermissions([]) setAgentStateDismissed(false) if (!activeServerId || !activeSessionId) return const server = serversRef.current.find((item) => item.id === activeServerId) if (!server || server.status !== "online") return void loadLatestAssistantResponse(server.url, activeSessionId) - }, [activeServerId, activeSessionId, loadLatestAssistantResponse, serversRef, setAgentStateDismissed]) + void loadPendingPermissions(server.url, activeSessionId) + }, [ + activeServerId, + activeSessionId, + loadLatestAssistantResponse, + loadPendingPermissions, + serversRef, + setAgentStateDismissed, + ]) useEffect(() => { return () => { @@ -404,6 +475,7 @@ export function useMonitoring({ const runtimeStatus = await fetchSessionRuntimeStatus(server.url, input.sessionID) await loadLatestAssistantResponse(server.url, input.sessionID) + await loadPendingPermissions(server.url, input.sessionID) if (runtimeStatus === "busy" || runtimeStatus === "retry") { const nextJob: MonitorJob = { @@ -433,6 +505,7 @@ export function useMonitoring({ appState, fetchSessionRuntimeStatus, loadLatestAssistantResponse, + loadPendingPermissions, refreshServerStatusAndSessions, serversRef, startForegroundMonitor, @@ -548,6 +621,62 @@ export function useMonitoring({ void syncSessionState({ serverID, sessionID }) }, [activeServerIdRef, activeSessionIdRef, appState, syncSessionState]) + const respondToPermission = useCallback( + async (input: { serverID: string; sessionID: string; requestID: string; reply: PermissionDecision }) => { + const server = serversRef.current.find((item) => item.id === input.serverID) + if (!server) { + throw new Error("Server unavailable") + } + + const base = server.url.replace(/\/+$/, "") + setReplyingPermissionID(input.requestID) + setMonitorStatus(input.reply === "reject" ? "Rejecting request…" : "Sending approval…") + let removed: PendingPermissionRequest | undefined + setPendingPermissions((current) => { + removed = current.find((item) => item.id === input.requestID) + return current.filter((item) => item.id !== input.requestID) + }) + + try { + const response = await fetch(`${base}/permission/${input.requestID}/reply`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ reply: input.reply }), + }) + + if (!response.ok) { + throw new Error(`Permission reply failed (${response.status})`) + } + + await syncSessionState({ + serverID: input.serverID, + sessionID: input.sessionID, + }) + } catch (error) { + if (removed) { + setPendingPermissions((current) => { + const restored = removed + if (!restored) { + return current + } + if (current.some((item) => item.id === restored.id)) { + return current + } + return [restored, ...current] + }) + } + throw error + } finally { + setReplyingPermissionID((current) => (current === input.requestID ? null : current)) + } + }, + [serversRef, syncSessionState], + ) + + const activePermissionRequest = pendingPermissions[0] ?? null + const relayServersKey = useMemo( () => servers @@ -658,6 +787,10 @@ export function useMonitoring({ monitorStatus, setMonitorStatus, latestAssistantResponse, + activePermissionRequest, + pendingPermissionCount: pendingPermissions.length, + respondingPermissionID: replyingPermissionID, + respondToPermission, beginMonitoring, } } diff --git a/packages/mobile-voice/src/hooks/use-server-sessions.ts b/packages/mobile-voice/src/hooks/use-server-sessions.ts index 609ea1f25b..f237b17526 100644 --- a/packages/mobile-voice/src/hooks/use-server-sessions.ts +++ b/packages/mobile-voice/src/hooks/use-server-sessions.ts @@ -320,6 +320,113 @@ export function useServerSessions() { [refreshServerStatusAndSessions], ) + const createSession = useCallback( + async ( + serverID: string, + options?: { + directory?: string + workspaceID?: string + title?: string + }, + ) => { + const server = serversRef.current.find((item) => item.id === serverID) + if (!server) { + return null + } + + const base = server.url.replace(/\/+$/, "") + const params = new URLSearchParams() + const directory = options?.directory?.trim() + const workspaceID = options?.workspaceID?.trim() + const title = options?.title?.trim() + + if (directory) { + params.set("directory", directory) + } + + const body: { + workspaceID?: string + title?: string + } = {} + + if (workspaceID) { + body.workspaceID = workspaceID + } + + if (title) { + body.title = title + } + + const query = params.toString() + const endpoint = `${base}/session${query ? `?${query}` : ""}` + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + console.log("[Server] session:create:http-error", { + id: server.id, + endpoint, + status: response.status, + }) + return null + } + + const payload = (await response.json()) as unknown + const parsed = parseSessionItems([payload])[0] + + if (!parsed) { + void refreshServerStatusAndSessions(serverID) + return null + } + + const created = parsed.updated > 0 ? parsed : { ...parsed, updated: Date.now() } + + setServers((prev) => + prev.map((item) => { + if (item.id !== serverID) return item + + const sessions = [created, ...item.sessions.filter((session) => session.id !== created.id)].sort( + (a, b) => b.updated - a.updated, + ) + + return { + ...item, + status: "online", + sessionsLoading: false, + sessions, + } + }), + ) + setActiveServerId(serverID) + setActiveSessionId(created.id) + + console.log("[Server] session:create", { + id: server.id, + sessionID: created.id, + hasDirectory: Boolean(created.directory), + hasWorkspaceID: Boolean(created.workspaceID), + }) + + return created + } catch (err) { + console.log("[Server] session:create:error", { + id: server.id, + endpoint, + error: err instanceof Error ? `${err.name}: ${err.message}` : String(err), + }) + return null + } + }, + [refreshServerStatusAndSessions], + ) + const findServerForSession = useCallback( async (sessionID: string, preferredServerID?: string | null): Promise => { if (!serversRef.current.length && !restoredRef.current) { @@ -381,6 +488,7 @@ export function useServerSessions() { selectSession, removeServer, addServer, + createSession, findServerForSession, } } diff --git a/packages/mobile-voice/src/lib/opencode-events.ts b/packages/mobile-voice/src/lib/opencode-events.ts index 10de4cfee7..df4e34a67c 100644 --- a/packages/mobile-voice/src/lib/opencode-events.ts +++ b/packages/mobile-voice/src/lib/opencode-events.ts @@ -1,76 +1,81 @@ export type OpenCodeEvent = { - type: string; - properties?: Record; -}; + type: string + properties?: Record +} -export type MonitorEventType = 'complete' | 'permission' | 'error'; +export type MonitorEventType = "complete" | "permission" | "error" export function extractSessionID(event: OpenCodeEvent): string | null { - const props = event.properties ?? {}; + const props = event.properties ?? {} - const fromDirect = props.sessionID; - if (typeof fromDirect === 'string' && fromDirect.length > 0) return fromDirect; + const fromDirect = props.sessionID + if (typeof fromDirect === "string" && fromDirect.length > 0) return fromDirect - const info = props.info; - if (info && typeof info === 'object') { - const infoSessionID = (info as Record).sessionID; - if (typeof infoSessionID === 'string' && infoSessionID.length > 0) return infoSessionID; + const info = props.info + if (info && typeof info === "object") { + const infoSessionID = (info as Record).sessionID + if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID } - const part = props.part; - if (part && typeof part === 'object') { - const partSessionID = (part as Record).sessionID; - if (typeof partSessionID === 'string' && partSessionID.length > 0) return partSessionID; + const part = props.part + if (part && typeof part === "object") { + const partSessionID = (part as Record).sessionID + if (typeof partSessionID === "string" && partSessionID.length > 0) return partSessionID } - return null; + return null } export function classifyMonitorEvent(event: OpenCodeEvent): MonitorEventType | null { - const type = event.type; - const lowerType = type.toLowerCase(); + const type = event.type + const lowerType = type.toLowerCase() - if (lowerType.includes('permission')) { - return 'permission'; + if (lowerType === "permission.asked" || lowerType === "permission") { + return "permission" } - if (lowerType.includes('error')) { - return 'error'; + if (lowerType.includes("error")) { + return "error" } - if (type === 'session.status') { - const status = event.properties?.status; - if (status && typeof status === 'object') { - const statusType = (status as Record).type; - if (statusType === 'idle') { - return 'complete'; + if (type === "session.status") { + const status = event.properties?.status + if (status && typeof status === "object") { + const statusType = (status as Record).type + if (statusType === "idle") { + return "complete" } } } - if (type === 'message.updated') { - const info = event.properties?.info; - if (info && typeof info === 'object') { - const role = (info as Record).role; - const time = (info as Record).time; - if (role === 'assistant' && time && typeof time === 'object' && 'completed' in (time as Record)) { - return 'complete'; + if (type === "message.updated") { + const info = event.properties?.info + if (info && typeof info === "object") { + const role = (info as Record).role + const time = (info as Record).time + if ( + role === "assistant" && + time && + typeof time === "object" && + "completed" in (time as Record) + ) { + return "complete" } } } - return null; + return null } export function formatMonitorEventLabel(eventType: MonitorEventType): string { switch (eventType) { - case 'complete': - return 'Session complete'; - case 'permission': - return 'Action needed'; - case 'error': - return 'Session error'; + case "complete": + return "Session complete" + case "permission": + return "Action needed" + case "error": + return "Session error" default: - return 'Session update'; + return "Session update" } } diff --git a/packages/mobile-voice/src/lib/pending-permissions.ts b/packages/mobile-voice/src/lib/pending-permissions.ts new file mode 100644 index 0000000000..5d1acc03c0 --- /dev/null +++ b/packages/mobile-voice/src/lib/pending-permissions.ts @@ -0,0 +1,256 @@ +export type PendingPermissionRequest = { + id: string + sessionID: string + permission: string + patterns: string[] + metadata: Record + always: string[] + tool?: { + messageID: string + callID: string + } +} + +export type PermissionCardSection = { + label: string + text: string + mono?: boolean +} + +export type PermissionCardModel = { + eyebrow: string + title: string + body: string + sections: PermissionCardSection[] +} + +function record(input: unknown): Record | null { + if (!input || typeof input !== "object") return null + return input as Record +} + +function maybeString(input: unknown): string | undefined { + return typeof input === "string" && input.trim().length > 0 ? input : undefined +} + +function stringList(input: unknown): string[] { + if (!Array.isArray(input)) return [] + return input.filter((item): item is string => typeof item === "string" && item.length > 0) +} + +function previewText(input: string, options?: { maxLines?: number; maxChars?: number }): string { + const maxLines = options?.maxLines ?? 18 + const maxChars = options?.maxChars ?? 1200 + const normalized = input.replace(/\r\n/g, "\n").trim() + if (!normalized) return "" + + const lines = normalized.split("\n") + const sliced = lines.slice(0, maxLines) + let text = sliced.join("\n") + + if (text.length > maxChars) { + text = `${text.slice(0, maxChars).trimEnd()}\n…` + } else if (lines.length > maxLines) { + text = `${text}\n…` + } + + return text +} + +function formatPath(input: string): string { + return input.replace(/\\/g, "/") +} + +function permissionTool(input: unknown): PendingPermissionRequest["tool"] | undefined { + const value = record(input) + if (!value) return + + const messageID = maybeString(value.messageID) + const callID = maybeString(value.callID) + if (!messageID || !callID) return + + return { + messageID, + callID, + } +} + +function parsePendingPermissionRequest(input: unknown): PendingPermissionRequest | null { + const value = record(input) + if (!value) return null + + const id = maybeString(value.id) + const sessionID = maybeString(value.sessionID) + const permission = maybeString(value.permission) + if (!id || !sessionID || !permission) return null + + return { + id, + sessionID, + permission, + patterns: stringList(value.patterns), + metadata: record(value.metadata) ?? {}, + always: stringList(value.always), + tool: permissionTool(value.tool), + } +} + +export { parsePendingPermissionRequest } + +export function parsePendingPermissionRequests(payload: unknown): PendingPermissionRequest[] { + if (!Array.isArray(payload)) return [] + + return payload + .map((item) => parsePendingPermissionRequest(item)) + .filter((item): item is PendingPermissionRequest => item !== null) +} + +function firstPattern(request: PendingPermissionRequest): string | undefined { + return request.patterns.find((item) => item.trim().length > 0) +} + +function externalDirectory(request: PendingPermissionRequest): string | undefined { + const fromMetadata = maybeString(request.metadata.parentDir) ?? maybeString(request.metadata.filepath) + if (fromMetadata) return fromMetadata + + const pattern = firstPattern(request) + if (!pattern) return + return pattern.endsWith("/*") ? pattern.slice(0, -2) : pattern +} + +function allowScopeSection(request: PendingPermissionRequest): PermissionCardSection | null { + if (request.always.length === 0) return null + if ( + request.always.length === request.patterns.length && + request.always.every((item, index) => item === request.patterns[index]) + ) { + return null + } + if (request.always.length === 1 && request.always[0] === "*") { + return { + label: "Always allow", + text: "Applies to all future requests of this permission until OpenCode restarts.", + } + } + + return { + label: "Always allow scope", + text: previewText(request.always.join("\n"), { maxLines: 8, maxChars: 600 }), + mono: true, + } +} + +export function buildPermissionCardModel(request: PendingPermissionRequest): PermissionCardModel { + const filepath = maybeString(request.metadata.filepath) + const diff = maybeString(request.metadata.diff) + const commandText = previewText(request.patterns.join("\n"), { maxLines: 6, maxChars: 700 }) + const scope = allowScopeSection(request) + + if (request.permission === "edit") { + const sections: PermissionCardSection[] = [] + if (filepath) { + sections.push({ label: "File", text: formatPath(filepath), mono: true }) + } + if (diff) { + sections.push({ label: "Diff preview", text: previewText(diff), mono: true }) + } + if (scope) sections.push(scope) + + return { + eyebrow: "EDIT", + title: "Allow file edit?", + body: "OpenCode wants to change a file in this session.", + sections, + } + } + + if (request.permission === "bash") { + const sections: PermissionCardSection[] = [] + if (commandText) { + sections.push({ label: "Command", text: commandText, mono: true }) + } + if (scope) sections.push(scope) + + return { + eyebrow: "BASH", + title: "Allow shell command?", + body: "OpenCode wants to run a shell command for this session.", + sections, + } + } + + if (request.permission === "read") { + const sections: PermissionCardSection[] = [] + const path = firstPattern(request) + if (path) { + sections.push({ label: "Path", text: formatPath(path), mono: true }) + } + if (scope) sections.push(scope) + + return { + eyebrow: "READ", + title: "Allow file read?", + body: "OpenCode wants to read a path from your machine.", + sections, + } + } + + if (request.permission === "external_directory") { + const sections: PermissionCardSection[] = [] + const dir = externalDirectory(request) + if (dir) { + sections.push({ label: "Directory", text: formatPath(dir), mono: true }) + } + if (request.patterns.length > 0) { + sections.push({ + label: "Patterns", + text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }), + mono: true, + }) + } + if (scope) sections.push(scope) + + return { + eyebrow: "DIRECTORY", + title: "Allow external access?", + body: "OpenCode wants to work with files outside the current project directory.", + sections, + } + } + + if (request.permission === "task") { + const sections: PermissionCardSection[] = [] + if (request.patterns.length > 0) { + sections.push({ + label: "Patterns", + text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }), + mono: true, + }) + } + if (scope) sections.push(scope) + + return { + eyebrow: "TASK", + title: "Allow delegated task?", + body: "OpenCode wants to launch another task as part of this session.", + sections, + } + } + + const sections: PermissionCardSection[] = [] + if (request.patterns.length > 0) { + sections.push({ + label: "Patterns", + text: previewText(request.patterns.join("\n"), { maxLines: 8, maxChars: 600 }), + mono: true, + }) + } + if (scope) sections.push(scope) + + return { + eyebrow: request.permission.toUpperCase(), + title: `Allow ${request.permission}?`, + body: "OpenCode needs your permission before it can continue this session.", + sections, + } +} diff --git a/packages/mobile-voice/src/lib/server-sessions.ts b/packages/mobile-voice/src/lib/server-sessions.ts index 82ee407c4f..4ad426c736 100644 --- a/packages/mobile-voice/src/lib/server-sessions.ts +++ b/packages/mobile-voice/src/lib/server-sessions.ts @@ -8,11 +8,17 @@ export type SessionItem = { id: string title: string updated: number + directory?: string + workspaceID?: string + projectID?: string } type ServerSessionPayload = { id?: unknown title?: unknown + directory?: unknown + workspaceID?: unknown + projectID?: unknown time?: { updated?: unknown } @@ -50,11 +56,21 @@ export function parseSessionItems(payload: unknown): SessionItem[] { return payload .filter((item): item is ServerSessionPayload => !!item && typeof item === "object") - .map((item) => ({ - id: String(item.id ?? ""), - title: String(item.title ?? item.id ?? "Untitled session"), - updated: Number(item.time?.updated ?? 0), - })) + .map((item) => { + const directory = typeof item.directory === "string" && item.directory.length > 0 ? item.directory : undefined + const workspaceID = + typeof item.workspaceID === "string" && item.workspaceID.length > 0 ? item.workspaceID : undefined + const projectID = typeof item.projectID === "string" && item.projectID.length > 0 ? item.projectID : undefined + + return { + id: String(item.id ?? ""), + title: String(item.title ?? item.id ?? "Untitled session"), + updated: Number(item.time?.updated ?? 0), + directory, + workspaceID, + projectID, + } + }) .filter((item) => item.id.length > 0) .sort((a, b) => b.updated - a.updated) }