iPhone and iPad app: the same features, served from the Frame

An iPhone can't run Python or ssh, but the Frame can. The app (ios/, SwiftUI)
connects with its own SSH key (Citadel), copies the server and helpers to
~/.cache/frame-control/<version> on the Frame once per version, starts
ui/server.py there with FRAME_LOCAL=1 on the Frame's 127.0.0.1, and shows the
page through an SSH tunnel. The server exits when the phone disconnects.

Server: FRAME_LOCAL=1 puts ui/local-bin on PATH, whose ssh stand-in runs each
`ssh frame COMMAND` locally (and serves as rsync's transport), so desktop and
phone share one code path. Android display goes through podman exec there, as
the Frame has no adb. FRAME_UI_KEY replaces the fixed X-Frame-UI value with a
per-session key. Power actions take the Developer Mode password via sudo -S.
--port 0 now prints the port it took.

Page: a bottom tab bar and safe areas on phones, Play buttons visible on touch
screens, saving through the share sheet, SSH/SFTP/Steam Link/remote desktop
opening in their iOS apps, and a password dialog for power.

App: pairing with the Developer Mode password once (never stored) or with a
key the user adds; host key pinned on first use; plain-language connection
errors with quiet retries; frame-control://install links; alerts and confirms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-27 11:18:15 +10:00
1 parent 0f770dc88a
commit 13187e8f6a
33 files changed
+2459 -34

No files matched your search

+15 -1
View File
@@ -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
+3
View File
@@ -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.
+73
View File
@@ -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/<version>` 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.
+4
View File
@@ -0,0 +1,4 @@
xcuserdata/
*.xcuserstate
build/
DerivedData/
+535
View File
@@ -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 = "<group>"; };
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlApp.swift; sourceTree = "<group>"; };
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortForwarder.swift; sourceTree = "<group>"; };
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = "<group>"; };
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = "<group>"; };
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = "<group>"; };
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 = "<group>";
};
5064A5B6FE5B17B18E5FA4B8 /* SSH */ = {
isa = PBXGroup;
children = (
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */,
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */,
237D9AF04EEA257AB382F60E /* Keys.swift */,
A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */,
);
path = SSH;
sourceTree = "<group>";
};
59B34B34E2BE8BCD237BCF26 /* Products */ = {
isa = PBXGroup;
children = (
F3E2F5607DD877272483D64E /* FrameControl.app */,
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
68AF00C8593B71502E1FB72B /* App */ = {
isa = PBXGroup;
children = (
8A11F3431826A26B247C0695 /* AppModel.swift */,
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
);
path = App;
sourceTree = "<group>";
};
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = {
isa = PBXGroup;
children = (
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
);
path = FrameControlTests;
sourceTree = "<group>";
};
A939D1267299AE8A48092557 /* Views */ = {
isa = PBXGroup;
children = (
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */,
DB544223FC60A59CC3E8EF5F /* SetupView.swift */,
);
path = Views;
sourceTree = "<group>";
};
B4F84A5777E9EFEAEB54DB91 /* FrameControl */ = {
isa = PBXGroup;
children = (
8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */,
6B5B6718C5EA77FA67F6B14C /* Info.plist */,
68AF00C8593B71502E1FB72B /* App */,
5064A5B6FE5B17B18E5FA4B8 /* SSH */,
A939D1267299AE8A48092557 /* Views */,
CC25EAB6C385A68D63F7DDF7 /* Web */,
);
path = FrameControl;
sourceTree = "<group>";
};
CC25EAB6C385A68D63F7DDF7 /* Web */ = {
isa = PBXGroup;
children = (
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */,
);
path = Web;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -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
}
@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
BuildableName = "FrameControl.app"
BlueprintName = "FrameControl"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "88565F33E966FD0BAC7AC9C8"
BuildableName = "FrameControlTests.xctest"
BlueprintName = "FrameControlTests"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
BuildableName = "FrameControl.app"
BlueprintName = "FrameControl"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "88565F33E966FD0BAC7AC9C8"
BuildableName = "FrameControlTests.xctest"
BlueprintName = "FrameControlTests"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
BuildableName = "FrameControl.app"
BlueprintName = "FrameControl"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
BuildableName = "FrameControl.app"
BlueprintName = "FrameControl"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+212
View File
@@ -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)..<close])
} else if target.host.filter({ $0 == ":" }).count == 1, let colon = target.host.lastIndex(of: ":"),
let port = Int(target.host[target.host.index(after: colon)...]) {
target.port = port
target.host = String(target.host[..<colon])
}
guard !target.host.isEmpty, !target.user.isEmpty, (1...65535).contains(target.port) else { return nil }
return target
}
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
var authorizedKeysLine: String {
DeviceKey.authorizedKeysLine(DeviceKey.loadOrCreate(), comment: "frame-control@\(deviceName)")
}
/// Forget the headset: back to the pairing screen. The Frame keeps the key line;
/// remove it from ~/.ssh/authorized_keys there to revoke this phone.
func forget() async {
await teardown()
UserDefaults.standard.removeObject(forKey: Self.settingsKey)
UserDefaults.standard.removeObject(forKey: Self.hostKeyKey)
settings = nil
phase = .setup
}
func showSetup() {
Task { await teardown() }
phase = .setup
}
// MARK: connecting
/// quiet: a background retry, which leaves the failure screen up until it works.
func connect(quiet: Bool = false) async {
guard let settings else {
phase = .setup
return
}
await teardown()
attempt += 1
let mine = attempt
func step(_ s: String) { if mine == attempt && !quiet { phase = .connecting(s) } }
step("Connecting to \(settings.host)")
do {
let bundle = try HeadsetServer.Bundle.fromApp()
let auth = SSHAuthenticationMethod.ed25519(username: settings.user, privateKey: DeviceKey.loadOrCreate())
let pin = PinnedHostKey(expected: hostKey)
let link = try await FrameLink.connect(settings, auth: auth, hostKey: pin)
guard mine == attempt else { await link.close(); return }
self.link = link
if hostKey == nil, let seen = pin.seen { UserDefaults.standard.set(seen, forKey: Self.hostKeyKey) }
let dir = try await HeadsetServer.deploy(bundle, over: link) { s in Task { @MainActor in step(s) } }
step("Starting Frame Control on the headset")
let key = Self.randomKey()
let server = try await HeadsetServer.start(in: dir, over: link, key: key, device: deviceName)
self.server = server
let forwarder = try await PortForwarder.start(over: link, to: server.port)
self.forwarder = forwarder
guard mine == attempt else { return }
server.onExit = { [weak self] tail in
Task { @MainActor in self?.lost(mine, "Frame Control on the headset stopped. \(tail.suffix(200))") }
}
readySince = Date()
phase = .ready(URL(string: "http://127.0.0.1:\(forwarder.localPort)/?key=\(key)")!)
} catch {
guard mine == attempt else { return }
await teardown()
phase = .failed((error as? FrameFailure)?.message ?? FrameLink.describe(error, host: settings.host))
// Keep trying quietly while the app is open: the Frame may just be asleep.
// A new task each time, so retrying for hours doesn't nest awaits.
Task { [weak self] in
try? await Task.sleep(nanoseconds: 10_000_000_000)
guard let self, mine == self.attempt, case .failed = self.phase,
UIApplication.shared.applicationState == .active else { return }
await self.connect(quiet: true)
}
}
}
/// Called when the app comes back to the foreground: iOS may have dropped the
/// connection while it was in the background.
func resume() {
switch phase {
case .ready:
if link?.isConnected != true || server?.exited != nil { Task { await connect() } }
case .failed:
if settings != nil { Task { await connect() } }
default:
break
}
}
private var readySince = Date.distantPast
private func lost(_ which: Int, _ why: String) {
guard which == attempt, case .ready = phase else { return }
// Restart it once; if it dies again straight away, say so instead of looping.
if Date().timeIntervalSince(readySince) < 20 {
Task { await teardown() }
phase = .failed(why)
} else {
Task { await connect() }
}
}
private func teardown() async {
forwarder?.stop()
forwarder = nil
server = nil
if let link {
self.link = nil
await link.close() // ends the server too: its stdin closes
}
}
private static func randomKey() -> String {
var bytes = [UInt8](repeating: 0, count: 24)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return bytes.map { String(format: "%02x", $0) }.joined()
}
}
@@ -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() }
}
}
}
}
+27
View File
@@ -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
}
}
@@ -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 }
}
@@ -0,0 +1,4 @@
{
"images" : [ { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

@@ -0,0 +1 @@
{ "images" : [ { "filename" : "icon.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } }
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }
+69
View File
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Frame Control</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.saphid.framecontrol.install</string>
<key>CFBundleURLSchemes</key>
<array>
<string>frame-control</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>ssh</string>
<string>sftp</string>
<string>steamlink</string>
<string>rdp</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Frame Control connects to your Steam Frame over your local network with SSH.</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string></string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>
+126
View File
@@ -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<Void>) {
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[..<range.lowerBound])
}
return (text.trimmingCharacters(in: .whitespacesAndNewlines), status)
}
/// Runs a command that must succeed; its output, or a FrameFailure with it.
@discardableResult
func check(_ command: String, _ what: String) async throws -> 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: "'\\''") + "'"
}
+143
View File
@@ -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/<version> 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<Int, Error>?
private var result: Result<Int, Error>?
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<Int, Error>) {
let c: CheckedContinuation<Int, Error>? = 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<Int, Error>? = lock.withLock {
if let result { return result }
continuation = c
return nil
}
if let done { c.resume(with: done) }
}
}
}
+54
View File
@@ -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
}
}
+113
View File
@@ -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
}
}
}
+70
View File
@@ -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)
}
}
+82
View File
@@ -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) }
}
}
+187
View File
@@ -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)
}
}
}
@@ -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'")
}
}
+76
View File
@@ -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]
+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1a9fff"/>
<stop offset="1" stop-color="#6f42c1"/>
</linearGradient>
<linearGradient id="visor" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#dfe8f5"/>
</linearGradient>
<clipPath id="tile"><rect x="100" y="100" width="824" height="824" rx="185"/></clipPath>
<mask id="nose">
<rect width="1024" height="1024" fill="#fff"/>
<ellipse cx="512" cy="690" rx="78" ry="96" fill="#000"/>
</mask>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0b1020" flood-opacity=".35"/>
</filter>
</defs>
<rect width="1024" height="1024" fill="url(#bg)"/>
<g transform="translate(512 512) scale(1.2427) translate(-512 -512)">
<g filter="url(#shadow)">
<rect x="222" y="350" width="580" height="320" rx="130" fill="url(#visor)" mask="url(#nose)"/>
</g>
<rect x="300" y="430" width="160" height="124" rx="50" fill="#13233a"/>
<rect x="564" y="430" width="160" height="124" rx="50" fill="#13233a"/>
<rect x="320" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
<rect x="584" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+52
View File
@@ -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/<version> on the Frame and
starts ui/server.py there. <version> 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])
+54
View File
@@ -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."""
+135 -26
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Frame Control</title>
<style>
/* Motiva Sans is Steam's UI font; Valve's CDN serves it with CORS open. */
@@ -61,7 +61,16 @@
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: 0 18px; } nav a { padding: 0 10px; } }
@media (max-width: 820px) { .brand b, #battChip { display: none; } }
@media (max-width: 820px) { .brand b { display: none; } }
nav svg { display: none; }
html { -webkit-text-size-adjust: 100%; }
body { -webkit-tap-highlight-color: transparent; }
.mobile-only { display: none; }
body.mobile .mobile-only { display: revert; }
body.mobile .desk-only { display: none; }
/* Touch screens can't hover: keep the library's name and Play button showing. */
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
/* ---- pages: one per tab; the header nav switches between them ---- */
.page { display: flex; flex-direction: column; gap: 26px; }
@@ -114,7 +123,7 @@
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
.seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
.seg button.on { background: var(--btn-hi); color: var(--bright); }
input[type=text], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
input[type=text], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 9px 11px; font: inherit; }
textarea { resize: vertical; min-height: 76px; }
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
@@ -247,10 +256,10 @@
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
.and-col { display: grid; gap: 22px; align-content: start; }
.rep-item .s { white-space: normal; }
#repDlg, #titleDlg, #wiDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
#repDlg, #titleDlg, #wiDlg, #pwDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop { background: rgba(0,0,0,.55); }
#repDlg h2, #titleDlg h2, #wiDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop { background: rgba(0,0,0,.55); }
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
@@ -313,6 +322,41 @@
.disp .seg button { padding: 0 10px; }
.disp input[type=number] { width: 72px; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
/* ---- phones: tabs move to a bottom bar, as in iOS apps; the page clears the notch and home indicator ---- */
@media (max-width: 640px) {
/* No blur here: it would make the header the containing block of the fixed tab bar. */
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) 14px 0; gap: 10px;
backdrop-filter: none; background: rgb(23, 29, 37); }
.brand svg { width: 26px; height: 26px; }
header .chip { height: 30px; padding: 0 10px; }
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
height: calc(58px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom);
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
nav a { flex: 1; flex-direction: column; justify-content: center; gap: 3px; padding: 0; font-size: 10.5px;
letter-spacing: .5px; border-bottom: 0; color: var(--muted); }
nav a.on { border-bottom: 0; }
nav svg { display: block; width: 23px; height: 23px; }
.bottombar, .drawer { display: none; }
body { padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
.toast { left: 14px; right: 14px; max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
main, .page { gap: 16px; }
main { padding: 12px 12px 20px; }
.panel { padding: 14px; }
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
.toolbar { gap: 8px; }
.toolbar .spacer { display: none; }
.shelf-head { flex-wrap: wrap; row-gap: 8px; }
.shelf { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; }
.cat-grid { grid-template-columns: 1fr; }
.shot-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.and-btns { padding-left: 0; }
.banner { flex-wrap: wrap; }
.banner .s { white-space: normal; }
button { height: 38px; }
button.small { height: 32px; }
.actions button { height: 46px; }
.cat-tools select { max-width: none; flex: 1 1 100%; }
}
</style>
</head>
<body>
@@ -327,10 +371,10 @@
<b>FRAME CONTROL</b>
</a>
<nav id="nav">
<a href="#home" class="on" title="Headset view, status and screenshots (1)">Home<kbd>1</kbd></a>
<a href="#games" title="Steam games and sideloaded titles (2)">Games<kbd>2</kbd></a>
<a href="#android" title="Android apps and their display (3)">Android<kbd>3</kbd></a>
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)">Tools<kbd>4</kbd></a>
<a href="#home" class="on" title="Headset view, status and screenshots (1)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="4"/><circle cx="8.5" cy="12" r="1.6"/><circle cx="15.5" cy="12" r="1.6"/></svg>Home<kbd>1</kbd></a>
<a href="#games" title="Steam games and sideloaded titles (2)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8h12a4 4 0 0 1 4 4v1a4 4 0 0 1-7 2.6h-6A4 4 0 0 1 2 13v-1a4 4 0 0 1 4-4z"/><path d="M7 11v3M5.5 12.5h3"/><circle cx="16" cy="11.5" r=".6"/><circle cx="18" cy="13.5" r=".6"/></svg>Games<kbd>2</kbd></a>
<a href="#android" title="Android apps and their display (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
</nav>
<div class="spacer"></div>
<span class="chip" id="conn" role="status"><span class="dot"></span><span>Connecting…</span></span>
@@ -547,10 +591,10 @@
<section class="panel" id="transfer">
<div class="shelf-head"><h2>Send to Frame</h2></div>
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
<b>Drop files here, or click to choose</b>
<b><span class="desk-only">Drop files here, or click to choose</span><span class="mobile-only">Tap to choose files</span></b>
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
You can also drop files anywhere in this window.
<span class="desk-only">You can also drop files anywhere in this window.</span>
<input type="file" id="fileInput" multiple hidden>
</div>
<div class="progress" id="prog"><i></i></div>
@@ -585,8 +629,10 @@
<button data-open="reboot" data-confirm="Restart the Frame?"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>Restart</button>
<button data-open="poweroff" data-confirm="Shut the Frame down?" class="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v9"/><path d="M6.3 7.3a8 8 0 1 0 11.4 0"/></svg>Shut down</button>
</div>
<div class="hint">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
<div class="links">
<div class="hint desk-only">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
<div class="hint mobile-only">Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.</div>
<div class="row mobile-only" style="margin-top:14px"><button class="small" id="changeHeadset">Change headset…</button></div>
<div class="links desk-only">
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
<div><a href="https://framedropvr.com" target="_blank">FrameDrop</a>: sideloader (Windows only for now)</div>
@@ -661,10 +707,23 @@
<button type="button" class="small" id="wiCancel">Cancel</button>
<button type="button" class="action small" id="wiGo" disabled>Install</button></div>
</dialog>
<dialog id="pwDlg" aria-labelledby="pwTitle">
<form method="dialog" id="pwForm">
<h2 id="pwTitle">Restart the Frame?</h2>
<div class="sub">SteamOS asks for the Developer Mode password for this. It's used once and not saved.</div>
<input type="password" id="pwInput" autocomplete="current-password" placeholder="Developer Mode password" style="margin-top:12px">
<div class="row rep-actions"><span class="sub" id="pwMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="pwCancel">Cancel</button>
<button type="submit" class="action small" id="pwGo">Restart</button></div>
</form>
</dialog>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
// Every /api/ request carries this header. The desktop server takes "1"; the
// iPhone app's server, on the Frame, takes a fresh key it puts in the page URL.
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_stream.Moonlight"],
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
@@ -711,14 +770,32 @@ $("bottombar").onclick = () => {
const HOST = { computer: "computer" };
function applyHostWording(h) {
Object.assign(HOST, h);
$("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
$("shotsSaveNew").textContent = `Save new to ${h.computer}`;
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
document.body.classList.toggle("mobile", !!h.mobile);
$("shotsFolder").hidden = !!h.mobile;
if (!h.mobile) $("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
$("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`;
$("clipMac").textContent = `Send ${h.computer} clipboard`;
if (h.mobile) $("offSetup").textContent = "Change headset…";
}
// Images to the phone (the app offers Save Image to Photos, Files and so on).
function blobBase64(blob) {
return new Promise((ok, bad) => {
const r = new FileReader();
r.onload = () => ok(String(r.result).split(",")[1]);
r.onerror = () => bad(new Error("couldn't read the image"));
r.readAsDataURL(blob);
});
}
async function saveToDevice(items) {
const images = await Promise.all(items.map(async i => ({ name: i.name, data: await blobBase64(i.blob) })));
return window.frameApp.saveImages(images);
}
const savesToDevice = () => !!(window.frameApp && window.frameApp.saveImages);
async function api(path, body) {
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
const opts = body === undefined ? { headers: {"X-Frame-UI": UI_KEY} } : {
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": UI_KEY}, body: JSON.stringify(body) };
let r;
try { r = await fetch(path, opts); }
catch { throw new Error("Frame Control's local server isn't running. Restart the app (Frame → Restart Server)."); }
@@ -1005,7 +1082,7 @@ async function capture() {
let url = null;
try {
const r = await fetch(view === "headset" ? "/api/screenshot?view=headset" : "/api/screenshot",
{ headers: {"X-Frame-UI": "1"} });
{ headers: {"X-Frame-UI": UI_KEY} });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
const source = r.headers.get("X-Capture-Source") || "gamescope";
url = URL.createObjectURL(await r.blob());
@@ -1104,7 +1181,7 @@ async function startVideo() {
},
error: e => log("Video decoder: " + e.message, "e"),
});
const r = await fetch(`/api/stream?${STREAM_QUERY}`, { headers: {"X-Frame-UI": "1"}, signal: ctl.signal });
const r = await fetch(`/api/stream?${STREAM_QUERY}`, { headers: {"X-Frame-UI": UI_KEY}, signal: ctl.signal });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
const reader = r.body.getReader();
let buf = new Uint8Array(0), scan = 0, auStart = -1;
@@ -1159,6 +1236,7 @@ $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) :
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
}, "image/png");
function download(blob, name) {
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = name;
@@ -1182,8 +1260,12 @@ document.body.addEventListener("click", async (e) => {
act(`Remove ${b.dataset.name}`, () => api("/api/flatpak", { action: "uninstall", id: b.dataset.uninstall }), b).then(refresh);
} else if (b.dataset.quick) installFlatpak(b.dataset.quick);
else if (b.dataset.open) {
const what = b.dataset.open, label = b.textContent.trim();
// On the phone, power needs the password here, and the rest opens other apps.
if (HOST.mobile && POWER[what]) return askPower(what);
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
act(b.textContent.trim(), () => api("/api/open", { what: b.dataset.open }), b);
if (HOST.mobile && window.frameApp?.open) return act(label, () => window.frameApp.open(what), b);
act(label, () => api("/api/open", { what }), b);
}
});
// Installs run as server jobs: start one, then poll until it's done. The
@@ -1214,6 +1296,29 @@ async function runJob(label, key, start) {
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
} finally { installing.delete(key); jobsRunning--; showJobs(); }
}
const POWER = { suspend: ["Put the Frame to sleep?", "Sleep"], reboot: ["Restart the Frame?", "Restart"],
poweroff: ["Shut the Frame down?", "Shut down"] };
function askPower(what) {
const [title, verb] = POWER[what], dlg = $("pwDlg");
$("pwTitle").textContent = title; $("pwGo").textContent = verb;
$("pwInput").value = ""; $("pwMsg").textContent = "";
$("pwCancel").onclick = () => dlg.close();
$("pwForm").onsubmit = async e => {
e.preventDefault();
const password = $("pwInput").value;
if (!password) return ($("pwMsg").textContent = "Enter the password");
$("pwGo").disabled = true; $("pwMsg").textContent = "";
try {
const res = await api("/api/open", { what, password });
dlg.close(); log(res.message, "ok"); toast(res.message);
} catch (err) { $("pwMsg").textContent = err.message; }
finally { $("pwGo").disabled = false; $("pwInput").value = ""; }
};
dlg.showModal();
$("pwInput").focus();
}
$("changeHeadset").onclick = () => window.frameApp?.setUpConnection && window.frameApp.setUpConnection();
function installFlatpak(id) {
if (installing.has(id)) return;
toast(`Installing ${id}. This can take a few minutes…`);
@@ -1289,7 +1394,7 @@ function upload(file, mode) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.setRequestHeader("X-Frame-UI", "1");
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
xhr.setRequestHeader("X-Mode", mode);
const bar = $("prog").firstElementChild;
@@ -1915,7 +2020,7 @@ function shotApp(appid) {
}
async function shotBlob(id, thumb) {
const r = await fetch(`/api/shots/image?id=${encodeURIComponent(id)}${thumb ? "&thumb=1" : ""}`,
{ headers: {"X-Frame-UI": "1"} });
{ headers: {"X-Frame-UI": UI_KEY} });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
return r.blob();
}
@@ -1928,14 +2033,14 @@ async function loadShots() {
} finally { $("shotsRefresh").disabled = false; }
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
const unsaved = shots.list.filter(s => !s.saved).length;
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
$("shotsSaveNew").disabled = !unsaved;
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved && !HOST.mobile ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
$("shotsSaveNew").disabled = HOST.mobile ? !shots.list.length : !unsaved;
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
<div class="row"><div class="grow">
<div class="t">${esc(shotApp(s.appid))}</div>
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
${s.saved ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
${s.saved && !HOST.mobile ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
</div></div>`).join("")
: `<div class="sub">No screenshots on the Frame yet.</div>`;
// Thumbnails one at a time over the shared SSH connection.
@@ -1975,6 +2080,10 @@ async function openShot(s) {
}
async function saveShots(list, btn) {
if (!list.length) return;
if (HOST.mobile && savesToDevice()) {
return act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
async () => saveToDevice(await Promise.all(list.map(async s => ({ blob: await shotBlob(s.id, false), name: s.file })))), btn);
}
const res = await act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
if (res) loadShots();
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Stand-in for ssh when Frame Control's server runs on the Frame itself
# (FRAME_LOCAL=1, started by the iPhone app). The server and its helpers call
# `ssh [options] frame COMMAND`, and rsync calls it as its transport; here that
# means: run COMMAND in the home directory, as ssh would, with the same stdin.
while [ $# -gt 0 ]; do
case "$1" in
-[bcDEeFIiJLlmOoPpQRSWwB]) shift 2 ;; # options that take a value
-?*) shift ;;
*) break ;;
esac
done
[ $# -gt 0 ] && shift # the host alias
cd "$HOME" || exit 255
[ $# -eq 0 ] && exit 0 # -N: nothing to run
exec "${SHELL:-/bin/sh}" -c "$*"
+66 -7
View File
@@ -7,6 +7,9 @@ up by scripts/connect.sh or ui/frame_connect.py.
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
Env: FRAME_ALIAS (default frame)
FRAME_LOCAL=1 run on the Frame itself (the iPhone app starts it there over SSH)
FRAME_UI_KEY required X-Frame-UI value (the iPhone app passes a fresh one)
FRAME_DEVICE what to call the device the page runs on (e.g. iPhone)
"""
import argparse
import base64
@@ -43,12 +46,20 @@ import frame_webinstall # noqa: E402
frame_host.trust_bundled_cas()
HERE = Path(__file__).resolve().parent
# On the Frame itself, every `ssh frame COMMAND` the server and its helpers run
# goes to local-bin/ssh, which runs COMMAND here instead, so one code path serves
# both. Nothing listens beyond 127.0.0.1; the phone reaches it through SSH.
LOCAL = os.environ.get("FRAME_LOCAL") == "1"
if LOCAL:
os.environ["PATH"] = f"{HERE / 'local-bin'}{os.pathsep}{os.environ.get('PATH', '')}"
UI_KEY = os.environ.get("FRAME_UI_KEY") or "1"
DEVICE = os.environ.get("FRAME_DEVICE") or "phone"
FRAME = os.environ.get("FRAME_ALIAS", "frame")
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
# supports it (not on Windows: there every command connects on its own).
CONTROL = frame_host.control_path()
CONTROL = None if LOCAL else frame_host.control_path()
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
# Commands use the master when it's up and connect directly when it isn't.
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
@@ -504,8 +515,30 @@ def flatpak(body):
raise Failure("action must be install or uninstall", 400)
def power(what, password):
"""Sleep, restart or shut down from the Frame itself: sudo takes the Developer Mode password on stdin."""
if not isinstance(password, str) or not password or "\n" in password:
raise Failure("enter the Developer Mode password", 400)
try:
r = subprocess.run(["sudo", "-S", "-k", "-p", "", "systemctl", what], input=password + "\n",
capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
raise Failure(f"systemctl {what} didn't answer")
if r.returncode != 0:
err = r.stderr.strip()
raise Failure("that password wasn't accepted" if "incorrect password" in err or "Sorry" in err
else err or f"systemctl {what} failed", 400)
return {"message": {"suspend": "Going to sleep", "reboot": "Restarting", "poweroff": "Shutting down"}[what]}
def open_thing(body):
what = body.get("what")
if LOCAL:
# Terminals, Steam Link and remote desktop open on the phone (its app does
# that); what's left here is power, with the password the page asked for.
if what in ("reboot", "poweroff", "suspend"):
return power(what, body.get("password"))
raise Failure("open that from the app", 400)
try:
if what == "terminal":
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
@@ -829,6 +862,30 @@ class AdbTunnel:
return False
class PodmanShell:
"""AdbTunnel's stand-in on the Frame itself: there's no adb there, but each
instance is a podman container, so run Android's shell inside it."""
def __init__(self, ports, containers):
self.containers = containers
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def shell(self, port, command, timeout=20):
ctr = self.containers.get(port)
if not ctr:
raise Failure(f"port {port} isn't a Lepton container this app can reach")
return ssh(f"podman exec {shlex.quote(ctr)} /system/bin/sh -c {shlex.quote(command)}", timeout=timeout)
def android_shell(ports, containers):
return PodmanShell(ports, containers) if LOCAL else AdbTunnel(ports)
DISPLAY_READ = "echo @@pkgs; pm list packages -3; echo @@size; wm size; echo @@density; wm density; " \
"echo @@font; settings get system font_scale"
@@ -901,7 +958,7 @@ def android_displays():
if not ports:
return {"instances": []}
instances = []
with AdbTunnel(ports) as t:
with android_shell(ports, containers) as t:
for p in ports:
item = {"port": p, "container": containers.get(p)}
try:
@@ -955,10 +1012,10 @@ def android_display(body):
if not cmds:
raise Failure("nothing to change: give density, size or fontScale", 400)
ports, _, _ = lepton_ports()
ports, containers, _ = lepton_ports()
if port not in ports:
raise Failure(f"no Lepton instance is listening on Frame port {port}", 404)
with AdbTunnel([port]) as t:
with android_shell([port], containers) as t:
for c in cmds:
out = t.shell(port, c)
# wm prints usage or an exception on failure but may still exit 0.
@@ -1225,7 +1282,7 @@ class Handler(BaseHTTPRequestHandler):
# All of /api/*, not just POST: an <img> on any website could otherwise
# trigger a headset capture and display it.
api = urlparse(self.path).path.startswith("/api/")
if (self.command == "POST" or api) and self.headers.get("X-Frame-UI") != "1":
if (self.command == "POST" or api) and not secrets.compare_digest(self.headers.get("X-Frame-UI") or "", UI_KEY):
self.send_json({"error": "missing X-Frame-UI header"}, 403)
return False
return True
@@ -1259,7 +1316,8 @@ class Handler(BaseHTTPRequestHandler):
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/host":
self.send_json({"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
"computer": "Mac" if frame_host.MAC else "PC"})
elif path == "/api/android":
ensure_master()
@@ -1459,7 +1517,8 @@ def main():
sys.stdin.buffer.read()
threading.Thread(target=httpd.shutdown, daemon=True).start()
threading.Thread(target=watch_stdin, daemon=True).start()
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt: