mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 00:00:21 +02:00
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:
1 parent
0f770dc88a
commit
13187e8f6a
33 files changed
+2459
-34
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
build/
|
||||
DerivedData/
|
||||
@@ -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>
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
@@ -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>
|
||||
@@ -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: "'\\''") + "'"
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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'")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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 |
@@ -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])
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Executable
+16
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user