mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
865e8dc17f | ||
|
|
34a334fa27 |
No files matched your search
+1
-9
@@ -1,7 +1,7 @@
|
|||||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||||
// a free loopback port and shows it in a native window. The server does all the
|
// a free loopback port and shows it in a native window. The server does all the
|
||||||
// work over the `frame` SSH alias; this file only hosts it.
|
// work over the `frame` SSH alias; this file only hosts it.
|
||||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron");
|
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
|
||||||
const { execFile, spawn } = require("child_process");
|
const { execFile, spawn } = require("child_process");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -264,14 +264,6 @@ function fromUi(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||||
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
|
|
||||||
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
|
|
||||||
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
|
|
||||||
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
|
|
||||||
if (img.isEmpty()) throw new Error("not an image");
|
|
||||||
clipboard.writeImage(img);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||||
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
||||||
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||||
|
|||||||
+2
-3
@@ -2,8 +2,8 @@
|
|||||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||||
// as a title without zipping it (the local server reads it from there).
|
// as a title without zipping it (the local server reads it from there).
|
||||||
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
|
// It can open Set Up Connection when the headset can't be reached, and keeps the
|
||||||
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
|
// Frame menu's list of headsets up to date.
|
||||||
// It also receives frame-control://install links (docs/web-install.md): only
|
// It also receives frame-control://install links (docs/web-install.md): only
|
||||||
// what the link asked for, never an install; the page asks the user first.
|
// what the link asked for, never an install; the page asks the user first.
|
||||||
// And it passes update state both ways: see app/updater.js.
|
// And it passes update state both ways: see app/updater.js.
|
||||||
@@ -12,7 +12,6 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
|||||||
contextBridge.exposeInMainWorld("frameApp", {
|
contextBridge.exposeInMainWorld("frameApp", {
|
||||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
|
||||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||||
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
||||||
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
|||||||
sys.path.insert(0, str(ROOT / "ui"))
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
import frame_devices as fd # noqa: E402
|
import frame_devices as fd # noqa: E402
|
||||||
|
import frame_host # noqa: E402
|
||||||
|
|
||||||
CONFIG = """Host lxso1
|
CONFIG = """Host lxso1
|
||||||
HostName 192.168.1.109
|
HostName 192.168.1.109
|
||||||
@@ -274,7 +275,8 @@ class Pins(Base):
|
|||||||
def test_hashed_and_non_default_port_entries(self):
|
def test_hashed_and_non_default_port_entries(self):
|
||||||
kh = self.ssh / "known_hosts"
|
kh = self.ssh / "known_hosts"
|
||||||
kh.write_text(f"[frame.local]:2222 {KEY}\n")
|
kh.write_text(f"[frame.local]:2222 {KEY}\n")
|
||||||
subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
|
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
|
||||||
|
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||||
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
|
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
|
||||||
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
|
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
|
||||||
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
|
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
|
||||||
@@ -283,7 +285,8 @@ class Pins(Base):
|
|||||||
target = fd.known_hosts("d4")
|
target = fd.known_hosts("d4")
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.write_text(f"frame-control-d4 {KEY}\n")
|
target.write_text(f"frame-control-d4 {KEY}\n")
|
||||||
subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
|
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
|
||||||
|
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||||
self.assertNotIn("frame-control-d4", target.read_text())
|
self.assertNotIn("frame-control-d4", target.read_text())
|
||||||
self.assertTrue(fd.pinned("d4"))
|
self.assertTrue(fd.pinned("d4"))
|
||||||
self.assertTrue(fd.forget_pin("d4"))
|
self.assertTrue(fd.forget_pin("d4"))
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(android.FrameError, 'start this app'):
|
with self.assertRaisesRegex(android.FrameError, 'start this app'):
|
||||||
data.install_obb(PKG, [path])
|
data.install_obb(PKG, [path])
|
||||||
stream.assert_not_called()
|
stream.assert_not_called()
|
||||||
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
||||||
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
|
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
|
||||||
data._stream('command')
|
data._stream('command')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
|
||||||
|
import sandbox # noqa: F401
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
|
||||||
|
import frame_host
|
||||||
|
|
||||||
|
|
||||||
|
class CapturedSSH(unittest.TestCase):
|
||||||
|
def run_command(self, source, **kwargs):
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||||
|
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
|
||||||
|
|
||||||
|
def test_binary_output_and_input(self):
|
||||||
|
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
|
||||||
|
"sys.stderr.buffer.write(b'error\\r\\n')",
|
||||||
|
capture_output=True, input=b"data\x00\xff")
|
||||||
|
self.assertEqual(result.stdout, b"data\x00\xff")
|
||||||
|
self.assertEqual(result.stderr, b"error\r\n")
|
||||||
|
|
||||||
|
def test_text_output_normalizes_newlines(self):
|
||||||
|
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
|
||||||
|
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
|
||||||
|
capture_output=True, input="hello\n", text=True)
|
||||||
|
self.assertEqual(result.stdout, "hello\n")
|
||||||
|
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
|
||||||
|
|
||||||
|
def test_explicit_encoding_and_errors(self):
|
||||||
|
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
|
||||||
|
capture_output=True, encoding="ascii", errors="replace")
|
||||||
|
self.assertEqual(result.stderr, "\ufffd\ufffd")
|
||||||
|
|
||||||
|
def test_check_preserves_error_output(self):
|
||||||
|
with self.assertRaises(subprocess.CalledProcessError) as caught:
|
||||||
|
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
|
||||||
|
capture_output=True, text=True, check=True)
|
||||||
|
self.assertEqual(caught.exception.returncode, 7)
|
||||||
|
self.assertEqual(caught.exception.stdout, "out\n")
|
||||||
|
self.assertEqual(caught.exception.stderr, "err\n")
|
||||||
|
|
||||||
|
def test_timeout_preserves_partial_stderr(self):
|
||||||
|
with self.assertRaises(subprocess.TimeoutExpired) as caught:
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||||
|
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
|
||||||
|
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
|
||||||
|
capture_output=True, text=True, timeout=1)
|
||||||
|
self.assertEqual(caught.exception.stderr, b"waiting")
|
||||||
|
|
||||||
|
def test_streamed_stdout_is_kept_separate(self):
|
||||||
|
with tempfile.TemporaryFile() as output:
|
||||||
|
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
|
||||||
|
"sys.stderr.buffer.write(b'error')",
|
||||||
|
stdout=output, stderr=subprocess.PIPE)
|
||||||
|
output.seek(0)
|
||||||
|
self.assertEqual(output.read(), b"file")
|
||||||
|
self.assertIsNone(result.stdout)
|
||||||
|
self.assertEqual(result.stderr, b"error")
|
||||||
|
|
||||||
|
def test_uncaptured_windows_call_is_unchanged(self):
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
|
||||||
|
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||||
|
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||||
|
|
||||||
|
def test_posix_call_is_unchanged(self):
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
|
||||||
|
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||||
|
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||||
|
|
||||||
|
def test_capture_rejects_explicit_streams(self):
|
||||||
|
for stream in ("stdout", "stderr"):
|
||||||
|
with self.subTest(stream=stream), self.assertRaises(ValueError):
|
||||||
|
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
|
||||||
|
def test_real_ssh_failure_returns_stderr_without_hanging(self):
|
||||||
|
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
|
||||||
|
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
|
||||||
|
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
|
||||||
|
self.assertEqual(result.returncode, 255)
|
||||||
|
self.assertIn("Could not resolve hostname", result.stderr)
|
||||||
@@ -111,8 +111,6 @@ class ServerGuards(unittest.TestCase):
|
|||||||
("/api/volume", {"level": 1.5}),
|
("/api/volume", {"level": 1.5}),
|
||||||
("/api/clipboard", {"text": ""}),
|
("/api/clipboard", {"text": ""}),
|
||||||
("/api/open", {"what": "anything-else"}),
|
("/api/open", {"what": "anything-else"}),
|
||||||
("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}),
|
|
||||||
("/api/open", {"what": "shot"}),
|
|
||||||
("/api/shots/save", {"ids": []}),
|
("/api/shots/save", {"ids": []}),
|
||||||
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
||||||
("/api/shots/save", {"ids": [1]}),
|
("/api/shots/save", {"ids": [1]}),
|
||||||
@@ -123,10 +121,6 @@ class ServerGuards(unittest.TestCase):
|
|||||||
status, payload = self.post(path, body)
|
status, payload = self.post(path, body)
|
||||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
||||||
|
|
||||||
def test_showing_a_shot_needs_it_saved_here(self):
|
|
||||||
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
|
|
||||||
self.assertEqual(status, 404, payload)
|
|
||||||
|
|
||||||
def test_screenshot_ids_checked_before_ssh(self):
|
def test_screenshot_ids_checked_before_ssh(self):
|
||||||
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
|
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
|
||||||
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
||||||
|
|||||||
+3
-3
@@ -47,8 +47,8 @@ def ssh(cmd, input=None, timeout=120):
|
|||||||
try:
|
try:
|
||||||
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
|
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
|
||||||
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
|
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
|
||||||
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
||||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise FrameError(f'timed out talking to {FRAME}')
|
raise FrameError(f'timed out talking to {FRAME}')
|
||||||
if p.returncode != 0:
|
if p.returncode != 0:
|
||||||
@@ -120,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
|
|||||||
else:
|
else:
|
||||||
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise FrameError(f'copying {name} to the Frame timed out')
|
raise FrameError(f'copying {name} to the Frame timed out')
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ import tempfile
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import frame_android as android
|
import frame_android as android
|
||||||
|
import frame_host
|
||||||
|
|
||||||
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
||||||
|
|
||||||
|
|
||||||
def _stream(command, src=None, dst=None):
|
def _stream(command, src=None, dst=None):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||||
stdin=src if src else subprocess.DEVNULL,
|
stdin=src if src else subprocess.DEVNULL,
|
||||||
stdout=dst if dst else subprocess.PIPE,
|
stdout=dst if dst else subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE, timeout=1800)
|
stderr=subprocess.PIPE, timeout=1800)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise android.FrameError('app-data transfer timed out')
|
raise android.FrameError('app-data transfer timed out')
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
|
|||||||
+5
-3
@@ -24,6 +24,8 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import frame_host
|
||||||
|
|
||||||
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
||||||
USER_FROM_ENV = "FRAME_USER" in os.environ
|
USER_FROM_ENV = "FRAME_USER" in os.environ
|
||||||
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
||||||
@@ -349,9 +351,9 @@ def _write_config(host, port, user):
|
|||||||
|
|
||||||
def key_login_works():
|
def key_login_works():
|
||||||
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
|
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
|
||||||
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||||
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
||||||
capture_output=True).returncode == 0
|
capture_output=True).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def configured_user():
|
def configured_user():
|
||||||
|
|||||||
+4
-4
@@ -333,8 +333,8 @@ def remove_block(alias, path=None):
|
|||||||
def effective_port(alias, config):
|
def effective_port(alias, config):
|
||||||
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
|
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
||||||
stdin=subprocess.DEVNULL, timeout=10).stdout
|
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return 22
|
return 22
|
||||||
m = re.search(r"^port (\d+)$", out, re.M)
|
m = re.search(r"^port (\d+)$", out, re.M)
|
||||||
@@ -346,8 +346,8 @@ def effective_port(alias, config):
|
|||||||
|
|
||||||
def _keygen(*args):
|
def _keygen(*args):
|
||||||
try:
|
try:
|
||||||
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
timeout=10)
|
timeout=10)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+38
-14
@@ -5,12 +5,14 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
|
|||||||
CLI (used by the Electron app, so terminal handling lives in one place):
|
CLI (used by the Electron app, so terminal handling lives in one place):
|
||||||
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
||||||
"""
|
"""
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import ssl
|
import ssl
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
MAC = sys.platform == "darwin"
|
MAC = sys.platform == "darwin"
|
||||||
@@ -32,6 +34,41 @@ class HostError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def run_ssh(argv, **kwargs):
|
||||||
|
"""Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
|
||||||
|
|
||||||
|
A real temporary file avoids OpenSSH's blocked asynchronous stderr writes,
|
||||||
|
while keeping subprocess.run's captured output, text, check and timeout API.
|
||||||
|
"""
|
||||||
|
if not WINDOWS:
|
||||||
|
return subprocess.run(argv, **kwargs)
|
||||||
|
if kwargs.pop("capture_output", False):
|
||||||
|
if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
|
||||||
|
raise ValueError("stdout and stderr arguments may not be used with capture_output")
|
||||||
|
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
if kwargs.get("stderr") != subprocess.PIPE:
|
||||||
|
return subprocess.run(argv, **kwargs)
|
||||||
|
check = kwargs.pop("check", False)
|
||||||
|
text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors"))
|
||||||
|
with tempfile.TemporaryFile() as stderr:
|
||||||
|
kwargs["stderr"] = stderr
|
||||||
|
try:
|
||||||
|
result = subprocess.run(argv, **kwargs)
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
stderr.seek(0)
|
||||||
|
error.stderr = stderr.read()
|
||||||
|
raise
|
||||||
|
stderr.seek(0)
|
||||||
|
if text:
|
||||||
|
with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader:
|
||||||
|
result.stderr = reader.read()
|
||||||
|
else:
|
||||||
|
result.stderr = stderr.read()
|
||||||
|
if check:
|
||||||
|
result.check_returncode()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def data_dir(*parts):
|
def data_dir(*parts):
|
||||||
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
|
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
|
||||||
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
|
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
|
||||||
@@ -153,19 +190,6 @@ def open_path(path):
|
|||||||
stderr=subprocess.DEVNULL, **DETACHED)
|
stderr=subprocess.DEVNULL, **DETACHED)
|
||||||
|
|
||||||
|
|
||||||
def reveal_path(path):
|
|
||||||
"""Show a file selected in its folder (Linux file managers vary, so there the folder opens)."""
|
|
||||||
path = Path(path)
|
|
||||||
if MAC:
|
|
||||||
cmd = ["open", "-R", str(path)]
|
|
||||||
elif WINDOWS:
|
|
||||||
cmd = f'explorer /select,"{path}"' # as one string: Explorer wants the quotes after the comma
|
|
||||||
else:
|
|
||||||
return open_path(path.parent)
|
|
||||||
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL, **DETACHED)
|
|
||||||
|
|
||||||
|
|
||||||
open_url = open_path # the same openers hand URLs to the default browser
|
open_url = open_path # the same openers hand URLs to the default browser
|
||||||
|
|
||||||
|
|
||||||
@@ -244,7 +268,7 @@ def clipboard_text():
|
|||||||
def ssh_hostname(alias):
|
def ssh_hostname(alias):
|
||||||
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return alias
|
return alias
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
|
|||||||
+8
-8
@@ -74,8 +74,8 @@ def ssh_g(alias):
|
|||||||
"""(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an
|
"""(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an
|
||||||
ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
|
ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
timeout=10).stdout
|
timeout=10).stdout
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
out = ""
|
out = ""
|
||||||
got = {}
|
got = {}
|
||||||
@@ -765,8 +765,8 @@ class Link:
|
|||||||
if not self.control:
|
if not self.control:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
|
return frame_host.run_ssh([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
|
||||||
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
|
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -777,8 +777,8 @@ class Link:
|
|||||||
pending.kill()
|
pending.kill()
|
||||||
if self.control and self.alias:
|
if self.control and self.alias:
|
||||||
try:
|
try:
|
||||||
subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
|
frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
|
||||||
stdin=subprocess.DEVNULL, timeout=5)
|
stdin=subprocess.DEVNULL, timeout=5)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
pass
|
pass
|
||||||
if proc and proc.poll() is None:
|
if proc and proc.poll() is None:
|
||||||
@@ -983,8 +983,8 @@ class Link:
|
|||||||
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
|
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
|
||||||
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
|
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
errors="replace", timeout=20)
|
errors="replace", timeout=20)
|
||||||
err = r.stderr.strip()
|
err = r.stderr.strip()
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
rows[i].update(ssh="ok", detail=f"{lead} · SSH works")
|
rows[i].update(ssh="ok", detail=f"{lead} · SSH works")
|
||||||
|
|||||||
+28
-196
@@ -258,12 +258,7 @@
|
|||||||
.shot-card .row { flex-wrap: nowrap; }
|
.shot-card .row { flex-wrap: nowrap; }
|
||||||
.shot-card .grow { flex: 1; min-width: 0; }
|
.shot-card .grow { flex: 1; min-width: 0; }
|
||||||
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.shot-card .s { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.shot-card .s { color: var(--muted); font-size: 12px; }
|
||||||
.ctx-menu { position: fixed; z-index: 1000; min-width: 200px; padding: 4px; border-radius: 4px; background: #232c38;
|
|
||||||
box-shadow: 0 10px 28px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.08); }
|
|
||||||
.ctx-menu button { display: block; width: 100%; height: 30px; padding: 0 10px; text-align: left; background: none; }
|
|
||||||
.ctx-menu button:hover, .ctx-menu button:focus-visible { background: var(--blue); color: #fff; outline: none; }
|
|
||||||
.ctx-menu hr { border: 0; border-top: 1px solid rgba(255,255,255,.1); margin: 4px 2px; }
|
|
||||||
|
|
||||||
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
|
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
|
||||||
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
||||||
@@ -706,7 +701,6 @@
|
|||||||
<button class="action" id="shotBtn">Capture</button>
|
<button class="action" id="shotBtn">Capture</button>
|
||||||
<button id="liveBtn" title="Keep updating, as video">Live</button>
|
<button id="liveBtn" title="Keep updating, as video">Live</button>
|
||||||
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
|
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
|
||||||
<button id="copyBtn" disabled title="Copy the image to the clipboard (or right-click it)">Copy</button>
|
|
||||||
<button id="saveBtn" disabled>Save</button>
|
<button id="saveBtn" disabled>Save</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -873,7 +867,7 @@
|
|||||||
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
||||||
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to <code>~/Pictures/SteamFrame</code>. Right-click for more.</div>
|
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1875,7 +1869,7 @@ async function capture() {
|
|||||||
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
|
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
|
||||||
ctrlShow();
|
ctrlShow();
|
||||||
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
|
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
|
||||||
$("saveBtn").disabled = $("copyBtn").disabled = false;
|
$("saveBtn").disabled = false;
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log("Capture failed: " + e.message, "e");
|
log("Capture failed: " + e.message, "e");
|
||||||
@@ -1957,7 +1951,7 @@ async function startVideo() {
|
|||||||
$("viewer").classList.remove("busy");
|
$("viewer").classList.remove("busy");
|
||||||
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
|
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
|
||||||
$("srcBadge").hidden = false; $("srcBadge").textContent = `${video.label} · video`;
|
$("srcBadge").hidden = false; $("srcBadge").textContent = `${video.label} · video`;
|
||||||
$("stamp").hidden = false; $("saveBtn").disabled = $("copyBtn").disabled = false;
|
$("stamp").hidden = false; $("saveBtn").disabled = false;
|
||||||
}
|
}
|
||||||
frames++;
|
frames++;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
@@ -2026,35 +2020,6 @@ $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) :
|
|||||||
if (!b) return toast("Couldn't encode the image", true);
|
if (!b) return toast("Couldn't encode the image", true);
|
||||||
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
|
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
|
||||||
}, "image/png");
|
}, "image/png");
|
||||||
const copyViewer = () => act("Copy image", () => copyImage(lastShot ? lastShot.blob
|
|
||||||
: new Promise((ok, bad) => $("canvas").toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"))));
|
|
||||||
$("copyBtn").onclick = copyViewer;
|
|
||||||
// Right-click the viewer to copy or save what it shows (in Control, a right-click goes to the Frame).
|
|
||||||
$("canvas").oncontextmenu = e => {
|
|
||||||
if (ctrl.on || $("saveBtn").disabled) return;
|
|
||||||
e.preventDefault();
|
|
||||||
showMenu(e, [["Copy image", copyViewer], ["Save image", () => $("saveBtn").click()]]);
|
|
||||||
};
|
|
||||||
// An image onto the clipboard. The app does it natively (JPEG too); a browser
|
|
||||||
// takes PNG only, and the blob is handed over as a promise so the click still counts.
|
|
||||||
async function copyImage(blob) {
|
|
||||||
if (window.frameApp?.writeImage) {
|
|
||||||
if (!await window.frameApp.writeImage(new Uint8Array(await (await blob).arrayBuffer()))) throw new Error("the app refused the image");
|
|
||||||
return { message: "Copied the image" };
|
|
||||||
}
|
|
||||||
if (!navigator.clipboard?.write || !window.ClipboardItem) throw new Error("This browser can't copy images here");
|
|
||||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": Promise.resolve(blob).then(pngBlob) })]);
|
|
||||||
return { message: "Copied the image" };
|
|
||||||
}
|
|
||||||
async function pngBlob(blob) {
|
|
||||||
if (blob.type === "image/png") return blob;
|
|
||||||
const bmp = await createImageBitmap(blob);
|
|
||||||
const c = document.createElement("canvas");
|
|
||||||
c.width = bmp.width; c.height = bmp.height;
|
|
||||||
c.getContext("2d").drawImage(bmp, 0, 0);
|
|
||||||
bmp.close();
|
|
||||||
return new Promise((ok, bad) => c.toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"));
|
|
||||||
}
|
|
||||||
function download(blob, name) {
|
function download(blob, name) {
|
||||||
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
|
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -3532,11 +3497,8 @@ loadReports();
|
|||||||
api("/api/host").then(applyHostWording).catch(() => {});
|
api("/api/host").then(applyHostWording).catch(() => {});
|
||||||
|
|
||||||
// ---- Steam screenshots from the headset ----
|
// ---- Steam screenshots from the headset ----
|
||||||
// thumbs: id -> promise of an object URL, kept across reloads so a refresh
|
const shots = { list: [], urls: [] };
|
||||||
// that finds a new shot fetches only that one's thumbnail.
|
|
||||||
const shots = { list: [], sig: null, thumbs: new Map(), loading: null, gen: 0, fill: 0 };
|
|
||||||
const STEAMVR_APPID = "250820";
|
const STEAMVR_APPID = "250820";
|
||||||
const SHOTS_POLL_MS = 8000;
|
|
||||||
function shotApp(appid) {
|
function shotApp(appid) {
|
||||||
if (appid === STEAMVR_APPID) return "SteamVR";
|
if (appid === STEAMVR_APPID) return "SteamVR";
|
||||||
const g = state?.games?.find(x => x.appid === appid);
|
const g = state?.games?.find(x => x.appid === appid);
|
||||||
@@ -3548,85 +3510,35 @@ async function shotBlob(id, thumb) {
|
|||||||
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();
|
||||||
}
|
}
|
||||||
function shotThumb(id) {
|
async function loadShots() {
|
||||||
const thumbs = shots.thumbs; // this headset's: a late failure must not touch the next one's
|
$("shotsRefresh").disabled = true;
|
||||||
if (!thumbs.has(id)) {
|
|
||||||
const p = shotBlob(id, true).then(b => URL.createObjectURL(b));
|
|
||||||
p.catch(() => { if (thumbs.get(id) === p) thumbs.delete(id); }); // try again next time
|
|
||||||
thumbs.set(id, p);
|
|
||||||
}
|
|
||||||
return thumbs.get(id);
|
|
||||||
}
|
|
||||||
// quiet: a background check. It keeps what's shown if the Frame can't be read,
|
|
||||||
// and redraws only when the shots (or whether they're saved here) changed.
|
|
||||||
function loadShots(quiet) {
|
|
||||||
if (shots.loading) {
|
|
||||||
// A check already on its way will do for another check; Refresh, or a save
|
|
||||||
// that just finished, reads again after it so the answer is a fresh one.
|
|
||||||
const again = () => loadShots();
|
|
||||||
return quiet === true ? shots.loading : shots.loading.then(again, again);
|
|
||||||
}
|
|
||||||
const p = readShots(quiet === true).finally(() => { if (shots.loading === p) shots.loading = null; });
|
|
||||||
return shots.loading = p;
|
|
||||||
}
|
|
||||||
// Forget the shots of a headset we've switched away from, and ignore its answers still on their way.
|
|
||||||
function resetShots() {
|
|
||||||
closeMenu(); // its items were about the other headset's shot
|
|
||||||
shots.gen++; shots.fill++; shots.loading = null; shots.list = []; shots.sig = null;
|
|
||||||
for (const p of shots.thumbs.values()) p.then(URL.revokeObjectURL, () => {});
|
|
||||||
shots.thumbs = new Map();
|
|
||||||
}
|
|
||||||
async function readShots(quiet) {
|
|
||||||
const gen = shots.gen;
|
|
||||||
if (!quiet) $("shotsRefresh").disabled = true;
|
|
||||||
let got;
|
|
||||||
try {
|
try {
|
||||||
got = await api("/api/shots");
|
shots.list = (await api("/api/shots")).shots;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!quiet && gen === shots.gen) { shots.sig = null; failed($("shotGrid"), e); }
|
return failed($("shotGrid"), e);
|
||||||
return;
|
|
||||||
} finally { $("shotsRefresh").disabled = false; }
|
} finally { $("shotsRefresh").disabled = false; }
|
||||||
if (gen !== shots.gen) return;
|
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
||||||
({ shots: shots.list, folder: shots.folder } = got);
|
|
||||||
const sig = JSON.stringify(shots.list.map(s => [s.id, s.saved, shotApp(s.appid)]));
|
|
||||||
if (sig !== shots.sig) { shots.sig = sig; drawShots(); }
|
|
||||||
fillThumbs(quiet); // not waited for: the next check, or a save, needn't sit behind the thumbnails
|
|
||||||
}
|
|
||||||
// Thumbnails one at a time over the shared SSH connection. One that failed is
|
|
||||||
// tried again on Refresh, not by every background check.
|
|
||||||
async function fillThumbs(quiet) {
|
|
||||||
const run = ++shots.fill;
|
|
||||||
// Refresh puts every failed one back in line first, so a background pass that takes over carries them all on.
|
|
||||||
if (!quiet) for (const img of document.querySelectorAll("#shotGrid img[data-failed]")) delete img.dataset.failed;
|
|
||||||
for (const img of document.querySelectorAll("#shotGrid img[data-shot]:not([src])" + (quiet ? ":not([data-failed])" : ""))) {
|
|
||||||
const s = shots.list[+img.dataset.shot];
|
|
||||||
let url;
|
|
||||||
try { url = await shotThumb(s.id); } catch (e) { img.alt = "Preview failed"; img.dataset.failed = 1; }
|
|
||||||
if (run !== shots.fill) return; // a newer pass (after a check, a redraw or another headset) has taken over
|
|
||||||
if (url) img.src = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// "Sep 28, 10:37 PM": short enough to sit beside the card's buttons (the full date is its tooltip).
|
|
||||||
function shotTime(t) {
|
|
||||||
return new Date(t * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
||||||
}
|
|
||||||
function drawShots() {
|
|
||||||
const ids = new Set(shots.list.map(s => s.id));
|
|
||||||
for (const [id, p] of shots.thumbs) {
|
|
||||||
if (!ids.has(id)) { shots.thumbs.delete(id); p.then(URL.revokeObjectURL, () => {}); }
|
|
||||||
}
|
|
||||||
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 && !HOST.mobile ? ` · ${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 = HOST.mobile ? !shots.list.length : !unsaved;
|
$("shotsSaveNew").disabled = HOST.mobile ? !shots.list.length : !unsaved;
|
||||||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card" data-card="${i}">
|
$("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 (right-click for more)">
|
<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" title="${esc(new Date(s.time * 1000).toLocaleString())}">${esc(shotTime(s.time))}</div></div>
|
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
|
||||||
<button class="small" data-shot-copy="${i}" title="Copy the image to the clipboard">Copy</button>
|
|
||||||
${s.saved && !HOST.mobile ? `<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.
|
||||||
|
for (const img of document.querySelectorAll("#shotGrid img[data-shot]")) {
|
||||||
|
const s = shots.list[+img.dataset.shot];
|
||||||
|
try {
|
||||||
|
const url = URL.createObjectURL(await shotBlob(s.id, true));
|
||||||
|
shots.urls.push(url);
|
||||||
|
img.src = url;
|
||||||
|
} catch (e) { img.alt = "Preview failed"; }
|
||||||
|
if (!img.isConnected) return; // the list was reloaded meanwhile
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function openShot(s) {
|
async function openShot(s) {
|
||||||
if (live) toggleLive(false);
|
if (live) toggleLive(false);
|
||||||
@@ -3644,7 +3556,7 @@ async function openShot(s) {
|
|||||||
draw();
|
draw();
|
||||||
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
|
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
|
||||||
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
|
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
|
||||||
$("saveBtn").disabled = $("copyBtn").disabled = false;
|
$("saveBtn").disabled = false;
|
||||||
$("view").scrollIntoView({ behavior: "smooth" });
|
$("view").scrollIntoView({ behavior: "smooth" });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast("Couldn't open the screenshot: " + e.message, true);
|
toast("Couldn't open the screenshot: " + e.message, true);
|
||||||
@@ -3663,95 +3575,15 @@ async function saveShots(list, btn) {
|
|||||||
() => 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();
|
||||||
}
|
}
|
||||||
const copyShot = (s, btn) => act("Copy screenshot", () => copyImage(shotBlob(s.id, false)), btn);
|
|
||||||
$("shotGrid").onclick = e => {
|
$("shotGrid").onclick = e => {
|
||||||
const img = e.target.closest("img[data-shot]");
|
const img = e.target.closest("img[data-shot]");
|
||||||
if (img) return openShot(shots.list[+img.dataset.shot]);
|
if (img) return openShot(shots.list[+img.dataset.shot]);
|
||||||
const c = e.target.closest("[data-shot-copy]");
|
|
||||||
if (c) return copyShot(shots.list[+c.dataset.shotCopy], c);
|
|
||||||
const b = e.target.closest("[data-shot-save]");
|
const b = e.target.closest("[data-shot-save]");
|
||||||
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
|
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
|
||||||
};
|
};
|
||||||
// A small right-click menu. items: [label, fn] pairs, null for a divider.
|
$("shotsRefresh").onclick = loadShots;
|
||||||
// back: the keyboard closed it, so focus returns to where it was.
|
|
||||||
function closeMenu(back) {
|
|
||||||
const menu = document.querySelector(".ctx-menu");
|
|
||||||
if (!menu) return;
|
|
||||||
menu.remove();
|
|
||||||
if (back === true && menu.opener?.isConnected) menu.opener.focus();
|
|
||||||
}
|
|
||||||
function showMenu(e, items) {
|
|
||||||
closeMenu();
|
|
||||||
const menu = document.createElement("div");
|
|
||||||
menu.className = "ctx-menu";
|
|
||||||
menu.setAttribute("role", "menu");
|
|
||||||
menu.opener = document.activeElement;
|
|
||||||
for (const it of items) {
|
|
||||||
if (!it) { menu.append(document.createElement("hr")); continue; }
|
|
||||||
const b = document.createElement("button");
|
|
||||||
b.textContent = it[0];
|
|
||||||
b.setAttribute("role", "menuitem");
|
|
||||||
b.onclick = () => { closeMenu(); it[1](); };
|
|
||||||
menu.append(b);
|
|
||||||
}
|
|
||||||
(document.fullscreenElement || document.body).append(menu); // in front of a fullscreen viewer
|
|
||||||
const r = menu.getBoundingClientRect();
|
|
||||||
menu.style.left = Math.max(4, Math.min(e.clientX, innerWidth - r.width - 4)) + "px";
|
|
||||||
menu.style.top = Math.max(4, Math.min(e.clientY, innerHeight - r.height - 4)) + "px";
|
|
||||||
menu.querySelector("button").focus();
|
|
||||||
}
|
|
||||||
document.addEventListener("pointerdown", e => { if (!e.target.closest(".ctx-menu")) closeMenu(); }, true);
|
|
||||||
document.addEventListener("keydown", e => {
|
|
||||||
const menu = document.querySelector(".ctx-menu");
|
|
||||||
if (!menu) return;
|
|
||||||
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); return closeMenu(true); } // Tab doesn't wander off behind it
|
|
||||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
|
||||||
e.preventDefault(); // arrows move through the menu, not the page
|
|
||||||
const items = [...menu.querySelectorAll("button")];
|
|
||||||
const at = items.indexOf(document.activeElement), step = e.key === "ArrowDown" ? 1 : -1;
|
|
||||||
items[at < 0 ? (step > 0 ? 0 : items.length - 1) : (at + step + items.length) % items.length].focus();
|
|
||||||
});
|
|
||||||
addEventListener("blur", closeMenu);
|
|
||||||
document.addEventListener("fullscreenchange", closeMenu);
|
|
||||||
addEventListener("scroll", closeMenu, true);
|
|
||||||
// Right-click a screenshot for everything it can do.
|
|
||||||
$("shotGrid").oncontextmenu = e => {
|
|
||||||
const card = e.target.closest("[data-card]");
|
|
||||||
if (!card) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const s = shots.list[+card.dataset.card];
|
|
||||||
const items = [
|
|
||||||
["Open in viewer", () => openShot(s)],
|
|
||||||
["Copy image", () => copyShot(s)],
|
|
||||||
];
|
|
||||||
if (!s.saved || HOST.mobile) items.push(null, [`Save to ${HOST.computer}`, () => saveShots([s])]);
|
|
||||||
else {
|
|
||||||
const sep = shots.folder.includes("\\") ? "\\" : "/";
|
|
||||||
items.push(null);
|
|
||||||
items.push([`Show in ${!HOST.fileManager || HOST.fileManager === "your file manager" ? "folder" : HOST.fileManager}`,
|
|
||||||
() => act("Show screenshot", () => api("/api/open", { what: "shot", id: s.id }))]);
|
|
||||||
items.push(["Copy file path", () => act("Copy file path", async () => {
|
|
||||||
await copyText(shots.folder + sep + s.file);
|
|
||||||
return { message: "Copied the file path" };
|
|
||||||
})]);
|
|
||||||
}
|
|
||||||
items.push(null, ["Copy file name", () => act("Copy file name", async () => {
|
|
||||||
await copyText(s.file);
|
|
||||||
return { message: "Copied " + s.file };
|
|
||||||
})]);
|
|
||||||
showMenu(e, items);
|
|
||||||
};
|
|
||||||
function copyText(text) {
|
|
||||||
if (!navigator.clipboard?.writeText) throw new Error("This browser can't copy here");
|
|
||||||
return navigator.clipboard.writeText(text);
|
|
||||||
}
|
|
||||||
$("shotsRefresh").onclick = () => loadShots();
|
|
||||||
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
||||||
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
||||||
// Watch for new shots: a cheap listing over the shared SSH connection while the
|
|
||||||
// window is visible and the Frame is reachable, and again on coming back to it.
|
|
||||||
setInterval(() => { if (!document.hidden && online) loadShots(true); }, SHOTS_POLL_MS);
|
|
||||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online) loadShots(true); });
|
|
||||||
|
|
||||||
// ---- Panel switcher: our UI over SteamVR's panel API ----
|
// ---- Panel switcher: our UI over SteamVR's panel API ----
|
||||||
let panelSeq = 0;
|
let panelSeq = 0;
|
||||||
@@ -4345,7 +4177,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setView("headset");
|
setView("headset");
|
||||||
refresh().then(() => loadShots()); // after status, so app names resolve
|
refresh().then(loadShots); // after status, so app names resolve
|
||||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
@@ -4466,7 +4298,7 @@ function onConnection(s) {
|
|||||||
lastImg = null; lastShot = null;
|
lastImg = null; lastShot = null;
|
||||||
$("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true;
|
$("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true;
|
||||||
["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true);
|
["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true);
|
||||||
$("saveBtn").disabled = $("copyBtn").disabled = true;
|
$("saveBtn").disabled = true;
|
||||||
// Lists and their buttons (Remove, Launch…) were the other headset's: clear them
|
// Lists and their buttons (Remove, Launch…) were the other headset's: clear them
|
||||||
// before anyone clicks one, until the new headset's arrive.
|
// before anyone clicks one, until the new headset's arrive.
|
||||||
["games", "titleList", "andApps", "flatpaks", "shotGrid", "gmGrid"].forEach(id => {
|
["games", "titleList", "andApps", "flatpaks", "shotGrid", "gmGrid"].forEach(id => {
|
||||||
@@ -4475,7 +4307,7 @@ function onConnection(s) {
|
|||||||
disp.list = []; disp.port = null;
|
disp.list = []; disp.port = null;
|
||||||
// The lists behind those panels too, so filters can't bring the old ones back.
|
// The lists behind those panels too, so filters can't bring the old ones back.
|
||||||
gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.seq++;
|
gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.seq++;
|
||||||
androidApps = []; resetShots();
|
androidApps = []; shots.list = [];
|
||||||
titlesSeq++; disp.seq++; // answers to loads already on their way are ignored
|
titlesSeq++; disp.seq++; // answers to loads already on their way are ignored
|
||||||
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
|
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
|
||||||
// Confirmations still open were checked against the other headset.
|
// Confirmations still open were checked against the other headset.
|
||||||
|
|||||||
+5
-11
@@ -332,8 +332,8 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
|||||||
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
||||||
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
||||||
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
||||||
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
|
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||||
text=text, errors="replace" if text else None, timeout=timeout)
|
text=text, errors="replace" if text else None, timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"Timed out talking to {FRAME}")
|
raise Failure(f"Timed out talking to {FRAME}")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -503,8 +503,8 @@ def save_shots(body):
|
|||||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure("Copying screenshots timed out")
|
raise Failure("Copying screenshots timed out")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -1192,12 +1192,6 @@ def open_thing(body):
|
|||||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
frame_host.open_path(SHOTS_DIR)
|
frame_host.open_path(SHOTS_DIR)
|
||||||
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
||||||
if what == "shot":
|
|
||||||
saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
|
|
||||||
if not saved.exists():
|
|
||||||
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
|
||||||
frame_host.reveal_path(saved)
|
|
||||||
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
|
|
||||||
except frame_host.HostError as e:
|
except frame_host.HostError as e:
|
||||||
raise Failure(str(e), 500)
|
raise Failure(str(e), 500)
|
||||||
raise Failure("unknown target", 400)
|
raise Failure("unknown target", 400)
|
||||||
@@ -2252,7 +2246,7 @@ def push_file(path, dest="Downloads/"):
|
|||||||
else:
|
else:
|
||||||
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
||||||
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
||||||
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"Copying {name} timed out")
|
raise Failure(f"Copying {name} timed out")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
|
|||||||
Reference in new issue
Block a user