mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0016d9200c | ||
|
|
fe87a9d826 | ||
|
|
1b26c4f92c | ||
|
|
3db311da13 | ||
|
|
c711e136d4 |
No files matched your search
@@ -87,6 +87,20 @@ check (a wrong or missing password refused; the right one reaches `systemctl`),
|
|||||||
a changed host key refused with "Pair with the Frame again", and a wrong
|
a changed host key refused with "Pair with the Frame again", and a wrong
|
||||||
pairing password reported the same way.
|
pairing password reported the same way.
|
||||||
|
|
||||||
|
Also verified in the Simulator against the Frame (2026-09-27): the setup screen
|
||||||
|
found the Frame by itself over Bonjour (`frame · 192.168.1.237`); a paired app
|
||||||
|
waiting for a sleeping Frame connected 4 s after it answered; an upload from the
|
||||||
|
app's web view landed in `~/Downloads`; the share sheet offers Save Image
|
||||||
|
(needs `NSPhotoLibraryAddUsageDescription`, now declared); an install link opens
|
||||||
|
the confirm dialog and downloads nothing until Install; Steam Link without the
|
||||||
|
app installed opens its App Store page.
|
||||||
|
|
||||||
|
Things iOS asks the first time: **Local Network** (tap Allow, or the app can't
|
||||||
|
see the Frame), and **Paste** when you send the iPhone's clipboard (tap Allow
|
||||||
|
Paste, or set Settings → Apps → Frame Control → Paste from Other Apps → Allow).
|
||||||
|
Sending text to the Frame's clipboard needs the desktop panel open in the
|
||||||
|
headset, as on the desktop app.
|
||||||
|
|
||||||
Not yet exercised: Android display changes through podman (no Android app was
|
Not yet exercised: Android display changes through podman (no Android app was
|
||||||
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
|
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
|
||||||
|
|
||||||
|
|||||||
+34
-9
@@ -1,9 +1,11 @@
|
|||||||
# Screen and desktop streaming
|
# Screen and desktop streaming
|
||||||
|
|
||||||
This covers two directions:
|
This covers three directions, plus input:
|
||||||
|
|
||||||
- **A. Frame → Mac**: see and control the headset from the Mac.
|
- **A. Frame → Mac**: see and control the headset from the Mac.
|
||||||
- **B. Mac → Frame**: use the Mac's desktop inside the headset.
|
- **B. Mac → Frame**: use the Mac's desktop inside the headset.
|
||||||
|
- **C. iPhone → Frame**: mirror the phone inside the headset.
|
||||||
|
- **Input**: type and point in the Frame from the Mac or iPhone.
|
||||||
|
|
||||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||||
|
|
||||||
@@ -51,14 +53,37 @@ choose to save it. Remmina stores passwords encrypted with a per-install key,
|
|||||||
so the script doesn't try to write the password. (The Remmina file format is
|
so the script doesn't try to write the password. (The Remmina file format is
|
||||||
standard; the Flatpak data path is inferred.)
|
standard; the Flatpak data path is inferred.)
|
||||||
|
|
||||||
## Input and text entry without the virtual keyboard
|
## C. Show the iPhone's screen inside the Frame
|
||||||
|
|
||||||
- **A Bluetooth keyboard and mouse** paired to the Frame is the obvious way to
|
iOS only shares its screen two ways: **AirPlay** (Screen Mirroring in Control
|
||||||
avoid the virtual keyboard. Road to VR says there are "only a few things
|
Centre) or a **ReplayKit broadcast extension** in an app. Nothing else can
|
||||||
you'd actually want to do" on the Linux desktop unless you connect a
|
capture it.
|
||||||
keyboard and mouse.
|
|
||||||
(Pairing a BT keyboard on the Frame is inferred from SteamOS; not verified.)
|
| Option | What it takes | Confidence | Verdict |
|
||||||
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh` (see
|
|---|---|---|---|
|
||||||
[file-transfer.md](file-transfer.md#clipboard)).
|
| **UxPlay** (an open-source AirPlay receiver) on the Frame | Build it for aarch64 (no Flathub package; there's a Snap and distro packages), run it in `~` or a podman container, and advertise it over mDNS. The iPhone *and* the Mac then see "Frame" in Screen Mirroring, with nothing to install on either | **Inferred.** It runs on ARM64 Linux such as the Raspberry Pi ([UxPlay](https://github.com/FDH2/UxPlay)). Not tried on the Frame: needs mDNS registration and its ports (7000, 7001, 7100 and a UDP range) reachable | **Recommended to try first.** It's the only receiver-side option, and it covers the Mac too. The window shows in the Frame's Linux desktop panel |
|
||||||
|
| A broadcast extension in Frame Control | ReplayKit sends the screen to a small extension (50 MB memory limit), which encodes H.264 and sends it through the app's SSH tunnel to the page, shown the same way as the Frame's live view in reverse | **Inferred** from Apple's ReplayKit docs | Full control and no network setup, but several days' work, and the picture only shows where Frame Control's page is open in the headset |
|
||||||
|
|
||||||
|
## Input: type and point in the Frame from the Mac or iPhone
|
||||||
|
|
||||||
|
**Verified 2026-09-27** on the headset: `steamos` is in the `input` group and
|
||||||
|
`/dev/uinput` is `crw-rw-r-- root input`, so **our own code can create a
|
||||||
|
virtual keyboard and mouse without sudo**. The Frame has no `python-evdev`,
|
||||||
|
`ydotool`, `wtype` or KDE Connect; `kwin_wayland` and `plasmashell` run only
|
||||||
|
while the desktop panel is open in the headset.
|
||||||
|
|
||||||
|
| Option | Mac | iPhone | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A uinput keyboard and mouse in Frame Control's server** | ✓ | ✓ | **Recommended.** The server opens `/dev/uinput` with `ctypes` (standard library only) and the page sends key and pointer events through the tunnel it already has. On the phone: a trackpad area (drag to move, tap to click, two fingers to scroll) and the iOS keyboard for typing. On the Mac: a "control the Frame" mode that captures the keyboard and pointer (Esc to release). Uinput devices look like real hardware to the kernel, so libinput, KWin and gamescope should take them; [frame-voice](https://github.com/DeeJanuz/frame-voice) already types into a Frame through a uinput keyboard. **Untested**: which surfaces in VR (desktop panel, SteamVR dashboard, games, Android apps in Lepton) accept the pointer. About a day or two of work |
|
||||||
|
| **Bluetooth keyboard and mouse** | – | – | Real hardware paired in SteamOS settings. The iPhone can't pretend to be a Bluetooth keyboard: iOS won't advertise the HID service ([Apple forums](https://developer.apple.com/forums/thread/733916)) |
|
||||||
|
| **Deskflow** (formerly Input Leap / Barrier) | ✓ | – | Moves the Mac's own mouse and keyboard onto the Frame's screen edge. Flathub has an aarch64 build ([Flathub](https://flathub.org/apps/org.deskflow.deskflow)); on Wayland it needs the InputCapture/libei portal, and only works while Plasma is running. No iPhone client |
|
||||||
|
| **KDE Connect** | ~ | ✓ | Its iOS app has a remote touchpad and keyboard, but the Frame would need KDE Connect installed (not on Flathub; `pacman` on a read-only root). More moving parts than the uinput route |
|
||||||
|
| **Remmina / Steam Link / RDP** | ✓ | – | Input only reaches the streamed session, not the headset's own apps |
|
||||||
|
|
||||||
|
Other ways to get text in:
|
||||||
|
|
||||||
|
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh`, or Frame Control's
|
||||||
|
clipboard box (see [file-transfer.md](file-transfer.md#clipboard)). Needs
|
||||||
|
the desktop panel open.
|
||||||
- **RDP session**: Windows App syncs the clipboard with xrdp, but only inside
|
- **RDP session**: Windows App syncs the clipboard with xrdp, but only inside
|
||||||
that RDP session.
|
that RDP session.
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
|
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
|
||||||
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
|
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
|
||||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
|
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
|
||||||
|
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
|
||||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; };
|
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
|
||||||
|
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameFinder.swift; sourceTree = "<group>"; };
|
||||||
6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; 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; };
|
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>"; };
|
8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||||
@@ -74,6 +76,7 @@
|
|||||||
5064A5B6FE5B17B18E5FA4B8 /* SSH */ = {
|
5064A5B6FE5B17B18E5FA4B8 /* SSH */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */,
|
||||||
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */,
|
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */,
|
||||||
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */,
|
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */,
|
||||||
237D9AF04EEA257AB382F60E /* Keys.swift */,
|
237D9AF04EEA257AB382F60E /* Keys.swift */,
|
||||||
@@ -255,6 +258,7 @@
|
|||||||
files = (
|
files = (
|
||||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
|
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
|
||||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
|
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
|
||||||
|
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
|
||||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
|
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
|
||||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */,
|
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */,
|
||||||
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */,
|
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// "host", "host:port" or "[v6]:port", plus a user name.
|
/// "host", "host:port" or "[v6]:port", plus a user name.
|
||||||
static func parse(host: String, user: String) -> FrameSettings? {
|
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
|
||||||
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
|
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
|
||||||
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
|
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
|
||||||
let rest = target.host[target.host.index(after: close)...]
|
let rest = target.host[target.host.index(after: close)...]
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ struct FrameControlApp: App {
|
|||||||
// and connect to FRAME_TEST_HOST with it (`simctl launch` passes
|
// and connect to FRAME_TEST_HOST with it (`simctl launch` passes
|
||||||
// SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST).
|
// SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST).
|
||||||
print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)")
|
print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)")
|
||||||
|
// FRAME_TEST_LANDSCAPE=1 turns the app on its side, to check the safe areas there.
|
||||||
|
if ProcessInfo.processInfo.environment["FRAME_TEST_LANDSCAPE"] != nil,
|
||||||
|
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||||
|
scene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight))
|
||||||
|
}
|
||||||
// FRAME_TEST_PAIR="host|user|password" runs the real password pairing.
|
// FRAME_TEST_PAIR="host|user|password" runs the real password pairing.
|
||||||
if model.settings == nil, let pair = ProcessInfo.processInfo.environment["FRAME_TEST_PAIR"] {
|
if model.settings == nil, let pair = ProcessInfo.processInfo.environment["FRAME_TEST_PAIR"] {
|
||||||
let f = pair.components(separatedBy: "|")
|
let f = pair.components(separatedBy: "|")
|
||||||
|
|||||||
@@ -43,8 +43,14 @@
|
|||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_steamos-devkit._tcp</string>
|
||||||
|
</array>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Frame Control connects to your Steam Frame over your local network with SSH.</string>
|
<string>Frame Control finds your Steam Frame on your network and connects to it.</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>Frame Control saves headset captures and screenshots to your photo library when you ask it to.</string>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIColorName</key>
|
<key>UIColorName</key>
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Finds the Frame on the local network so nobody has to type its address.
|
||||||
|
/// A Frame in Developer Mode advertises Valve's devkit service over Bonjour
|
||||||
|
/// (`_steamos-devkit._tcp`); failing that, `fallback` (the saved address, or
|
||||||
|
/// frame.local) is checked by opening its SSH port. Both repeat until stopped.
|
||||||
|
@MainActor
|
||||||
|
final class FrameFinder: ObservableObject {
|
||||||
|
struct Found: Equatable {
|
||||||
|
let host: String // what to connect to
|
||||||
|
let name: String // what to call it
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var found: Found?
|
||||||
|
/// When the search began, to tell "still looking" from "can't find it".
|
||||||
|
@Published private(set) var since = Date()
|
||||||
|
|
||||||
|
private var browser: NWBrowser?
|
||||||
|
private var probeTask: Task<Void, Never>?
|
||||||
|
private var fallback = "frame.local"
|
||||||
|
|
||||||
|
func start(fallback: String?) {
|
||||||
|
stop()
|
||||||
|
self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local"
|
||||||
|
found = nil
|
||||||
|
since = Date()
|
||||||
|
browse()
|
||||||
|
probeTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
guard let self else { return }
|
||||||
|
let host = self.fallback
|
||||||
|
if self.found == nil, await Self.sshAnswers(host: host) {
|
||||||
|
self.found = Found(host: host, name: host)
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
browser?.cancel()
|
||||||
|
browser = nil
|
||||||
|
probeTask?.cancel()
|
||||||
|
probeTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func browse() {
|
||||||
|
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
|
||||||
|
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||||
|
for result in results {
|
||||||
|
guard case let .service(name, _, _, _) = result.endpoint else { continue }
|
||||||
|
Self.resolve(result.endpoint) { host in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self, let host else { return }
|
||||||
|
// A found device is used over the fallback probe.
|
||||||
|
self.found = Found(host: host, name: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
browser.start(queue: .main)
|
||||||
|
self.browser = browser
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device's IP address: connect to the service and read where it went.
|
||||||
|
nonisolated private static func resolve(_ endpoint: NWEndpoint, done: @escaping @Sendable (String?) -> Void) {
|
||||||
|
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||||
|
let once = Once()
|
||||||
|
connection.stateUpdateHandler = { state in
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
var host: String?
|
||||||
|
if case let .hostPort(h, _)? = connection.currentPath?.remoteEndpoint {
|
||||||
|
host = "\(h)".components(separatedBy: "%").first // drop an IPv6 interface suffix
|
||||||
|
}
|
||||||
|
connection.cancel()
|
||||||
|
if once.claim() { done(host) }
|
||||||
|
case .failed, .cancelled:
|
||||||
|
if once.claim() { done(nil) }
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: .global())
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + 5) {
|
||||||
|
connection.cancel()
|
||||||
|
if once.claim() { done(nil) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether something answers on the SSH port of "host" or "host:port" within a few seconds.
|
||||||
|
nonisolated static func sshAnswers(host address: String) async -> Bool {
|
||||||
|
var host = address, port: UInt16 = 22
|
||||||
|
if let target = AppModel.parse(host: address, user: "steamos") {
|
||||||
|
host = target.host
|
||||||
|
port = UInt16(target.port)
|
||||||
|
}
|
||||||
|
return await sshAnswers(host: host, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
|
||||||
|
await withCheckedContinuation { (c: CheckedContinuation<Bool, Never>) in
|
||||||
|
let connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port) ?? 22, using: .tcp)
|
||||||
|
let once = Once()
|
||||||
|
connection.stateUpdateHandler = { state in
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
connection.cancel()
|
||||||
|
if once.claim() { c.resume(returning: true) }
|
||||||
|
case .failed, .waiting:
|
||||||
|
connection.cancel()
|
||||||
|
if once.claim() { c.resume(returning: false) }
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: .global())
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
|
||||||
|
connection.cancel()
|
||||||
|
if once.claim() { c.resume(returning: false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ struct RootView: View {
|
|||||||
SetupView(model: model)
|
SetupView(model: model)
|
||||||
case .connecting(let step):
|
case .connecting(let step):
|
||||||
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
|
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
|
||||||
|
case .failed(let message) where model.retrying && !model.needsPairing:
|
||||||
|
WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName,
|
||||||
|
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
|
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
|
||||||
retry: { Task { await model.connect() } }, change: { model.showSetup() })
|
retry: { Task { await model.connect() } }, change: { model.showSetup() })
|
||||||
@@ -73,3 +76,46 @@ struct FailedView: View {
|
|||||||
.frame(maxWidth: 480)
|
.frame(maxWidth: 480)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A paired Frame that isn't answering is almost always asleep: say how to wake
|
||||||
|
/// it, and connect the moment it does (its SSH port is checked every 3 s).
|
||||||
|
struct WaitingView: View {
|
||||||
|
let host: String
|
||||||
|
let detail: String
|
||||||
|
let deviceName: String
|
||||||
|
let reachable: () -> Void
|
||||||
|
let change: () -> Void
|
||||||
|
@State private var pulse = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 18) {
|
||||||
|
Image("AppIconImage").resizable().frame(width: 76, height: 76)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
|
.opacity(pulse ? 1 : 0.55)
|
||||||
|
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
|
||||||
|
Text("Waiting for your Frame").font(.title3.bold())
|
||||||
|
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
|
||||||
|
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
|
||||||
|
Button("Connect to a different Frame", action: change).font(.footnote)
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
.frame(maxWidth: 480)
|
||||||
|
.onAppear { pulse = true }
|
||||||
|
.task(id: host) {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
|
||||||
|
reachable()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,82 +1,197 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// Pairing: the Developer Mode password is used once, to add this phone's own
|
/// First run: two steps. Wake the Frame (the app finds it by itself), then type the
|
||||||
/// key to the Frame. It isn't stored.
|
/// Developer Mode password once. Everything else waits under "Other ways to connect".
|
||||||
struct SetupView: View {
|
struct SetupView: View {
|
||||||
@ObservedObject var model: AppModel
|
@ObservedObject var model: AppModel
|
||||||
@State private var host = ""
|
@StateObject private var finder = FrameFinder()
|
||||||
@State private var user = "steamos"
|
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@FocusState private var focus: Field?
|
@State private var manualHost = ""
|
||||||
private enum Field { case host, user, password }
|
@State private var user = "steamos"
|
||||||
|
@State private var showOther = false
|
||||||
|
@State private var showHelp = false
|
||||||
|
@FocusState private var passwordFocused: Bool
|
||||||
|
|
||||||
|
/// Where Connect goes: what the finder saw, else what was typed, else frame.local.
|
||||||
|
private var host: String {
|
||||||
|
let typed = manualHost.trimmingCharacters(in: .whitespaces)
|
||||||
|
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
ScrollView {
|
||||||
Form {
|
VStack(alignment: .leading, spacing: 22) {
|
||||||
Section {
|
header
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
StepCard(number: 1, title: "Wake your Frame", done: finder.found != nil) { wakeStep }
|
||||||
Image("AppIconImage").resizable().frame(width: 64, height: 64).clipShape(RoundedRectangle(cornerRadius: 14))
|
StepCard(number: 2, title: "Enter its Developer Mode password", done: false) { passwordStep }
|
||||||
Text("Connect to your Steam Frame").font(.title2.bold())
|
otherWays
|
||||||
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)
|
.padding(20)
|
||||||
.background(Color.frameBackground)
|
.frame(maxWidth: 560)
|
||||||
.navigationTitle("Frame Control")
|
.frame(maxWidth: .infinity)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.background(Color.frameBackground)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
host = model.settings?.host ?? "frame.local"
|
manualHost = model.settings?.host ?? ""
|
||||||
user = model.settings?.user ?? "steamos"
|
user = model.settings?.user ?? "steamos"
|
||||||
|
var fallback = model.settings?.host
|
||||||
|
#if DEBUG
|
||||||
|
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
|
||||||
|
#endif
|
||||||
|
finder.start(fallback: fallback)
|
||||||
|
}
|
||||||
|
.onDisappear { finder.stop() }
|
||||||
|
// After a few seconds of not finding it, say exactly what to check.
|
||||||
|
.task(id: finder.since) {
|
||||||
|
try? await Task.sleep(nanoseconds: 8_000_000_000)
|
||||||
|
showHelp = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pair() {
|
private var header: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Image("AppIconImage").resizable().frame(width: 56, height: 56).clipShape(RoundedRectangle(cornerRadius: 13))
|
||||||
|
Text("Connect to your Steam Frame").font(.title2.bold())
|
||||||
|
Text("One time only. After this, the app connects by itself whenever your Frame is awake.")
|
||||||
|
.foregroundStyle(Color.frameMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: step 1
|
||||||
|
|
||||||
|
@ViewBuilder private var wakeStep: some View {
|
||||||
|
if let found = finder.found {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Found your Frame").fontWeight(.semibold)
|
||||||
|
Text(found.name == found.host ? found.host : "\(found.name) · \(found.host)")
|
||||||
|
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Looking for it on this network…").foregroundStyle(Color.frameMuted)
|
||||||
|
}
|
||||||
|
Text("Put the headset on, or press its power button, so it's awake.")
|
||||||
|
if showHelp {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Still can't see it? Check:").font(.subheadline.weight(.semibold))
|
||||||
|
Tip(icon: "wifi", text: "The Frame and this \(model.deviceName) are on the same Wi-Fi.")
|
||||||
|
Tip(icon: "hammer", text: "Developer Mode is on: on the Frame, Steam Settings → System → Enable Developer Mode.")
|
||||||
|
Tip(icon: "network", text: "Local Network is allowed for Frame Control: \(model.deviceName) Settings → Apps → Frame Control.")
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: step 2
|
||||||
|
|
||||||
|
@ViewBuilder private var passwordStep: some View {
|
||||||
|
SecureField("Developer Mode password", text: $password)
|
||||||
|
.textContentType(.password)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.focused($passwordFocused)
|
||||||
|
.onSubmit(connect)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
Text("Haven't set one? On the Frame: Steam Settings → Developer → Set User Password. It's only used now, to let this \(model.deviceName) in; it isn't saved.")
|
||||||
|
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||||
|
Button(action: connect) {
|
||||||
|
Text(finder.found == nil ? "Connect to \(host)" : "Connect")
|
||||||
|
.fontWeight(.semibold).frame(maxWidth: .infinity).padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(password.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: everything else, out of the way
|
||||||
|
|
||||||
|
private var otherWays: some View {
|
||||||
|
DisclosureGroup(isExpanded: $showOther) {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
|
||||||
|
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
|
||||||
|
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||||
|
.onSubmit { finder.start(fallback: manualHost) }
|
||||||
|
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
TextField("User", text: $user)
|
||||||
|
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||||
|
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
Text("Typing an address here uses it instead of searching.").font(.caption).foregroundStyle(Color.frameMuted)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Already reach the Frame over SSH? Add this \(model.deviceName)'s key to ~/.ssh/authorized_keys there, then connect without a password.")
|
||||||
|
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||||
|
HStack {
|
||||||
|
Button("Copy key") { UIPasteboard.general.string = model.authorizedKeysLine }
|
||||||
|
Spacer()
|
||||||
|
Button("Connect with the key") {
|
||||||
|
let (h, u) = (host, user)
|
||||||
|
Task { await model.useKey(host: h, user: u) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let saved = model.settings {
|
||||||
|
Button("Forget \(saved.host)", role: .destructive) { Task { await model.forget() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 10)
|
||||||
|
} label: {
|
||||||
|
Text("Other ways to connect").foregroundStyle(Color.frameMuted)
|
||||||
|
}
|
||||||
|
.onChange(of: manualHost) { _, value in
|
||||||
|
// A typed address replaces the search.
|
||||||
|
if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connect() {
|
||||||
|
guard !password.isEmpty else { passwordFocused = true; return }
|
||||||
let (h, u, p) = (host, user, password)
|
let (h, u, p) = (host, user, password)
|
||||||
password = ""
|
password = ""
|
||||||
|
finder.stop()
|
||||||
Task { await model.pair(host: h, user: u, password: p) }
|
Task { await model.pair(host: h, user: u, password: p) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A numbered step with a tick once it's done.
|
||||||
|
struct StepCard<Content: View>: View {
|
||||||
|
let number: Int
|
||||||
|
let title: String
|
||||||
|
let done: Bool
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(done ? Color.green : Color.frameBlue).frame(width: 26, height: 26)
|
||||||
|
if done { Image(systemName: "checkmark").font(.caption.bold()) } else { Text("\(number)").font(.subheadline.bold()) }
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(title).font(.headline)
|
||||||
|
}
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Tip: View {
|
||||||
|
let icon: String
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Label { Text(text).font(.subheadline).fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: icon).foregroundStyle(Color.frameBlue) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -39,7 +39,9 @@ targets:
|
|||||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||||
UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||||
UIUserInterfaceStyle: Dark
|
UIUserInterfaceStyle: Dark
|
||||||
NSLocalNetworkUsageDescription: Frame Control connects to your Steam Frame over your local network with SSH.
|
NSLocalNetworkUsageDescription: Frame Control finds your Steam Frame on your network and connects to it.
|
||||||
|
NSBonjourServices: [_steamos-devkit._tcp]
|
||||||
|
NSPhotoLibraryAddUsageDescription: Frame Control saves headset captures and screenshots to your photo library when you ask it to.
|
||||||
NSAppTransportSecurity:
|
NSAppTransportSecurity:
|
||||||
NSAllowsLocalNetworking: true
|
NSAllowsLocalNetworking: true
|
||||||
LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp]
|
LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp]
|
||||||
|
|||||||
+24
-16
@@ -27,8 +27,8 @@
|
|||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
[hidden] { display: none !important; } /* class display rules would otherwise win */
|
[hidden] { display: none !important; } /* class display rules would otherwise win */
|
||||||
html { scroll-behavior: smooth; scroll-padding-top: 76px; }
|
html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); }
|
||||||
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: 46px;
|
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom));
|
||||||
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
|
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
|
||||||
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
|
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
|
||||||
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
|
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
|
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
|
||||||
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px;
|
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px;
|
||||||
height: 64px; padding: 0 28px; background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
|
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
|
||||||
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
|
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
|
||||||
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
|
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
|
||||||
.brand svg { width: 30px; height: 30px; }
|
.brand svg { width: 30px; height: 30px; }
|
||||||
@@ -57,10 +57,10 @@
|
|||||||
.dot.on { background: var(--green); box-shadow: 0 0 6px rgba(89,191,64,.8); }
|
.dot.on { background: var(--green); box-shadow: 0 0 6px rgba(89,191,64,.8); }
|
||||||
.dot.off { background: var(--bad); }
|
.dot.off { background: var(--bad); }
|
||||||
|
|
||||||
main { max-width: 1480px; margin: 0 auto; padding: 22px 28px 30px; display: flex; flex-direction: column; gap: 26px; }
|
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
|
||||||
.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: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
|
||||||
@media (max-width: 820px) { .brand b { display: none; } }
|
@media (max-width: 820px) { .brand b { display: none; } }
|
||||||
nav svg { display: none; }
|
nav svg { display: none; }
|
||||||
html { -webkit-text-size-adjust: 100%; }
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
@@ -231,21 +231,21 @@
|
|||||||
.links div { margin: 5px 0; }
|
.links div { margin: 5px 0; }
|
||||||
|
|
||||||
/* ---- bottom bar (Steam's DOWNLOADS strip) ---- */
|
/* ---- bottom bar (Steam's DOWNLOADS strip) ---- */
|
||||||
.bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: 42px; display: flex; align-items: center; gap: 14px;
|
.bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: calc(42px + env(safe-area-inset-bottom)); display: flex; align-items: center; gap: 14px;
|
||||||
padding: 0 28px; background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; }
|
padding: 0 calc(28px + env(safe-area-inset-right)) env(safe-area-inset-bottom) calc(28px + env(safe-area-inset-left)); background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; }
|
||||||
.bottombar .label { font-size: 12px; font-weight: 700; letter-spacing: 1.6px; color: var(--muted); }
|
.bottombar .label { font-size: 12px; font-weight: 700; letter-spacing: 1.6px; color: var(--muted); }
|
||||||
.bottombar .jobs { display: inline-flex; align-items: center; gap: 8px; color: var(--link); font-size: 12.5px; }
|
.bottombar .jobs { display: inline-flex; align-items: center; gap: 8px; color: var(--link); font-size: 12.5px; }
|
||||||
.bottombar .jobs .spinner { width: 13px; height: 13px; border-width: 2px; }
|
.bottombar .jobs .spinner { width: 13px; height: 13px; border-width: 2px; }
|
||||||
.bottombar .last { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; }
|
.bottombar .last { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; }
|
||||||
.bottombar .last.e { color: #ff8a73; }
|
.bottombar .last.e { color: #ff8a73; }
|
||||||
.drawer { position: fixed; left: 0; right: 0; bottom: 42px; z-index: 19; max-height: 0; overflow: hidden;
|
.drawer { position: fixed; left: 0; right: 0; bottom: calc(42px + env(safe-area-inset-bottom)); z-index: 19; max-height: 0; overflow: hidden;
|
||||||
background: rgba(14,20,27,.98); transition: max-height .25s ease; box-shadow: 0 -10px 30px rgba(0,0,0,.5); }
|
background: rgba(14,20,27,.98); transition: max-height .25s ease; box-shadow: 0 -10px 30px rgba(0,0,0,.5); }
|
||||||
.drawer.open { max-height: 300px; }
|
.drawer.open { max-height: 300px; }
|
||||||
.log { font: 12px/1.55 ui-monospace, "SF Mono", Menlo, monospace; color: #b8c4d2; max-height: 300px; overflow: auto;
|
.log { font: 12px/1.55 ui-monospace, "SF Mono", Menlo, monospace; color: #b8c4d2; max-height: 300px; overflow: auto;
|
||||||
white-space: pre-wrap; padding: 12px 28px; }
|
white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); }
|
||||||
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
|
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
|
||||||
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
|
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
|
||||||
.toast { position: fixed; bottom: 58px; right: 28px; z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
|
.toast { position: fixed; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
|
||||||
background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue);
|
background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue);
|
||||||
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
|
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
|
||||||
.toast.show { opacity: 1; transform: none; }
|
.toast.show { opacity: 1; transform: none; }
|
||||||
@@ -322,15 +322,17 @@
|
|||||||
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; }
|
||||||
/* Touch screens can't hover: keep the library's name and Play button showing. */
|
/* 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; } }
|
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
|
||||||
/* ---- phones: tabs move to a bottom bar, as in iOS apps; the page clears the notch and home indicator ---- */
|
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
|
||||||
@media (max-width: 640px) {
|
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
|
||||||
|
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
|
||||||
|
html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
|
||||||
/* No blur here: it would make the header the containing block of the fixed tab bar. */
|
/* 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;
|
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
|
||||||
backdrop-filter: none; background: rgb(23, 29, 37); }
|
backdrop-filter: none; background: rgb(23, 29, 37); }
|
||||||
.brand svg { width: 26px; height: 26px; }
|
.brand svg { width: 26px; height: 26px; }
|
||||||
header .chip { height: 30px; padding: 0 10px; }
|
header .chip { height: 30px; padding: 0 10px; }
|
||||||
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
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);
|
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||||
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
|
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;
|
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); }
|
letter-spacing: .5px; border-bottom: 0; color: var(--muted); }
|
||||||
@@ -338,9 +340,9 @@
|
|||||||
nav svg { display: block; width: 23px; height: 23px; }
|
nav svg { display: block; width: 23px; height: 23px; }
|
||||||
.bottombar, .drawer { display: none; }
|
.bottombar, .drawer { display: none; }
|
||||||
body { padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
|
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)); }
|
.toast { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
|
||||||
main, .page { gap: 16px; }
|
main, .page { gap: 16px; }
|
||||||
main { padding: 12px 12px 20px; }
|
main { padding: 12px calc(12px + env(safe-area-inset-right)) 20px calc(12px + env(safe-area-inset-left)); }
|
||||||
.panel { padding: 14px; }
|
.panel { padding: 14px; }
|
||||||
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
|
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
|
||||||
.toolbar { gap: 8px; }
|
.toolbar { gap: 8px; }
|
||||||
@@ -356,6 +358,8 @@
|
|||||||
button.small { height: 32px; }
|
button.small { height: 32px; }
|
||||||
.actions button { height: 46px; }
|
.actions button { height: 46px; }
|
||||||
.cat-tools select { max-width: none; flex: 1 1 100%; }
|
.cat-tools select { max-width: none; flex: 1 1 100%; }
|
||||||
|
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
|
||||||
|
body.typing nav { display: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -2120,6 +2124,10 @@ function showPage() {
|
|||||||
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
window.addEventListener("hashchange", showPage);
|
window.addEventListener("hashchange", showPage);
|
||||||
|
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
|
||||||
|
const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=checkbox], [type=radio], [type=file], [type=button])');
|
||||||
|
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
|
||||||
|
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
|
||||||
showPage();
|
showPage();
|
||||||
document.addEventListener("keydown", e => {
|
document.addEventListener("keydown", e => {
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
|
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
|
||||||
|
|||||||
Reference in new issue
Block a user