diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 654da64..51eea2d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -20,6 +20,7 @@ jobs: run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh - name: Script syntax run: | + sh -n ui/local-bin/ssh for f in scripts/*.sh frame/*/*.sh; do case "$(head -n 1 "$f")" in *zsh*) zsh -n "$f" ;; @@ -28,7 +29,7 @@ jobs: done - name: Python compiles run: | - python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py + python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py ios/scripts/*.py # Valve's devkit-utils (vendored; run by the Frame's python3). Most have no .py suffix. python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py - name: Server tests @@ -57,3 +58,16 @@ jobs: python-version: ${{ matrix.python }} - name: Server tests run: python -m unittest discover -s tests -v + + # The iPhone app: builds for the Simulator and runs its unit tests. + ios: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Generate the project + run: brew install xcodegen && cd ios && xcodegen generate + - name: Build and test + run: | + cd ios + udid=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for r in d for x in d[r] if x["name"].startswith("iPhone")))') + xcodebuild -project FrameControl.xcodeproj -scheme FrameControl -destination "platform=iOS Simulator,id=$udid" CODE_SIGNING_ALLOWED=NO test diff --git a/README.md b/README.md index 072790d..089606c 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,9 @@ already ships (sideloading a game copies Valve's own devkit scripts to | **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) | | **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) | +**iPhone and iPad:** the same features from your phone, with nothing to install on +a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md). + The app brings its own Python and `adb`; SSH is built into macOS and Windows. Google doesn't publish `adb` for arm64 Linux, so that build uses your distribution's. If you already have `adb`, the app uses yours. diff --git a/docs/iphone.md b/docs/iphone.md new file mode 100644 index 0000000..cc60d6a --- /dev/null +++ b/docs/iphone.md @@ -0,0 +1,73 @@ +# Frame Control for iPhone + +The iPhone (and iPad) app does what the desktop app does, from the phone: +headset view and live video, battery and status, screenshots, Steam games, +Android apps and their display settings, sideloading, files, clipboard, +Flatpaks, and power. Source: [`ios/`](../ios). + +## How it works + +An iPhone can't run Python or `ssh`, but the Frame can. So the app: + +1. connects to the Frame over SSH itself (the [Citadel](https://github.com/orlandos-nl/Citadel) + Swift SSH library), with its own ed25519 key from the Keychain; +2. copies Frame Control's server and helpers (`ios/scripts/make_frame_bundle.py`, + under 1 MB) to `~/.cache/frame-control/` on the Frame, once per version; +3. starts `ui/server.py` there with `FRAME_LOCAL=1`. It listens only on the + Frame's own 127.0.0.1, and it stops when the phone disconnects (`--exit-on-eof`); +4. tunnels to it through the SSH session and shows the same page as the desktop + app, in a web view. The page carries a fresh key each session, which the + server requires on every request. + +With `FRAME_LOCAL=1`, every `ssh frame COMMAND` the server runs goes to +`ui/local-bin/ssh`, which runs the command on the Frame directly (rsync uses it +as its transport too), so the desktop and phone share one code path. Android +display settings use `podman exec` into each Lepton container instead of adb, +which the Frame doesn't have. + +Nothing is left running on the Frame after the phone disconnects; the copied +files stay in `~/.cache/frame-control` (delete it any time). + +## Pairing + +On the Frame, turn on Developer Mode and set a user password (Steam Settings → +System, then Developer → Set User Password). In the app, enter the headset's +address (`frame.local`, its IP, or its Tailscale name) and that password once. +The app adds its own key to `~/.ssh/authorized_keys` and remembers the Frame's +host key; the password isn't saved. If you already reach the Frame over SSH, +**Or add the key yourself** shows the phone's key to paste into +`authorized_keys`, and connects without a password. + +Valve's tap-to-approve devkit pairing isn't used: it only takes RSA keys, and +the Frame's OpenSSH 9.7 rejects the SHA-1 RSA signatures the Swift SSH library +makes. + +## What's different on the phone + +| Desktop | iPhone | +|---|---| +| Drop files anywhere | Tap **Send to Frame** (or Add a game) and pick files; folders need zipping | +| Screenshots save to `~/Pictures/SteamFrame` | Save opens the share sheet: Save Image puts it in Photos | +| SSH and SFTP open a terminal | They open an app that handles `ssh://` / `sftp://` (Blink Shell, Termius) | +| Steam Link, remote desktop | Open the Steam Link and Windows App apps | +| Sleep, restart, shut down ask in a terminal | The page asks for the Developer Mode password | +| Compatibility reports kept on the computer | Kept on the Frame (`~/.local/share/Frame Control`) | + +## Building + +```sh +cd ios +xcodegen generate # after changing project.yml +open FrameControl.xcodeproj +``` + +The build packs the Frame bundle from the checkout, so the phone always runs +the page and server from the same commit. Running on a phone needs your own +signing team in Xcode (Signing & Capabilities). + +## Verified + +In the iOS Simulator (iOS 26.5) and against a Frame (2026-09-27): the page and +all its reads, headset capture, live video, and file upload through the +on-Frame server. Not yet exercised: pairing with the password, Android display +changes through podman, and power actions. diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..aa95f19 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,4 @@ +xcuserdata/ +*.xcuserstate +build/ +DerivedData/ diff --git a/ios/FrameControl.xcodeproj/project.pbxproj b/ios/FrameControl.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c3de927 --- /dev/null +++ b/ios/FrameControl.xcodeproj/project.pbxproj @@ -0,0 +1,535 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; }; + 12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; }; + 1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; }; + 41A697B9924018DA48F24A1F /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237D9AF04EEA257AB382F60E /* Keys.swift */; }; + 4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */; }; + 476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */; }; + 765661DBC0E6798A27CC60DB /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24E1BCD4F69A24C7DEF02F /* RootView.swift */; }; + 78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; }; + 84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; }; + 9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; }; + A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; }; + DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; }; + F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + E1823E86AC0698172B566DB0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 72E728699F904E68DEC369D3 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1015B8BE90EB02C2062752A1; + remoteInfo = FrameControl; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = ""; }; + 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = ""; }; + 237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = ""; }; + 6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlApp.swift; sourceTree = ""; }; + 9B24E1BCD4F69A24C7DEF02F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortForwarder.swift; sourceTree = ""; }; + BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = ""; }; + D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = ""; }; + DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; + EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = ""; }; + F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 35C098707058D19A2E23092E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1518C8775325C731AD7E2421 = { + isa = PBXGroup; + children = ( + B4F84A5777E9EFEAEB54DB91 /* FrameControl */, + 75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */, + 59B34B34E2BE8BCD237BCF26 /* Products */, + ); + sourceTree = ""; + }; + 5064A5B6FE5B17B18E5FA4B8 /* SSH */ = { + isa = PBXGroup; + children = ( + 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */, + EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */, + 237D9AF04EEA257AB382F60E /* Keys.swift */, + A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */, + ); + path = SSH; + sourceTree = ""; + }; + 59B34B34E2BE8BCD237BCF26 /* Products */ = { + isa = PBXGroup; + children = ( + F3E2F5607DD877272483D64E /* FrameControl.app */, + 6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 68AF00C8593B71502E1FB72B /* App */ = { + isa = PBXGroup; + children = ( + 8A11F3431826A26B247C0695 /* AppModel.swift */, + 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */, + BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */, + ); + path = App; + sourceTree = ""; + }; + 75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = { + isa = PBXGroup; + children = ( + 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */, + ); + path = FrameControlTests; + sourceTree = ""; + }; + A939D1267299AE8A48092557 /* Views */ = { + isa = PBXGroup; + children = ( + 9B24E1BCD4F69A24C7DEF02F /* RootView.swift */, + DB544223FC60A59CC3E8EF5F /* SetupView.swift */, + ); + path = Views; + sourceTree = ""; + }; + B4F84A5777E9EFEAEB54DB91 /* FrameControl */ = { + isa = PBXGroup; + children = ( + 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */, + 6B5B6718C5EA77FA67F6B14C /* Info.plist */, + 68AF00C8593B71502E1FB72B /* App */, + 5064A5B6FE5B17B18E5FA4B8 /* SSH */, + A939D1267299AE8A48092557 /* Views */, + CC25EAB6C385A68D63F7DDF7 /* Web */, + ); + path = FrameControl; + sourceTree = ""; + }; + CC25EAB6C385A68D63F7DDF7 /* Web */ = { + isa = PBXGroup; + children = ( + D6C4E6C28315CA8729FCAAEA /* WebShell.swift */, + ); + path = Web; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1015B8BE90EB02C2062752A1 /* FrameControl */ = { + isa = PBXNativeTarget; + buildConfigurationList = F08406CA3118DCFFD91EEA4D /* Build configuration list for PBXNativeTarget "FrameControl" */; + buildPhases = ( + 81ACE79C878CE95DC2C74A8B /* Pack the Frame bundle */, + D452F3AE39D323226E2E4D1D /* Sources */, + B6E396EEA6D8BB0EE84E01A4 /* Resources */, + 35C098707058D19A2E23092E /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = FrameControl; + packageProductDependencies = ( + 6BA549B6CC0A0CB847126456 /* Citadel */, + ); + productName = FrameControl; + productReference = F3E2F5607DD877272483D64E /* FrameControl.app */; + productType = "com.apple.product-type.application"; + }; + 88565F33E966FD0BAC7AC9C8 /* FrameControlTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3EE44365AF181B5C85B38B07 /* Build configuration list for PBXNativeTarget "FrameControlTests" */; + buildPhases = ( + F220B2041FE675A075E860BB /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + B88C5AA6F25947DCEE51C178 /* PBXTargetDependency */, + ); + name = FrameControlTests; + packageProductDependencies = ( + ); + productName = FrameControlTests; + productReference = 6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 72E728699F904E68DEC369D3 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + }; + }; + buildConfigurationList = D6217CB1638429ED91524BB3 /* Build configuration list for PBXProject "FrameControl" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 1518C8775325C731AD7E2421; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 59B34B34E2BE8BCD237BCF26 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1015B8BE90EB02C2062752A1 /* FrameControl */, + 88565F33E966FD0BAC7AC9C8 /* FrameControlTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B6E396EEA6D8BB0EE84E01A4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 84423CB45629465420180A64 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 81ACE79C878CE95DC2C74A8B /* Pack the Frame bundle */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Pack the Frame bundle"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "python3 \"${SRCROOT}/scripts/make_frame_bundle.py\" \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\" > \"${DERIVED_FILE_DIR}/frame-bundle.version\"\nmkdir -p \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\ncp \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\" \"${DERIVED_FILE_DIR}/frame-bundle.version\" \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + D452F3AE39D323226E2E4D1D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */, + 78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */, + 12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */, + 0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */, + 4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */, + 41A697B9924018DA48F24A1F /* Keys.swift in Sources */, + 476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */, + 765661DBC0E6798A27CC60DB /* RootView.swift in Sources */, + 9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */, + 1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F220B2041FE675A075E860BB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + B88C5AA6F25947DCEE51C178 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1015B8BE90EB02C2062752A1 /* FrameControl */; + targetProxy = E1823E86AC0698172B566DB0 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 0B43879551190738EFF21848 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FrameControl/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.saphid.framecontrol; + PRODUCT_NAME = "Frame Control"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 3228B6B229BF6430C8338B55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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_DOCUMENTATION_COMMENTS = YES; + 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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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 = 17.0; + MARKETING_VERSION = 0.1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 54BEF779B5906F671E4134CE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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_DOCUMENTATION_COMMENTS = YES; + 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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + 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 = 17.0; + MARKETING_VERSION = 0.1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 57A1F4BD520A2EDA424181E8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.saphid.FrameControlTests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control"; + }; + name = Release; + }; + 6E69BB8A560DC32B8D0E10A6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.saphid.FrameControlTests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control"; + }; + name = Debug; + }; + C7FCE7EB18B4AEF8EFEC8FDE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FrameControl/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.saphid.framecontrol; + PRODUCT_NAME = "Frame Control"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 3EE44365AF181B5C85B38B07 /* Build configuration list for PBXNativeTarget "FrameControlTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6E69BB8A560DC32B8D0E10A6 /* Debug */, + 57A1F4BD520A2EDA424181E8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + D6217CB1638429ED91524BB3 /* Build configuration list for PBXProject "FrameControl" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 54BEF779B5906F671E4134CE /* Debug */, + 3228B6B229BF6430C8338B55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + F08406CA3118DCFFD91EEA4D /* Build configuration list for PBXNativeTarget "FrameControl" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C7FCE7EB18B4AEF8EFEC8FDE /* Debug */, + 0B43879551190738EFF21848 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/orlandos-nl/Citadel.git"; + requirement = { + kind = exactVersion; + version = 0.12.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 6BA549B6CC0A0CB847126456 /* Citadel */ = { + isa = XCSwiftPackageProductDependency; + package = AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */; + productName = Citadel; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 72E728699F904E68DEC369D3 /* Project object */; +} diff --git a/ios/FrameControl.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/FrameControl.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/FrameControl.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/FrameControl.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/FrameControl.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..0cd5935 --- /dev/null +++ b/ios/FrameControl.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,96 @@ +{ + "originHash" : "06e1233a9a9b220c5f5b14eefc3220aa9e394ac504fece9df28b2a550b7d6017", + "pins" : [ + { + "identity" : "bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/attaswift/BigInt.git", + "state" : { + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", + "version" : "5.7.0" + } + }, + { + "identity" : "citadel", + "kind" : "remoteSourceControl", + "location" : "https://github.com/orlandos-nl/Citadel.git", + "state" : { + "revision" : "ae8562f895de06ccb86fdb1cbb65fd99c8976e12", + "version" : "0.12.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "98ef3c98609a1e31b7e157b5b619579001a789d6", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb", + "version" : "1.15.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" + } + }, + { + "identity" : "swift-nio-ssh", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Wellz26/swift-nio-ssh.git", + "state" : { + "revision" : "d88989f3d3bb1dfb2a38ce4af598afbf7fc3095c", + "version" : "0.3.7" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + } + ], + "version" : 3 +} diff --git a/ios/FrameControl.xcodeproj/xcshareddata/xcschemes/FrameControl.xcscheme b/ios/FrameControl.xcodeproj/xcshareddata/xcschemes/FrameControl.xcscheme new file mode 100644 index 0000000..7544a28 --- /dev/null +++ b/ios/FrameControl.xcodeproj/xcshareddata/xcschemes/FrameControl.xcscheme @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/FrameControl/App/AppModel.swift b/ios/FrameControl/App/AppModel.swift new file mode 100644 index 0000000..323ff28 --- /dev/null +++ b/ios/FrameControl/App/AppModel.swift @@ -0,0 +1,212 @@ +import Citadel +import Foundation +import SwiftUI +import UIKit + +/// The app's one piece of state: which headset, and how far along connecting to it is. +@MainActor +final class AppModel: ObservableObject { + enum Phase: Equatable { + case setup + case connecting(String) + case ready(URL) + case failed(String) + } + + @Published private(set) var phase: Phase + @Published private(set) var settings: FrameSettings? + /// Install links that arrived before the page was ready for them. + @Published var pendingInstallLinks: [InstallLink] = [] + + private var link: FrameLink? + private var server: HeadsetServer? + private var forwarder: PortForwarder? + private var attempt = 0 + + private static let settingsKey = "frame.settings" + private static let hostKeyKey = "frame.hostKey" + + init() { + let saved = UserDefaults.standard.data(forKey: Self.settingsKey).flatMap { try? JSONDecoder().decode(FrameSettings.self, from: $0) } + settings = saved + phase = saved == nil ? .setup : .connecting("Connecting") + } + + var deviceName: String { UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone" } + private var hostKey: String? { UserDefaults.standard.string(forKey: Self.hostKeyKey) } + + // MARK: pairing + + /// First time: log in with the Developer Mode password, add this phone's key to + /// ~/.ssh/authorized_keys, record the Frame's host key, then connect with the key. + func pair(host: String, user: String, password: String) async { + guard let target = Self.parse(host: host, user: user) else { + phase = .failed("Enter the headset's address and user name.") + return + } + await teardown() + attempt += 1 + let mine = attempt + phase = .connecting("Signing in to \(target.host)") + let pin = PinnedHostKey(expected: nil) + do { + let link = try await FrameLink.connect(target, auth: .passwordBased(username: target.user, password: password), hostKey: pin) + defer { Task { await link.close() } } + guard mine == attempt else { return } + phase = .connecting("Adding this \(deviceName)'s key") + let line = authorizedKeysLine + try await link.check("umask 077; mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys && " + + "(grep -qxF \(shellQuote(line)) ~/.ssh/authorized_keys || echo \(shellQuote(line)) >> ~/.ssh/authorized_keys)", + "Couldn't add the key on the Frame") + guard let seen = pin.seen else { throw FrameFailure("The Frame didn't show a host key") } + UserDefaults.standard.set(seen, forKey: Self.hostKeyKey) + UserDefaults.standard.set(try JSONEncoder().encode(target), forKey: Self.settingsKey) + settings = target + } catch { + guard mine == attempt else { return } + phase = .failed((error as? FrameFailure)?.message ?? FrameLink.describe(error, host: target.host)) + return + } + await connect() + } + + /// For someone who added this phone's key to the Frame themselves: no password. + /// The Frame's host key is recorded on this first connection. + func useKey(host: String, user: String) async { + guard let target = Self.parse(host: host, user: user) else { + phase = .failed("Enter the headset's address and user name.") + return + } + UserDefaults.standard.removeObject(forKey: Self.hostKeyKey) + UserDefaults.standard.set(try? JSONEncoder().encode(target), forKey: Self.settingsKey) + settings = target + await connect() + } + + /// "host", "host:port" or "[v6]:port", plus a user name. + static func parse(host: String, user: String) -> FrameSettings? { + var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces)) + if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") { + let rest = target.host[target.host.index(after: close)...] + if rest.hasPrefix(":"), let port = Int(rest.dropFirst()) { target.port = port } + target.host = String(target.host[target.host.index(after: target.host.startIndex).. String { + var bytes = [UInt8](repeating: 0, count: 24) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return bytes.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/ios/FrameControl/App/FrameControlApp.swift b/ios/FrameControl/App/FrameControlApp.swift new file mode 100644 index 0000000..840e5ce --- /dev/null +++ b/ios/FrameControl/App/FrameControlApp.swift @@ -0,0 +1,34 @@ +import SwiftUI + +@main +struct FrameControlApp: App { + @StateObject private var model = AppModel() + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + RootView(model: model) + .task { + #if DEBUG + // Simulator testing without the pairing screen: print this device's key, + // and connect to FRAME_TEST_HOST with it (`simctl launch` passes + // SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST). + print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)") + if model.settings == nil, let host = ProcessInfo.processInfo.environment["FRAME_TEST_HOST"] { + await model.useKey(host: host, user: "steamos") + return + } + #endif + if model.settings != nil { await model.connect() } + } + .onOpenURL { url in + // frame-control://install?… from a website (docs/web-install.md). + guard let link = InstallLink(url.absoluteString), model.pendingInstallLinks.count < 5 else { return } + model.pendingInstallLinks.append(link) + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { model.resume() } + } + } + } +} diff --git a/ios/FrameControl/App/InstallLink.swift b/ios/FrameControl/App/InstallLink.swift new file mode 100644 index 0000000..be29576 --- /dev/null +++ b/ios/FrameControl/App/InstallLink.swift @@ -0,0 +1,27 @@ +import Foundation + +/// frame-control://install?manifest=URL or ?url=URL (docs/web-install.md), the same +/// first filter as app/install-link.js. The server on the Frame applies the full +/// rules (HTTPS, no private addresses, redirects) before fetching anything. +struct InstallLink: Equatable { + enum Kind: String { case manifest, url } + let kind: Kind + let target: String + + static let scheme = "frame-control" + private static let maxLink = 4096 + private static let maxURL = 2048 + + init?(_ raw: String) { + guard raw.count <= Self.maxLink, raw.lowercased().hasPrefix("\(Self.scheme):"), + let link = URLComponents(string: raw), link.scheme?.lowercased() == Self.scheme, + link.host?.lowercased() == "install", ["", "/"].contains(link.path) else { return nil } + let items = link.queryItems ?? [] + guard items.count == 1, let item = items.first, let kind = Kind(rawValue: item.name), + let target = item.value, !target.isEmpty, target.count <= Self.maxURL, + let url = URLComponents(string: target), ["https", "http"].contains(url.scheme?.lowercased() ?? ""), + url.host?.isEmpty == false, url.user == nil, url.password == nil else { return nil } + self.kind = kind + self.target = target + } +} diff --git a/ios/FrameControl/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/FrameControl/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..406fe95 --- /dev/null +++ b/ios/FrameControl/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,4 @@ +{ + "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0xFF", "green" : "0x9F", "red" : "0x1A" } }, "idiom" : "universal" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/FrameControl/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/FrameControl/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..42f5c3f --- /dev/null +++ b/ios/FrameControl/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [ { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/FrameControl/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/FrameControl/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..f17f340 Binary files /dev/null and b/ios/FrameControl/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/ios/FrameControl/Assets.xcassets/AppIconImage.imageset/Contents.json b/ios/FrameControl/Assets.xcassets/AppIconImage.imageset/Contents.json new file mode 100644 index 0000000..5558bed --- /dev/null +++ b/ios/FrameControl/Assets.xcassets/AppIconImage.imageset/Contents.json @@ -0,0 +1 @@ +{ "images" : [ { "filename" : "icon.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } diff --git a/ios/FrameControl/Assets.xcassets/AppIconImage.imageset/icon.png b/ios/FrameControl/Assets.xcassets/AppIconImage.imageset/icon.png new file mode 100644 index 0000000..f17f340 Binary files /dev/null and b/ios/FrameControl/Assets.xcassets/AppIconImage.imageset/icon.png differ diff --git a/ios/FrameControl/Assets.xcassets/Contents.json b/ios/FrameControl/Assets.xcassets/Contents.json new file mode 100644 index 0000000..46cba7d --- /dev/null +++ b/ios/FrameControl/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info" : { "author" : "xcode", "version" : 1 } } diff --git a/ios/FrameControl/Info.plist b/ios/FrameControl/Info.plist new file mode 100644 index 0000000..210d075 --- /dev/null +++ b/ios/FrameControl/Info.plist @@ -0,0 +1,69 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Frame Control + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleURLTypes + + + CFBundleURLName + com.saphid.framecontrol.install + CFBundleURLSchemes + + frame-control + + + + CFBundleVersion + 1 + LSApplicationQueriesSchemes + + ssh + sftp + steamlink + rdp + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + Frame Control connects to your Steam Frame over your local network with SSH. + UILaunchScreen + + UIColorName + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + + diff --git a/ios/FrameControl/SSH/FrameLink.swift b/ios/FrameControl/SSH/FrameLink.swift new file mode 100644 index 0000000..28c4455 --- /dev/null +++ b/ios/FrameControl/SSH/FrameLink.swift @@ -0,0 +1,126 @@ +import Citadel +import CryptoKit +import Foundation +import NIOCore +import NIOSSH + +/// Where the Frame is and who to log in as. +struct FrameSettings: Codable, Equatable { + var host: String + var port: Int = 22 + var user: String = "steamos" +} + +struct FrameFailure: LocalizedError { + let message: String + init(_ message: String) { self.message = message } + var errorDescription: String? { message } +} + +/// Trust on first use: pairing records the Frame's host key; later connections +/// accept that key and nothing else, as ssh's known_hosts does. +final class PinnedHostKey: NIOSSHClientServerAuthenticationDelegate, @unchecked Sendable { + struct Changed: Error {} + let expected: String? + private let lock = NSLock() + private var _seen: String? + var seen: String? { lock.withLock { _seen } } + + init(expected: String?) { self.expected = expected } + + func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise) { + let key = String(openSSHPublicKey: hostKey) + lock.withLock { _seen = key } + if expected == nil || expected == key { + validationCompletePromise.succeed(()) + } else { + validationCompletePromise.fail(Changed()) + } + } +} + +/// One SSH connection to the Frame, and the few things the app does over it. +final class FrameLink: @unchecked Sendable { + let client: SSHClient + + private init(client: SSHClient) { self.client = client } + + static func connect(_ settings: FrameSettings, auth: SSHAuthenticationMethod, hostKey: PinnedHostKey) async throws -> FrameLink { + do { + let client = try await SSHClient.connect( + host: settings.host, port: settings.port, authenticationMethod: auth, + hostKeyValidator: .custom(hostKey), reconnect: .never, connectTimeout: .seconds(8)) + return FrameLink(client: client) + } catch { + throw FrameFailure(describe(error, host: settings.host)) + } + } + + /// The plain-language reason a connection failed, like the desktop server's messages. + static func describe(_ error: Error, host: String) -> String { + if error is PinnedHostKey.Changed { + return "The Frame's SSH identity changed (after a reinstall, or a different device at \(host)). Pair again." + } + let text = String(describing: error) + if text.contains("allAuthenticationOptionsFailed") || text.contains("authentication") { + return "The Frame didn't accept the login. Pair again, and check the Developer Mode password." + } + if ["timeout", "Timeout", "timed out", "Host is down", "No route to host", "Network is unreachable", + "errno: 64", "errno: 65", "errno: 51", "errno: 60"].contains(where: text.contains) { + return "The Frame isn't answering at \(host). It may be asleep, switched off, or on another network." + } + if text.contains("refused") || text.contains("ECONNREFUSED") { + return "The Frame refused the connection at \(host). Check Developer Mode is still on." + } + if text.contains("NXDOMAIN") || text.contains("resolve") || text.contains("unknownHost") || text.contains("NoAddress") { + return "Can't find \(host) on the network. Check the address, and that the Frame is on the same network." + } + return "Couldn't connect to \(host): \(text)" + } + + var isConnected: Bool { client.isConnected } + + func close() async { + try? await client.close() + } + + /// Runs a shell command; returns its combined output and exit status. + func run(_ command: String) async throws -> (output: String, status: Int) { + // stderr joins stdout (Citadel treats any stderr as a failure), and the + // status comes back as the last line so a non-zero exit isn't an exception. + let buffer = try await client.executeCommand("{ \(command)\n} 2>&1; echo \"@@rc=$?\"") + var text = String(buffer: buffer) + var status = 0 + if let range = text.range(of: "@@rc=", options: .backwards) { + status = Int(text[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1 + text = String(text[.. String { + let r = try await run(command) + guard r.status == 0 else { throw FrameFailure("\(what): \(r.output.isEmpty ? "exit \(r.status)" : r.output)") } + return r.output + } + + /// Writes data to a path relative to the home directory. + func upload(_ data: Data, to path: String) async throws { + let sftp = try await client.openSFTP() + do { + try await sftp.withFile(filePath: path, flags: [.write, .create, .truncate]) { file in + try await file.write(ByteBuffer(bytes: data)) + } + try? await sftp.close() + } catch { + try? await sftp.close() + throw error + } + } +} + +func shellQuote(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" +} diff --git a/ios/FrameControl/SSH/HeadsetServer.swift b/ios/FrameControl/SSH/HeadsetServer.swift new file mode 100644 index 0000000..43d23ef --- /dev/null +++ b/ios/FrameControl/SSH/HeadsetServer.swift @@ -0,0 +1,143 @@ +import Citadel +import Foundation +import NIOCore + +/// Frame Control's server, running on the Frame itself. The app copies the bundle +/// (ios/scripts/make_frame_bundle.py) to ~/.cache/frame-control/ once per +/// version, then starts ui/server.py there over SSH. It listens only on the Frame's +/// 127.0.0.1, and it exits when this SSH session ends (--exit-on-eof). +final class HeadsetServer: @unchecked Sendable { + let port: Int + private let lock = NSLock() + private var _exited: String? + /// Set once the server stops, with its last output. + var exited: String? { lock.withLock { _exited } } + var onExit: (@Sendable (String) -> Void)? + + private init(port: Int) { self.port = port } + + static let cacheDir = ".cache/frame-control" + + struct Bundle { + let data: Data + let version: String + + static func fromApp() throws -> Bundle { + guard let url = Foundation.Bundle.main.url(forResource: "frame-bundle", withExtension: "tar.gz"), + let data = try? Data(contentsOf: url), + let vurl = Foundation.Bundle.main.url(forResource: "frame-bundle", withExtension: "version"), + let version = try? String(contentsOf: vurl, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines), + version.range(of: "^[0-9a-f]{16}$", options: .regularExpression) != nil else { + throw FrameFailure("This build of the app is missing its Frame bundle") + } + return Bundle(data: data, version: version) + } + } + + /// Copies the bundle over unless this version is already there; removes older versions. + static func deploy(_ bundle: Bundle, over link: FrameLink, progress: @escaping @Sendable (String) -> Void) async throws -> String { + let dir = "\(cacheDir)/\(bundle.version)" + let py = try await link.run("command -v python3 >/dev/null && python3 -c 'import sys; print(sys.version_info >= (3, 8))'") + guard py.status == 0, py.output.hasSuffix("True") else { + throw FrameFailure("The Frame has no Python 3.8 or later, which Frame Control needs there.") + } + if try await link.run("test -f \(dir)/ui/server.py").status != 0 { + progress("Copying Frame Control to the headset") + try await link.check("mkdir -p \(cacheDir)", "Couldn't make \(cacheDir)") + let archive = "\(dir).tar.gz" + try await link.upload(bundle.data, to: archive) + progress("Unpacking") + try await link.check("rm -rf \(dir).tmp && mkdir \(dir).tmp && tar xzf \(archive) -C \(dir).tmp && rm -f \(archive) " + + "&& rm -rf \(dir) && mv \(dir).tmp \(dir)", "Couldn't unpack Frame Control on the headset") + } + // Older versions: only this one is used from now on. + _ = try? await link.run("cd \(cacheDir) && for d in */; do [ \"${d%/}\" = \(bundle.version) ] || rm -rf -- \"$d\"; done") + return dir + } + + /// Starts the server in dir and waits for it to say which port it took. + static func start(in dir: String, over link: FrameLink, key: String, device: String) async throws -> HeadsetServer { + let command = "cd \(dir) && FRAME_LOCAL=1 FRAME_UI_KEY=\(key) FRAME_DEVICE=\(shellQuote(device)) " + + "exec python3 -I -u -B ui/server.py --port 0 --exit-on-eof 2>&1" + let stream = try await link.client.executeCommandStream(command) + let box = PortWaiter() + let reader = Task { () -> Void in + var text = "" + do { + for try await chunk in stream { + switch chunk { + case .stdout(let b), .stderr(let b): text += String(buffer: b) + } + if text.count > 20_000 { text = String(text.suffix(10_000)) } + if let port = Self.port(in: text) { box.found(port) } + } + } catch { + text += "\n\(error)" + } + box.ended(text) + } + let server: HeadsetServer + do { + server = HeadsetServer(port: try await box.wait(seconds: 30)) + } catch { + reader.cancel() + throw error + } + box.onEnd = { [weak server] tail in + guard let server else { return } + server.lock.withLock { server._exited = tail } + server.onExit?(tail) + } + return server + } + + static func port(in text: String) -> Int? { + guard let r = text.range(of: #"Frame Control on http://127\.0\.0\.1:(\d+)"#, options: .regularExpression) else { return nil } + return Int(text[r].split(separator: ":").last ?? "") + } +} + +/// Hands the port from the output reader to start(), or the output if the server died first. +private final class PortWaiter: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var result: Result? + private var endedTail: String? + var onEnd: (@Sendable (String) -> Void)? { + didSet { if let tail = lock.withLock({ endedTail }) { onEnd?(tail) } } + } + + func found(_ port: Int) { finish(.success(port)) } + + func ended(_ text: String) { + let tail = String(text.suffix(600)).trimmingCharacters(in: .whitespacesAndNewlines) + lock.withLock { endedTail = tail } + finish(.failure(FrameFailure("Frame Control's server on the headset stopped: \(tail.isEmpty ? "no output" : tail)"))) + onEnd?(tail) + } + + private func finish(_ r: Result) { + let c: CheckedContinuation? = lock.withLock { + guard result == nil else { return nil } + result = r + defer { continuation = nil } + return continuation + } + c?.resume(with: r) + } + + func wait(seconds: Double) async throws -> Int { + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(seconds * 1e9)) + self?.finish(.failure(FrameFailure("Frame Control's server on the headset didn't start within \(Int(seconds)) s"))) + } + return try await withCheckedThrowingContinuation { c in + let done: Result? = lock.withLock { + if let result { return result } + continuation = c + return nil + } + if let done { c.resume(with: done) } + } + } +} diff --git a/ios/FrameControl/SSH/Keys.swift b/ios/FrameControl/SSH/Keys.swift new file mode 100644 index 0000000..4917b82 --- /dev/null +++ b/ios/FrameControl/SSH/Keys.swift @@ -0,0 +1,54 @@ +import CryptoKit +import Foundation +import NIOSSH +import Security + +/// Small wrapper over the Keychain for this app's secrets. +enum Keychain { + private static let service = "com.saphid.framecontrol" + + private static func query(_ account: String) -> [String: Any] { + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, + kSecAttrAccount as String: account] + } + + static func data(_ account: String) -> Data? { + var q = query(account) + q[kSecReturnData as String] = true + q[kSecMatchLimit as String] = kSecMatchLimitOne + var out: AnyObject? + return SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess ? out as? Data : nil + } + + static func set(_ data: Data, _ account: String) { + SecItemDelete(query(account) as CFDictionary) + var q = query(account) + q[kSecValueData as String] = data + // Only on this device and not in backups: the key is this phone's identity. + q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + SecItemAdd(q as CFDictionary, nil) + } + + static func delete(_ account: String) { + SecItemDelete(query(account) as CFDictionary) + } +} + +/// This phone's SSH key: ed25519, made once, kept in the Keychain. +enum DeviceKey { + private static let account = "ssh-ed25519" + + static func loadOrCreate() -> Curve25519.Signing.PrivateKey { + if let raw = Keychain.data(account), let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) { + return key + } + let key = Curve25519.Signing.PrivateKey() + Keychain.set(key.rawRepresentation, account) + return key + } + + /// The line for ~/.ssh/authorized_keys, e.g. "ssh-ed25519 AAAA… frame-control@iPhone". + static func authorizedKeysLine(_ key: Curve25519.Signing.PrivateKey, comment: String) -> String { + String(openSSHPublicKey: NIOSSHPrivateKey(ed25519Key: key).publicKey) + " " + comment + } +} diff --git a/ios/FrameControl/SSH/PortForwarder.swift b/ios/FrameControl/SSH/PortForwarder.swift new file mode 100644 index 0000000..ab94d34 --- /dev/null +++ b/ios/FrameControl/SSH/PortForwarder.swift @@ -0,0 +1,113 @@ +import Citadel +import Foundation +import NIOCore +import NIOPosix +import NIOSSH + +/// Listens on this phone's 127.0.0.1 and carries each connection to a port on the +/// Frame's 127.0.0.1 through the SSH session (ssh -L). The web view loads the +/// server from here; every API request still needs the session's key. +final class PortForwarder: @unchecked Sendable { + private let channel: Channel + let localPort: Int + + private init(channel: Channel, localPort: Int) { + self.channel = channel + self.localPort = localPort + } + + static func start(over link: FrameLink, to remotePort: Int) async throws -> PortForwarder { + let client = link.client + // The listener shares the SSH connection's event loop, so the glue between + // each pair of channels never crosses threads. + let bootstrap = ServerBootstrap(group: client.eventLoop) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelOption(ChannelOptions.allowRemoteHalfClosure, value: true) + // Nothing is read from the web view until the SSH side is ready for it. + .childChannelOption(ChannelOptions.autoRead, value: false) + .childChannelInitializer { inbound in + inbound.eventLoop.makeFutureWithTask { + let (local, remote) = GlueHandler.matchedPair() + try await inbound.pipeline.addHandler(local).get() + let origin = try inbound.remoteAddress ?? SocketAddress(ipAddress: "127.0.0.1", port: 0) + _ = try await client.createDirectTCPIPChannel( + using: SSHChannelType.DirectTCPIP(targetHost: "127.0.0.1", targetPort: remotePort, originatorAddress: origin) + ) { channel in channel.pipeline.addHandler(remote) } + try await inbound.setOption(ChannelOptions.autoRead, value: true).get() + } + } + let channel = try await bootstrap.bind(host: "127.0.0.1", port: 0).get() + guard let port = channel.localAddress?.port else { throw FrameFailure("Couldn't open a local port") } + return PortForwarder(channel: channel, localPort: port) + } + + func stop() { + channel.close(promise: nil) + } +} + +/// Joins two channels: what one reads, the other writes, with backpressure and +/// half-close passed across (the pattern from SwiftNIO's examples). +final class GlueHandler: ChannelDuplexHandler, @unchecked Sendable { + typealias InboundIn = NIOAny + typealias OutboundIn = NIOAny + typealias OutboundOut = NIOAny + + private var partner: GlueHandler? + private var context: ChannelHandlerContext? + private var pendingRead = false + + static func matchedPair() -> (GlueHandler, GlueHandler) { + let a = GlueHandler(), b = GlueHandler() + a.partner = b + b.partner = a + return (a, b) + } + + private func partnerWrite(_ data: NIOAny) { context?.write(data, promise: nil) } + private func partnerFlush() { context?.flush() } + private func partnerWriteEOF() { context?.close(mode: .output, promise: nil) } + private func partnerClose() { context?.close(promise: nil) } + private var partnerWritable: Bool { context?.channel.isWritable ?? false } + + private func partnerBecameWritable() { + if pendingRead { + pendingRead = false + context?.read() + } + } + + func handlerAdded(context: ChannelHandlerContext) { self.context = context } + + func handlerRemoved(context: ChannelHandlerContext) { + self.context = nil + partner = nil + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { partner?.partnerWrite(data) } + func channelReadComplete(context: ChannelHandlerContext) { partner?.partnerFlush() } + func channelInactive(context: ChannelHandlerContext) { partner?.partnerClose() } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if let e = event as? ChannelEvent, case .inputClosed = e { + partner?.partnerWriteEOF() + } + context.fireUserInboundEventTriggered(event) + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + partner?.partnerClose() + } + + func channelWritabilityChanged(context: ChannelHandlerContext) { + if context.channel.isWritable { partner?.partnerBecameWritable() } + } + + func read(context: ChannelHandlerContext) { + if let partner, partner.partnerWritable { + context.read() + } else { + pendingRead = true + } + } +} diff --git a/ios/FrameControl/Views/RootView.swift b/ios/FrameControl/Views/RootView.swift new file mode 100644 index 0000000..15b938c --- /dev/null +++ b/ios/FrameControl/Views/RootView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct RootView: View { + @ObservedObject var model: AppModel + + var body: some View { + ZStack { + Color.frameBackground.ignoresSafeArea() + switch model.phase { + case .setup: + SetupView(model: model) + case .connecting(let step): + ConnectingView(step: step, host: model.settings?.host) { model.showSetup() } + case .failed(let message): + FailedView(message: message, canRetry: model.settings != nil, + retry: { Task { await model.connect() } }, change: { model.showSetup() }) + case .ready(let url): + WebShell(url: url, model: model).ignoresSafeArea() + } + } + .preferredColorScheme(.dark) + .tint(.frameBlue) + } +} + +extension Color { + static let frameBackground = Color(red: 0.055, green: 0.078, blue: 0.106) + static let framePanel = Color(red: 0.118, green: 0.137, blue: 0.161) + static let frameBlue = Color(red: 0.102, green: 0.624, blue: 1.0) + static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627) +} + +struct ConnectingView: View { + let step: String + let host: String? + let cancel: () -> Void + + var body: some View { + VStack(spacing: 18) { + Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17)) + ProgressView().controlSize(.large) + Text(step).font(.headline).multilineTextAlignment(.center) + if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) } + Button("Change headset", action: cancel).padding(.top, 8) + } + .padding(32) + } +} + +struct FailedView: View { + let message: String + let canRetry: Bool + let retry: () -> Void + let change: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "wifi.exclamationmark").font(.system(size: 44)).foregroundStyle(.orange) + Text("Can't reach the Frame").font(.title3.bold()) + Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted) + if canRetry { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) } + if canRetry { + Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large) + } + Button(canRetry ? "Change headset" : "Back", action: change) + } + .padding(32) + .frame(maxWidth: 480) + } +} diff --git a/ios/FrameControl/Views/SetupView.swift b/ios/FrameControl/Views/SetupView.swift new file mode 100644 index 0000000..aab21e0 --- /dev/null +++ b/ios/FrameControl/Views/SetupView.swift @@ -0,0 +1,82 @@ +import SwiftUI +import UIKit + +/// Pairing: the Developer Mode password is used once, to add this phone's own +/// key to the Frame. It isn't stored. +struct SetupView: View { + @ObservedObject var model: AppModel + @State private var host = "" + @State private var user = "steamos" + @State private var password = "" + @FocusState private var focus: Field? + private enum Field { case host, user, password } + + var body: some View { + NavigationStack { + Form { + Section { + VStack(alignment: .leading, spacing: 10) { + Image("AppIconImage").resizable().frame(width: 64, height: 64).clipShape(RoundedRectangle(cornerRadius: 14)) + Text("Connect to your Steam Frame").font(.title2.bold()) + Text("See what the headset sees, install games and Android apps, and send files and text, from this \(model.deviceName).") + .foregroundStyle(Color.frameMuted) + } + .padding(.vertical, 6) + .listRowBackground(Color.clear) + } + Section { + Label("On the Frame, open Steam Settings → System and turn on Developer Mode.", systemImage: "1.circle") + Label("Then Developer → Set User Password.", systemImage: "2.circle") + Label("Enter the headset's address and that password here, once.", systemImage: "3.circle") + } header: { Text("Before you start") } + Section { + TextField("frame.local or 192.168.1.20", text: $host) + .textContentType(.URL).keyboardType(.URL).autocorrectionDisabled().textInputAutocapitalization(.never) + .focused($focus, equals: .host).submitLabel(.next).onSubmit { focus = .password } + TextField("User", text: $user) + .autocorrectionDisabled().textInputAutocapitalization(.never).focused($focus, equals: .user) + SecureField("Developer Mode password", text: $password) + .textContentType(.password).focused($focus, equals: .password).submitLabel(.go).onSubmit(pair) + } header: { Text("Headset") } footer: { + Text("The password is only used to add this \(model.deviceName)'s own SSH key to the Frame; it isn't saved. The Frame and this \(model.deviceName) need to be on the same network, or both on Tailscale.") + } + Section { + Button(action: pair) { + Text("Pair").frame(maxWidth: .infinity).fontWeight(.semibold) + } + .disabled(host.trimmingCharacters(in: .whitespaces).isEmpty || password.isEmpty) + if model.settings != nil { + Button("Use \(model.settings!.host) again") { Task { await model.connect() } } + Button("Forget this headset", role: .destructive) { Task { await model.forget() } } + } + } + Section { + Text(model.authorizedKeysLine) + .font(.system(.caption2, design: .monospaced)).lineLimit(3).textSelection(.enabled) + Button("Copy this \(model.deviceName)'s key") { UIPasteboard.general.string = model.authorizedKeysLine } + Button("Connect with the key") { + let (h, u) = (host, user) + Task { await model.useKey(host: h, user: u) } + } + .disabled(host.trimmingCharacters(in: .whitespaces).isEmpty) + } header: { Text("Or add the key yourself") } footer: { + Text("If you already reach the Frame over SSH, add this line to ~/.ssh/authorized_keys there, then connect without a password.") + } + } + .scrollContentBackground(.hidden) + .background(Color.frameBackground) + .navigationTitle("Frame Control") + .navigationBarTitleDisplayMode(.inline) + } + .onAppear { + host = model.settings?.host ?? "frame.local" + user = model.settings?.user ?? "steamos" + } + } + + private func pair() { + let (h, u, p) = (host, user, password) + password = "" + Task { await model.pair(host: h, user: u, password: p) } + } +} diff --git a/ios/FrameControl/Web/WebShell.swift b/ios/FrameControl/Web/WebShell.swift new file mode 100644 index 0000000..afd23e4 --- /dev/null +++ b/ios/FrameControl/Web/WebShell.swift @@ -0,0 +1,187 @@ +import SwiftUI +import UIKit +import WebKit + +/// The Frame Control page, served by the server on the headset, in a web view. +/// window.frameApp (the same bridge the desktop app's preload.js provides) lets +/// the page use the phone: clipboard, saving images, other apps, install links. +struct WebShell: UIViewRepresentable { + let url: URL + @ObservedObject var model: AppModel + + func makeCoordinator() -> Coordinator { Coordinator(model: model) } + + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + let content = WKUserContentController() + content.addUserScript(WKUserScript(source: Self.bridge, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + content.addScriptMessageHandler(context.coordinator, contentWorld: .page, name: "frameApp") + config.userContentController = content + config.allowsInlineMediaPlayback = true + let web = WKWebView(frame: .zero, configuration: config) + web.navigationDelegate = context.coordinator + web.uiDelegate = context.coordinator + web.isOpaque = false + web.backgroundColor = UIColor(red: 0.055, green: 0.078, blue: 0.106, alpha: 1) + web.scrollView.backgroundColor = web.backgroundColor + web.scrollView.contentInsetAdjustmentBehavior = .never // the page pads for the safe area itself + web.allowsBackForwardNavigationGestures = false + #if DEBUG + web.isInspectable = true + #endif + context.coordinator.web = web + web.load(URLRequest(url: url)) + return web + } + + func updateUIView(_ web: WKWebView, context: Context) { + if context.coordinator.loaded != url { + context.coordinator.loaded = url + web.load(URLRequest(url: url)) + } + context.coordinator.deliverInstallLinks() + } + + static let bridge = """ + (() => { + const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null }); + let installCb = null; + window.frameApp = { + platform: "ios", + readClipboard: () => call("readClipboard"), + setUpConnection: () => call("setUpConnection"), + open: (what) => call("open", what), + saveImages: (images) => call("saveImages", images), + onInstallLink: (cb) => { installCb = cb; return call("installLinkReady"); }, + }; + window.__frameInstallLink = (req) => { if (installCb) installCb(req); }; + })(); + """ + + final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate { + let model: AppModel + weak var web: WKWebView? + var loaded: URL? + private var installReady = false + + init(model: AppModel) { self.model = model } + + // MARK: bridge + + @MainActor + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage, + replyHandler: @escaping (Any?, String?) -> Void) { + guard let body = message.body as? [String: Any], let name = body["name"] as? String else { + return replyHandler(nil, "bad message") + } + let arg = body["arg"] + switch name { + case "readClipboard": + replyHandler(UIPasteboard.general.string ?? "", nil) + case "setUpConnection": + model.showSetup() + replyHandler(nil, nil) + case "open": + let result = open(arg as? String ?? "") + replyHandler(result.message.map { ["message": $0] }, result.error) + case "saveImages": + let images = (arg as? [[String: Any]] ?? []).compactMap { item -> UIImage? in + guard let b64 = item["data"] as? String, let data = Data(base64Encoded: b64) else { return nil } + return UIImage(data: data) + } + guard !images.isEmpty else { return replyHandler(nil, "No images to save") } + share(images) + replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil) + case "installLinkReady": + installReady = true + deliverInstallLinks() + replyHandler(nil, nil) + default: + replyHandler(nil, "unknown request \(name)") + } + } + + @MainActor + func deliverInstallLinks() { + guard installReady, let web, !model.pendingInstallLinks.isEmpty else { return } + let links = model.pendingInstallLinks + model.pendingInstallLinks = [] + for link in links { + let req = ["kind": link.kind.rawValue, "target": link.target] + guard let json = try? JSONSerialization.data(withJSONObject: req), let text = String(data: json, encoding: .utf8) else { continue } + web.evaluateJavaScript("window.__frameInstallLink(\(text))") + } + } + + /// SSH, SFTP, Steam Link and remote desktop open in the apps that handle them. + @MainActor + private func open(_ what: String) -> (message: String?, error: String?) { + guard let s = model.settings else { return (nil, "Not paired with a Frame") } + let host = s.host.contains(":") ? "[\(s.host)]" : s.host + let target: (url: String, app: String, store: String) + switch what { + case "terminal": target = ("ssh://\(s.user)@\(host):\(s.port)", "an SSH app such as Blink Shell or Termius", "https://apps.apple.com/search?term=ssh") + case "sftp": target = ("sftp://\(s.user)@\(host):\(s.port)", "an SFTP app such as Termius or Secure ShellFish", "https://apps.apple.com/search?term=sftp") + case "steamlink": target = ("steamlink://", "Steam Link", "https://apps.apple.com/app/steam-link/id1246969117") + case "rdp": target = ("rdp://full%20address=s:\(s.host):3389", "Windows App (Microsoft Remote Desktop)", "https://apps.apple.com/app/windows-app/id714464092") + default: return (nil, "Can't open \(what) on this \(model.deviceName)") + } + guard let url = URL(string: target.url) else { return (nil, "Bad address") } + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + return ("Opening \(target.app)", nil) + } + if let store = URL(string: target.store) { UIApplication.shared.open(store) } + return (nil, "Install \(target.app) to open this; opening the App Store") + } + + @MainActor + private func share(_ images: [UIImage]) { + guard let web, let root = web.window?.rootViewController else { return } + let sheet = UIActivityViewController(activityItems: images, applicationActivities: nil) + sheet.popoverPresentationController?.sourceView = web + sheet.popoverPresentationController?.sourceRect = CGRect(x: web.bounds.midX, y: web.bounds.midY, width: 1, height: 1) + (root.presentedViewController ?? root).present(sheet, animated: true) + } + + // MARK: navigation: the app's page stays here; other sites open in Safari + + func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = action.request.url else { return decisionHandler(.cancel) } + if url.host == "127.0.0.1" || url.scheme == "about" || url.scheme == "blob" || url.scheme == "data" { + return decisionHandler(.allow) + } + UIApplication.shared.open(url) + decisionHandler(.cancel) + } + + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + if let url = action.request.url { UIApplication.shared.open(url) } // target="_blank" links + return nil + } + + // MARK: alert() and confirm(), which the page uses before removing things + + func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { + present(message, actions: [UIAlertAction(title: "OK", style: .default) { _ in completionHandler() }], fallback: completionHandler) + } + + func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { + present(message, actions: [ + UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(false) }, + UIAlertAction(title: "OK", style: .default) { _ in completionHandler(true) }, + ], fallback: { completionHandler(false) }) + } + + private func present(_ message: String, actions: [UIAlertAction], fallback: @escaping () -> Void) { + guard let root = web?.window?.rootViewController else { return fallback() } + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + actions.forEach(alert.addAction) + (root.presentedViewController ?? root).present(alert, animated: true) + } + } +} diff --git a/ios/FrameControlTests/FrameControlTests.swift b/ios/FrameControlTests/FrameControlTests.swift new file mode 100644 index 0000000..d1d5a99 --- /dev/null +++ b/ios/FrameControlTests/FrameControlTests.swift @@ -0,0 +1,54 @@ +import CryptoKit +import XCTest +@testable import Frame_Control + +final class InstallLinkTests: XCTestCase { + func testAcceptsManifestAndURLLinks() { + XCTAssertEqual(InstallLink("frame-control://install?manifest=https://example.com/app.json"), + InstallLink("frame-control://install/?manifest=https://example.com/app.json")) + XCTAssertEqual(InstallLink("frame-control://install?url=https://example.com/a.apk")?.kind, .url) + XCTAssertEqual(InstallLink("FRAME-CONTROL://install?manifest=https://example.com/m.json")?.target, "https://example.com/m.json") + } + + func testRejectsAnythingElse() { + for raw in ["frame-control://other?url=https://example.com/a.apk", + "frame-control://install?url=ftp://example.com/a.apk", + "frame-control://install?url=https://user:pw@example.com/a.apk", + "frame-control://install?url=https://example.com/a&manifest=https://example.com/b", + "frame-control://install?url=https://a.example/x&url=https://b.example/y", + "frame-control://install?url=", + "frame-control://install/deeper?url=https://example.com/a.apk", + "https://example.com/?url=https://example.com/a.apk", + "frame-control://install?url=https://example.com/" + String(repeating: "a", count: 2100)] { + XCTAssertNil(InstallLink(raw), raw) + } + } +} + +final class HeadsetServerTests: XCTestCase { + func testReadsThePortTheServerPrints() { + XCTAssertEqual(HeadsetServer.port(in: "Frame Control on http://127.0.0.1:41234 (alias: frame; Ctrl-C to stop)\n"), 41234) + XCTAssertNil(HeadsetServer.port(in: "Traceback (most recent call last):")) + } + + func testBundleIsInTheApp() throws { + let bundle = try HeadsetServer.Bundle.fromApp() + XCTAssertGreaterThan(bundle.data.count, 100_000) + XCTAssertEqual(bundle.version.count, 16) + } +} + +final class KeyTests: XCTestCase { + func testAuthorizedKeysLine() { + let line = DeviceKey.authorizedKeysLine(Curve25519.Signing.PrivateKey(), comment: "frame-control@iPhone") + let parts = line.split(separator: " ") + XCTAssertEqual(parts.count, 3) + XCTAssertEqual(parts[0], "ssh-ed25519") + XCTAssertEqual(Data(base64Encoded: String(parts[1]))?.count, 51) // string "ssh-ed25519" + 32-byte key + XCTAssertEqual(parts[2], "frame-control@iPhone") + } + + func testShellQuote() { + XCTAssertEqual(shellQuote("it's"), "'it'\\''s'") + } +} diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..8d91aef --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,76 @@ +name: FrameControl +options: + bundleIdPrefix: com.saphid + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true +packages: + Citadel: + url: https://github.com/orlandos-nl/Citadel.git + exactVersion: 0.12.1 +settings: + base: + SWIFT_VERSION: "5.0" + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" +targets: + FrameControl: + type: application + platform: iOS + sources: + - path: FrameControl + dependencies: + - package: Citadel + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.saphid.framecontrol + PRODUCT_NAME: Frame Control + TARGETED_DEVICE_FAMILY: "1,2" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_FILE: FrameControl/Info.plist + ENABLE_USER_SCRIPT_SANDBOXING: NO + info: + path: FrameControl/Info.plist + properties: + CFBundleDisplayName: Frame Control + UILaunchScreen: + UIColorName: "" + UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight] + UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight] + UIUserInterfaceStyle: Dark + NSLocalNetworkUsageDescription: Frame Control connects to your Steam Frame over your local network with SSH. + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp] + CFBundleURLTypes: + - CFBundleURLName: com.saphid.framecontrol.install + CFBundleURLSchemes: [frame-control] + preBuildScripts: + - name: Pack the Frame bundle + # The server, headset helpers and catalogue, as the app copies them to the Frame. + script: | + python3 "${SRCROOT}/scripts/make_frame_bundle.py" "${DERIVED_FILE_DIR}/frame-bundle.tar.gz" > "${DERIVED_FILE_DIR}/frame-bundle.version" + mkdir -p "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" + cp "${DERIVED_FILE_DIR}/frame-bundle.tar.gz" "${DERIVED_FILE_DIR}/frame-bundle.version" "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/" + basedOnDependencyAnalysis: false + FrameControlTests: + type: bundle.unit-test + platform: iOS + sources: + - path: FrameControlTests + dependencies: + - target: FrameControl + settings: + base: + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control" + BUNDLE_LOADER: "$(TEST_HOST)" +schemes: + FrameControl: + build: + targets: + FrameControl: all + FrameControlTests: [test] + test: + targets: [FrameControlTests] diff --git a/ios/scripts/icon-ios.svg b/ios/scripts/icon-ios.svg new file mode 100644 index 0000000..7be30e8 --- /dev/null +++ b/ios/scripts/icon-ios.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/scripts/make_frame_bundle.py b/ios/scripts/make_frame_bundle.py new file mode 100644 index 0000000..c26083b --- /dev/null +++ b/ios/scripts/make_frame_bundle.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Pack what Frame Control's server needs to run on the Frame itself (the files the +desktop app ships, plus ui/local-bin) into one reproducible .tar.gz. + +The iPhone app copies it to ~/.cache/frame-control/ on the Frame and +starts ui/server.py there. is the SHA-256 of the archive, so a new +build replaces an old one and an unchanged one isn't copied again. + +Usage: make_frame_bundle.py OUT.tar.gz (prints the version) +""" +import gzip +import hashlib +import io +import sys +import tarfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py", + "frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js"] + + +def files(): + found = set() + for pattern in PATTERNS: + for p in ROOT.glob(pattern): + if p.is_file() and "__pycache__" not in p.parts: + found.add(p) + return sorted(found) + + +def build(): + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode="w", format=tarfile.PAX_FORMAT) as tar: + for p in files(): + info = tarfile.TarInfo(str(p.relative_to(ROOT))) + data = p.read_bytes() + info.size, info.mtime, info.uid, info.gid, info.uname, info.gname = len(data), 0, 0, 0, "", "" + info.mode = 0o755 if p.stat().st_mode & 0o111 else 0o644 + tar.addfile(info, io.BytesIO(data)) + out = io.BytesIO() + with gzip.GzipFile(fileobj=out, mode="wb", mtime=0) as gz: + gz.write(raw.getvalue()) + return out.getvalue() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + data = build() + Path(sys.argv[1]).write_bytes(data) + print(hashlib.sha256(data).hexdigest()[:16]) diff --git a/tests/test_server.py b/tests/test_server.py index 2dd9484..efae991 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -217,6 +217,60 @@ class ServerGuards(unittest.TestCase): self.assertEqual(self.post("/api/nope", {})[0], 404) +@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script") +class LocalMode(unittest.TestCase): + """FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key + guards /api/, and ssh goes to ui/local-bin/ssh, which runs commands here.""" + + KEY = "0123456789abcdef0123456789abcdef" + + @classmethod + def setUpClass(cls): + env = {**os.environ, "FRAME_LOCAL": "1", "FRAME_UI_KEY": cls.KEY, "FRAME_DEVICE": "iPhone", + "PYTHONDONTWRITEBYTECODE": "1"} + cls.log = tempfile.TemporaryFile() + cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"], + env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=cls.log, text=True) + line = cls.proc.stdout.readline() + cls.port = int(line.split("127.0.0.1:")[1].split()[0]) # --port 0: the server prints the port it took + + @classmethod + def tearDownClass(cls): + cls.proc.stdin.close() # --exit-on-eof: the phone disconnecting + cls.proc.wait(timeout=15) + cls.proc.stdout.close() + cls.log.close() + + def request(self, method, path, body=None, key=KEY): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20) + conn.request(method, path, body=json.dumps(body).encode() if body is not None else None, + headers={"X-Frame-UI": key, "Content-Type": "application/json"}) + r = conn.getresponse() + data = json.loads(r.read() or b"{}") + conn.close() + return r.status, data + + def test_needs_the_session_key(self): + self.assertEqual(self.request("GET", "/api/host", key="1")[0], 403) + self.assertEqual(self.request("GET", "/api/host", key="")[0], 403) + status, host = self.request("GET", "/api/host") + self.assertEqual(status, 200) + self.assertEqual(host, {"os": "SteamOS", "fileManager": None, "computer": "iPhone", "mobile": True}) + + def test_commands_run_locally(self): + # frame_titles lists ~/devkit-game here; with nothing there, the list is empty rather than an ssh error. + status, body = self.request("GET", "/api/titles") + self.assertEqual(status, 200, body) + self.assertIsInstance(body["titles"], list) + + def test_open_is_for_the_app_and_power_needs_a_password(self): + self.assertEqual(self.request("POST", "/api/open", {"what": "terminal"})[0], 400) + status, body = self.request("POST", "/api/open", {"what": "reboot"}) + self.assertEqual(status, 400) + self.assertIn("password", body["error"]) + self.assertEqual(self.request("POST", "/api/open", {"what": "reboot", "password": "a\nb"})[0], 400) + + class UnreachableMessages(unittest.TestCase): """Only ssh's own connection failures are reworded; other errors keep their text.""" diff --git a/ui/index.html b/ui/index.html index 0cb771a..c97603d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2,7 +2,7 @@ - + Frame Control @@ -327,10 +371,10 @@ FRAME CONTROL
Connecting… @@ -547,10 +591,10 @@

Send to Frame

- Drop files here, or click to choose + Drop files here, or click to chooseTap to choose files Files land in ~/Downloads. .apk files install as their own Android app; a game's .zip, folder or .exe becomes a title in the Steam library. - You can also drop files anywhere in this window. + You can also drop files anywhere in this window.
@@ -585,8 +629,10 @@ -
Sleep, Restart and Shut down open a terminal window for the Developer Mode password.
-