mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +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
|
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||||
- name: Script syntax
|
- name: Script syntax
|
||||||
run: |
|
run: |
|
||||||
|
sh -n ui/local-bin/ssh
|
||||||
for f in scripts/*.sh frame/*/*.sh; do
|
for f in scripts/*.sh frame/*/*.sh; do
|
||||||
case "$(head -n 1 "$f")" in
|
case "$(head -n 1 "$f")" in
|
||||||
*zsh*) zsh -n "$f" ;;
|
*zsh*) zsh -n "$f" ;;
|
||||||
@@ -28,7 +29,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
- name: Python compiles
|
- name: Python compiles
|
||||||
run: |
|
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.
|
# 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
|
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
|
||||||
- name: Server tests
|
- name: Server tests
|
||||||
@@ -57,3 +58,16 @@ jobs:
|
|||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
- name: Server tests
|
- name: Server tests
|
||||||
run: python -m unittest discover -s tests -v
|
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** (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`) |
|
| **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.
|
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
|
Google doesn't publish `adb` for arm64 Linux, so that build uses your
|
||||||
distribution's. If you already have `adb`, the app uses yours.
|
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)
|
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):
|
class UnreachableMessages(unittest.TestCase):
|
||||||
"""Only ssh's own connection failures are reworded; other errors keep their text."""
|
"""Only ssh's own connection failures are reworded; other errors keep their text."""
|
||||||
|
|
||||||
|
|||||||
+135
-26
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<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>
|
<title>Frame Control</title>
|
||||||
<style>
|
<style>
|
||||||
/* Motiva Sans is Steam's UI font; Valve's CDN serves it with CORS open. */
|
/* 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-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; }
|
.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: 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 ---- */
|
/* ---- pages: one per tab; the header nav switches between them ---- */
|
||||||
.page { display: flex; flex-direction: column; gap: 26px; }
|
.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 { 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 { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
|
||||||
.seg button.on { background: var(--btn-hi); color: var(--bright); }
|
.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; }
|
border-radius: 3px; padding: 9px 11px; font: inherit; }
|
||||||
textarea { resize: vertical; min-height: 76px; }
|
textarea { resize: vertical; min-height: 76px; }
|
||||||
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
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-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; }
|
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||||
.rep-item .s { white-space: normal; }
|
.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); }
|
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::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::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 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, #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; }
|
#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;
|
#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 .seg button { padding: 0 10px; }
|
||||||
.disp input[type=number] { width: 72px; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
.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; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -327,10 +371,10 @@
|
|||||||
<b>FRAME CONTROL</b>
|
<b>FRAME CONTROL</b>
|
||||||
</a>
|
</a>
|
||||||
<nav id="nav">
|
<nav id="nav">
|
||||||
<a href="#home" class="on" title="Headset view, status and screenshots (1)">Home<kbd>1</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)">Games<kbd>2</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)">Android<kbd>3</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)">Tools<kbd>4</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>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<span class="chip" id="conn" role="status"><span class="dot"></span><span>Connecting…</span></span>
|
<span class="chip" id="conn" role="status"><span class="dot"></span><span>Connecting…</span></span>
|
||||||
@@ -547,10 +591,10 @@
|
|||||||
<section class="panel" id="transfer">
|
<section class="panel" id="transfer">
|
||||||
<div class="shelf-head"><h2>Send to Frame</h2></div>
|
<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">
|
<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;
|
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.
|
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>
|
<input type="file" id="fileInput" multiple hidden>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress" id="prog"><i></i></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="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>
|
<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>
|
||||||
<div class="hint">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
<div class="hint desk-only">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||||||
<div class="links">
|
<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://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://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>
|
<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="small" id="wiCancel">Cancel</button>
|
||||||
<button type="button" class="action small" id="wiGo" disabled>Install</button></div>
|
<button type="button" class="action small" id="wiGo" disabled>Install</button></div>
|
||||||
</dialog>
|
</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>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = (id) => document.getElementById(id);
|
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"],
|
const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_stream.Moonlight"],
|
||||||
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
||||||
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
||||||
@@ -711,14 +770,32 @@ $("bottombar").onclick = () => {
|
|||||||
const HOST = { computer: "computer" };
|
const HOST = { computer: "computer" };
|
||||||
function applyHostWording(h) {
|
function applyHostWording(h) {
|
||||||
Object.assign(HOST, h);
|
Object.assign(HOST, h);
|
||||||
$("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
|
||||||
$("shotsSaveNew").textContent = `Save new to ${h.computer}`;
|
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`;
|
$("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) {
|
async function api(path, body) {
|
||||||
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
|
const opts = body === undefined ? { headers: {"X-Frame-UI": UI_KEY} } : {
|
||||||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
|
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": UI_KEY}, body: JSON.stringify(body) };
|
||||||
let r;
|
let r;
|
||||||
try { r = await fetch(path, opts); }
|
try { r = await fetch(path, opts); }
|
||||||
catch { throw new Error("Frame Control's local server isn't running. Restart the app (Frame → Restart Server)."); }
|
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;
|
let url = null;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(view === "headset" ? "/api/screenshot?view=headset" : "/api/screenshot",
|
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}`);
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
const source = r.headers.get("X-Capture-Source") || "gamescope";
|
const source = r.headers.get("X-Capture-Source") || "gamescope";
|
||||||
url = URL.createObjectURL(await r.blob());
|
url = URL.createObjectURL(await r.blob());
|
||||||
@@ -1104,7 +1181,7 @@ async function startVideo() {
|
|||||||
},
|
},
|
||||||
error: e => log("Video decoder: " + e.message, "e"),
|
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}`);
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
const reader = r.body.getReader();
|
const reader = r.body.getReader();
|
||||||
let buf = new Uint8Array(0), scan = 0, auStart = -1;
|
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`);
|
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
|
||||||
}, "image/png");
|
}, "image/png");
|
||||||
function download(blob, name) {
|
function download(blob, name) {
|
||||||
|
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = name;
|
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);
|
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.quick) installFlatpak(b.dataset.quick);
|
||||||
else if (b.dataset.open) {
|
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;
|
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
|
// 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);
|
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
|
||||||
} finally { installing.delete(key); jobsRunning--; showJobs(); }
|
} 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) {
|
function installFlatpak(id) {
|
||||||
if (installing.has(id)) return;
|
if (installing.has(id)) return;
|
||||||
toast(`Installing ${id}. This can take a few minutes…`);
|
toast(`Installing ${id}. This can take a few minutes…`);
|
||||||
@@ -1289,7 +1394,7 @@ function upload(file, mode) {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open("POST", "/api/upload");
|
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-Filename", encodeURIComponent(file.name));
|
||||||
xhr.setRequestHeader("X-Mode", mode);
|
xhr.setRequestHeader("X-Mode", mode);
|
||||||
const bar = $("prog").firstElementChild;
|
const bar = $("prog").firstElementChild;
|
||||||
@@ -1915,7 +2020,7 @@ function shotApp(appid) {
|
|||||||
}
|
}
|
||||||
async function shotBlob(id, thumb) {
|
async function shotBlob(id, thumb) {
|
||||||
const r = await fetch(`/api/shots/image?id=${encodeURIComponent(id)}${thumb ? "&thumb=1" : ""}`,
|
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}`);
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
return r.blob();
|
return r.blob();
|
||||||
}
|
}
|
||||||
@@ -1928,14 +2033,14 @@ async function loadShots() {
|
|||||||
} finally { $("shotsRefresh").disabled = false; }
|
} finally { $("shotsRefresh").disabled = false; }
|
||||||
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
||||||
const unsaved = shots.list.filter(s => !s.saved).length;
|
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}` : "") : "";
|
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved && !HOST.mobile ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
|
||||||
$("shotsSaveNew").disabled = !unsaved;
|
$("shotsSaveNew").disabled = HOST.mobile ? !shots.list.length : !unsaved;
|
||||||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
|
$("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">
|
<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="row"><div class="grow">
|
||||||
<div class="t">${esc(shotApp(s.appid))}</div>
|
<div class="t">${esc(shotApp(s.appid))}</div>
|
||||||
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></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></div>`).join("")
|
||||||
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
||||||
// Thumbnails one at a time over the shared SSH connection.
|
// Thumbnails one at a time over the shared SSH connection.
|
||||||
@@ -1975,6 +2080,10 @@ async function openShot(s) {
|
|||||||
}
|
}
|
||||||
async function saveShots(list, btn) {
|
async function saveShots(list, btn) {
|
||||||
if (!list.length) return;
|
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"}`,
|
const res = await act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
|
||||||
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
|
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
|
||||||
if (res) loadShots();
|
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)
|
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
||||||
Env: FRAME_ALIAS (default frame)
|
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 argparse
|
||||||
import base64
|
import base64
|
||||||
@@ -43,12 +46,20 @@ import frame_webinstall # noqa: E402
|
|||||||
frame_host.trust_bundled_cas()
|
frame_host.trust_bundled_cas()
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
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")
|
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", 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}")
|
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
|
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||||
# supports it (not on Windows: there every command connects on its own).
|
# 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 [])]
|
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.
|
# 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"]
|
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)
|
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):
|
def open_thing(body):
|
||||||
what = body.get("what")
|
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:
|
try:
|
||||||
if what == "terminal":
|
if what == "terminal":
|
||||||
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
||||||
@@ -829,6 +862,30 @@ class AdbTunnel:
|
|||||||
return False
|
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; " \
|
DISPLAY_READ = "echo @@pkgs; pm list packages -3; echo @@size; wm size; echo @@density; wm density; " \
|
||||||
"echo @@font; settings get system font_scale"
|
"echo @@font; settings get system font_scale"
|
||||||
|
|
||||||
@@ -901,7 +958,7 @@ def android_displays():
|
|||||||
if not ports:
|
if not ports:
|
||||||
return {"instances": []}
|
return {"instances": []}
|
||||||
instances = []
|
instances = []
|
||||||
with AdbTunnel(ports) as t:
|
with android_shell(ports, containers) as t:
|
||||||
for p in ports:
|
for p in ports:
|
||||||
item = {"port": p, "container": containers.get(p)}
|
item = {"port": p, "container": containers.get(p)}
|
||||||
try:
|
try:
|
||||||
@@ -955,10 +1012,10 @@ def android_display(body):
|
|||||||
|
|
||||||
if not cmds:
|
if not cmds:
|
||||||
raise Failure("nothing to change: give density, size or fontScale", 400)
|
raise Failure("nothing to change: give density, size or fontScale", 400)
|
||||||
ports, _, _ = lepton_ports()
|
ports, containers, _ = lepton_ports()
|
||||||
if port not in ports:
|
if port not in ports:
|
||||||
raise Failure(f"no Lepton instance is listening on Frame port {port}", 404)
|
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:
|
for c in cmds:
|
||||||
out = t.shell(port, c)
|
out = t.shell(port, c)
|
||||||
# wm prints usage or an exception on failure but may still exit 0.
|
# 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
|
# All of /api/*, not just POST: an <img> on any website could otherwise
|
||||||
# trigger a headset capture and display it.
|
# trigger a headset capture and display it.
|
||||||
api = urlparse(self.path).path.startswith("/api/")
|
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)
|
self.send_json({"error": "missing X-Frame-UI header"}, 403)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -1259,7 +1316,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if path in ("/", "/index.html"):
|
if path in ("/", "/index.html"):
|
||||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||||
elif path == "/api/host":
|
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"})
|
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||||
elif path == "/api/android":
|
elif path == "/api/android":
|
||||||
ensure_master()
|
ensure_master()
|
||||||
@@ -1459,7 +1517,8 @@ def main():
|
|||||||
sys.stdin.buffer.read()
|
sys.stdin.buffer.read()
|
||||||
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||||
threading.Thread(target=watch_stdin, 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:
|
try:
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
Reference in new issue
Block a user