mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 10:02:26 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
810ef1822d |
No files matched your search
@@ -63,8 +63,8 @@ About 4,500 F-Droid apps rated for the Frame. One click installs each as its own
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**📁 Files, games and clipboard**<br>
|
||||
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
|
||||
**📁 Files and games**<br>
|
||||
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
@@ -72,8 +72,8 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
|
||||
**📸 Screenshots**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||
|
||||
**⌨️ Keyboard and trackpad**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere.
|
||||
**⌨️ Type on the headset**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -282,6 +282,12 @@ ipcMain.handle("server:restart", (e) => {
|
||||
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
|
||||
});
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
// Text from the headset's clipboard onto this computer's.
|
||||
ipcMain.handle("clipboard:write", (e, text) => {
|
||||
if (!fromUi(e) || typeof text !== "string") return false;
|
||||
clipboard.writeText(text);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("preferences:read", (e) => fromUi(e) ? preferences.read() : {});
|
||||
ipcMain.handle("preferences:write", (e, key, value) => fromUi(e) ? preferences.write(key, value) : false);
|
||||
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
|
||||
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
// Lets the page read this computer's clipboard through Electron, so sending it
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// Lets the page read and write this computer's clipboard through Electron, so sending
|
||||
// it to the Frame, and copying the Frame's back, 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
|
||||
// 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
|
||||
@@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld("frameApp", {
|
||||
},
|
||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
writeClipboard: (text) => ipcRenderer.invoke("clipboard:write", text),
|
||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||
|
||||
@@ -36,3 +36,28 @@ desktop has to be running in the headset. It's text only, and pastes over about
|
||||
|
||||
A simpler fallback: `ssh frame 'cat > ~/clip.txt'` < file, then open it in the
|
||||
headset.
|
||||
|
||||
### In Frame Control
|
||||
|
||||
**Tools → Type on the headset** (also on Home, and press `T` anywhere) has the
|
||||
clipboard next to the keyboard and trackpad, both ways:
|
||||
|
||||
- **Send text** or **Send this computer's clipboard** puts text on the headset's
|
||||
clipboard, as above.
|
||||
- **Copy the headset's clipboard** brings it back: copy something in a desktop
|
||||
app on the Frame, then press it, and paste on your computer. It calls
|
||||
Klipper's `getClipboardContents` on the same D-Bus bus, and sends the text
|
||||
back as base64, so accents, emoji and line breaks arrive unchanged. The
|
||||
desktop app and the iPhone app put it on their own clipboard. With the server
|
||||
in a browser, the server sets the clipboard of the computer it runs on:
|
||||
`pbcopy` on macOS, PowerShell's `Set-Clipboard` (or `clip.exe`) on Windows,
|
||||
`wl-copy`, `xclip` or `xsel` on Linux.
|
||||
|
||||
It's text only, and copying back takes up to 1 MB; a bigger clipboard is
|
||||
refused with a message, so send big things as a file. An empty clipboard, or
|
||||
one holding an image, says so. Neither the server nor its log ever records what
|
||||
was on the clipboard.
|
||||
|
||||
**Not tested on a headset yet.** Reading uses the same Klipper interface as
|
||||
sending, which was verified on 2026-09-25, but `getClipboardContents` itself
|
||||
hasn't been tried on a Frame. The fake Frame (`tests/fakeframe`) answers it.
|
||||
+11
-5
@@ -25,7 +25,7 @@ settings, reports), **Tools** (in the headset: your computer's windows, the medi
|
||||
player and the panel switcher; then sending files and text, Flatpaks, remote and
|
||||
power), **Devices** (your headsets and their addresses), and **Control** (the
|
||||
searchable Control Center, metrics, supported settings and headset guidance).
|
||||
Keys 1–6 switch between them. The gear in the header (key 7) opens **Settings**:
|
||||
Keys 1–6 switch between them, and `T` opens Type on the headset. The gear in the header (key 7) opens **Settings**:
|
||||
privacy and updates, library artwork and the assistant. The battery chip opens
|
||||
the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS
|
||||
build. Files can be dropped anywhere in the window. A
|
||||
@@ -80,8 +80,14 @@ counts them while they run.
|
||||
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
|
||||
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
|
||||
Runtime picked from the program's header), listed under **Sideloaded titles**
|
||||
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
|
||||
Frame clipboard.
|
||||
with Launch and Remove; see [sideloading.md](sideloading.md).
|
||||
- **Type on the headset** (on Home, in Tools, in the Control Center, or press
|
||||
`T`): use your computer's keyboard and mouse, or a phone's touchscreen, in the
|
||||
Frame's apps through KDE Connect, and move clipboard text both ways. Send
|
||||
typed text or your computer's clipboard to the headset, or copy the headset's
|
||||
clipboard back: copy in a desktop app on the Frame, then **Copy the headset's
|
||||
clipboard**. The clipboard needs the desktop panel open in the headset; it's
|
||||
text only, up to 1 MB. See [file-transfer.md](file-transfer.md#clipboard).
|
||||
- **Mac in the headset** (macOS): show any Mac window, or a whole screen, as
|
||||
its own panel in the headset. Place it with the SteamVR dashboard, click and
|
||||
scroll with the laser, and type on the Mac. Streams hardware H.264 through
|
||||
@@ -220,8 +226,8 @@ Google publishes no arm64 Linux platform-tools. Set Up Connection runs
|
||||
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
|
||||
and others). The log is at
|
||||
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
|
||||
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
|
||||
`xclip` (X11).
|
||||
instead of the app, sending the clipboard, or copying the headset's to this
|
||||
computer, needs `wl-clipboard` (Wayland), `xclip` or `xsel` (X11).
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
+3
-2
@@ -102,8 +102,9 @@ 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.
|
||||
Sending text to the Frame's clipboard, or copying the Frame's to the phone
|
||||
(**Type on the headset → Copy the headset'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
|
||||
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ capture it.
|
||||
|
||||
## Input: type and point in the Frame from the Mac or iPhone
|
||||
|
||||
**Built: Home → Keyboard and trackpad**, in every version of Frame Control
|
||||
**Built: Home → Type on the headset** (or press `T`), in every version of Frame Control
|
||||
(Mac, Windows, Linux, iPhone and iPad), with nothing to install on the device
|
||||
you're holding. On a phone the panel is a trackpad (drag to move, tap to click,
|
||||
two fingers to scroll, two-finger tap to right-click) plus a text field that
|
||||
|
||||
@@ -55,6 +55,7 @@ struct WebShell: UIViewRepresentable {
|
||||
},
|
||||
notify: (message, request) => call("notify", { message, request }),
|
||||
readClipboard: () => call("readClipboard"),
|
||||
writeClipboard: (text) => call("writeClipboard", text),
|
||||
setUpConnection: () => call("setUpConnection"),
|
||||
open: (what) => call("open", what),
|
||||
saveImages: (images) => call("saveImages", images),
|
||||
@@ -143,6 +144,10 @@ struct WebShell: UIViewRepresentable {
|
||||
replyHandler(true, nil)
|
||||
case "readClipboard":
|
||||
replyHandler(UIPasteboard.general.string ?? "", nil)
|
||||
case "writeClipboard":
|
||||
guard let text = arg as? String else { return replyHandler(nil, "Expected text") }
|
||||
UIPasteboard.general.string = text
|
||||
replyHandler(true, nil)
|
||||
case "setUpConnection":
|
||||
model.showSetup()
|
||||
replyHandler(nil, nil)
|
||||
|
||||
@@ -45,6 +45,13 @@ class Device(harness.FrameTestCase):
|
||||
self.assertRegex(out['message'], r'^copied via Klipper \(\d+ chars\)$')
|
||||
self.assertEqual(state()['clipboard'], [text])
|
||||
|
||||
def test_clipboard_comes_back_from_klipper(self):
|
||||
text = 'from the headset: 世界 🎧\n\nwith trailing newlines\n\n'
|
||||
ok('POST', '/api/clipboard', {'text': text})
|
||||
out = ok('POST', '/api/clipboard', {'read': True})
|
||||
self.assertEqual(out['text'], text)
|
||||
self.assertRegex(out['message'], r"^Copied \d+ characters from the headset's clipboard$")
|
||||
|
||||
def test_flatpak_install_and_remove(self):
|
||||
# Installs run as background jobs (server.start_job); uninstall answers at once.
|
||||
job = finished(ok('POST', '/api/flatpak', {'id': 'org.videolan.VLC', 'action': 'install'}))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stub for qdbus6: records Klipper setClipboardContents calls, the way
|
||||
Frame Control sends text to the headset desktop's clipboard (server.PASTE,
|
||||
verified 2026-09-25)."""
|
||||
verified 2026-09-25), and answers getClipboardContents with the last one
|
||||
(server.COPY). Printing the string and a newline is how qdbus prints a
|
||||
returned QString; getClipboardContents itself is a guess, not seen on a headset."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -15,5 +17,10 @@ if args[:3] == ['org.kde.klipper', '/klipper', 'org.kde.klipper.klipper.setClipb
|
||||
sys.exit('Could not connect to D-Bus server')
|
||||
with fs.update() as s:
|
||||
s['clipboard'].append(args[3])
|
||||
elif args == ['org.kde.klipper', '/klipper', 'org.kde.klipper.klipper.getClipboardContents']:
|
||||
if not os.environ.get('DBUS_SESSION_BUS_ADDRESS'):
|
||||
sys.exit('Could not connect to D-Bus server')
|
||||
clips = fs.read()['clipboard']
|
||||
sys.stdout.buffer.write((clips[-1] if clips else '').encode() + b'\n')
|
||||
else:
|
||||
sys.exit(f'qdbus6 stub: unsupported {args}')
|
||||
@@ -0,0 +1,241 @@
|
||||
"""The clipboard both ways: the headset's read script, the server's answers, and the
|
||||
writer for this computer's clipboard on each platform. No headset: SSH and every
|
||||
clipboard program are mocked, and the read script runs against stub pgrep and qdbus6."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import base64
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import frame_host # noqa: E402
|
||||
import server # noqa: E402
|
||||
|
||||
TEXT = "héllo, 世界 🎧\nsecond line\n\n" # accents, CJK, an emoji outside the BMP, trailing newlines
|
||||
|
||||
|
||||
def b64(text):
|
||||
return base64.b64encode(text.encode()).decode()
|
||||
|
||||
|
||||
class ReadCommand(unittest.TestCase):
|
||||
def test_reads_klipper_on_the_desktop_bus(self):
|
||||
self.assertIn("org.kde.klipper.klipper.getClipboardContents", server.COPY)
|
||||
self.assertIn("DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper", server.COPY)
|
||||
self.assertIn(server.DESKTOP_CLOSED, server.COPY)
|
||||
self.assertIn(server.CLIPBOARD_TOO_BIG, server.COPY)
|
||||
self.assertIn(f"-gt {server.CLIPBOARD_LIMIT + 1} ]", server.COPY) # the limit, plus qdbus6's newline
|
||||
self.assertIn("base64 -w 0", server.COPY)
|
||||
|
||||
def test_command_carries_the_script_intact(self):
|
||||
encoded = server.COPY_CMD.split("echo ", 1)[1].split(" |", 1)[0]
|
||||
self.assertEqual(base64.b64decode(encoded).decode(), server.COPY)
|
||||
self.assertNotIn("'", server.COPY_CMD)
|
||||
|
||||
def test_send_still_uses_the_same_bus(self):
|
||||
self.assertIn(server.DESKTOP_BUS, server.PASTE)
|
||||
self.assertIn(server.DESKTOP_BUS, server.COPY)
|
||||
self.assertIn("setClipboardContents", server.PASTE)
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith("linux") and shutil.which("bash") and shutil.which("base64"),
|
||||
"the script reads /proc, as on the Frame")
|
||||
class ReadScript(unittest.TestCase):
|
||||
"""server.COPY for real, with plasmashell's pid and Klipper stubbed."""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp(prefix="frame-clip-"))
|
||||
self.addCleanup(shutil.rmtree, self.dir, True)
|
||||
self.clip = self.dir / "clip"
|
||||
# A process whose environment holds the desktop's bus address, standing in for plasmashell.
|
||||
self.plasma = subprocess.Popen(["sleep", "60"], env={**os.environ, "DBUS_SESSION_BUS_ADDRESS": "unix:path=/fake"})
|
||||
self.addCleanup(self.plasma.wait)
|
||||
self.addCleanup(self.plasma.kill)
|
||||
(self.dir / "pgrep").write_text(f'#!/bin/sh\n[ -e "{self.dir}/closed" ] || echo {self.plasma.pid}\n')
|
||||
(self.dir / "qdbus6").write_text(
|
||||
'#!/bin/sh\n[ "$DBUS_SESSION_BUS_ADDRESS" = unix:path=/fake ] || exit 1\n'
|
||||
f'[ "$3" = org.kde.klipper.klipper.getClipboardContents ] || exit 1\ncat "{self.clip}"; echo\n')
|
||||
for name in ("pgrep", "qdbus6"):
|
||||
(self.dir / name).chmod(0o755)
|
||||
|
||||
def run_script(self):
|
||||
def local_ssh(remote, **kw):
|
||||
r = subprocess.run(["bash", "-c", server.COPY], capture_output=True, text=True,
|
||||
env={**os.environ, "PATH": f"{self.dir}:{os.environ['PATH']}"})
|
||||
if r.returncode:
|
||||
raise server.Failure(r.stderr.strip())
|
||||
return r.stdout
|
||||
with mock.patch.object(server, "ssh", local_ssh):
|
||||
return server.headset_clipboard()
|
||||
|
||||
def test_unicode_and_newlines_come_back_exactly(self):
|
||||
self.clip.write_bytes(TEXT.encode())
|
||||
self.assertEqual(self.run_script(), TEXT)
|
||||
|
||||
def test_empty(self):
|
||||
self.clip.write_bytes(b"")
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.run_script()
|
||||
self.assertEqual(cm.exception.status, 400)
|
||||
self.assertIn("empty", str(cm.exception))
|
||||
|
||||
def test_too_big_is_refused_on_the_headset(self):
|
||||
self.clip.write_bytes(b"x" * (server.CLIPBOARD_LIMIT + 1))
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.run_script()
|
||||
self.assertEqual(cm.exception.status, 413)
|
||||
self.assertIn("1 MB", str(cm.exception))
|
||||
self.clip.write_bytes(b"x" * server.CLIPBOARD_LIMIT)
|
||||
self.assertEqual(len(self.run_script()), server.CLIPBOARD_LIMIT)
|
||||
|
||||
def test_desktop_closed(self):
|
||||
(self.dir / "closed").touch()
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.run_script()
|
||||
self.assertEqual(cm.exception.status, 409)
|
||||
|
||||
|
||||
class HeadsetClipboard(unittest.TestCase):
|
||||
def read(self, out=None, error=None):
|
||||
def fake_ssh(remote, **kw):
|
||||
self.assertEqual(remote, server.COPY_CMD)
|
||||
self.assertNotIn("stdin", kw)
|
||||
if error:
|
||||
raise server.Failure(error)
|
||||
return out
|
||||
with mock.patch.object(server, "ssh", fake_ssh):
|
||||
return server.headset_clipboard()
|
||||
|
||||
def test_drops_only_qdbus6s_newline(self):
|
||||
self.assertEqual(self.read(b64(TEXT + "\n") + "\n"), TEXT)
|
||||
self.assertEqual(self.read(b64("one line\n")), "one line")
|
||||
|
||||
def test_empty_and_images_say_so(self):
|
||||
for out, words in ((b64("\n"), "empty"), ("", "empty"), (b64("▨ 640x480 32bpp\n"), "image")):
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.read(out)
|
||||
self.assertEqual(cm.exception.status, 400)
|
||||
self.assertIn(words, str(cm.exception))
|
||||
|
||||
def test_unreadable_output_is_a_fault(self):
|
||||
for out in ("not base64!", base64.b64encode(b"\xff\xfe\n").decode()):
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.read(out)
|
||||
self.assertEqual(cm.exception.status, 502)
|
||||
|
||||
def test_failures(self):
|
||||
for error, status in (("The headset's desktop isn't open (plasmashell is not running).", 409),
|
||||
("The headset's clipboard holds too much to copy (over 1 MB).", 413),
|
||||
("Klipper call failed (bus: none)", 502)):
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.read(error=error)
|
||||
self.assertEqual(cm.exception.status, status, error)
|
||||
|
||||
|
||||
class ClipboardRequests(unittest.TestCase):
|
||||
def test_read_returns_the_text_for_the_page(self):
|
||||
with mock.patch.object(server, "headset_clipboard", return_value=TEXT):
|
||||
out = server.clipboard({"read": True})
|
||||
self.assertEqual(out["text"], TEXT)
|
||||
self.assertNotIn(TEXT, out["message"])
|
||||
|
||||
def test_to_computer_sets_this_computers_clipboard(self):
|
||||
with mock.patch.object(server, "headset_clipboard", return_value=TEXT), \
|
||||
mock.patch.object(frame_host, "set_clipboard_text") as put:
|
||||
out = server.clipboard({"toComputer": True})
|
||||
put.assert_called_once_with(TEXT)
|
||||
self.assertNotIn("text", out)
|
||||
self.assertEqual(out["message"], f"Copied {len(TEXT)} characters from the headset to this computer's clipboard")
|
||||
|
||||
def test_to_computer_without_a_clipboard_program(self):
|
||||
with mock.patch.object(server, "headset_clipboard", return_value=TEXT), \
|
||||
mock.patch.object(frame_host, "set_clipboard_text", side_effect=frame_host.HostError("Can't set the clipboard")):
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
server.clipboard({"toComputer": True})
|
||||
self.assertEqual(cm.exception.status, 500)
|
||||
|
||||
def test_to_computer_refused_when_the_server_runs_on_the_headset(self):
|
||||
with mock.patch.object(server, "LOCAL", True), mock.patch.object(server, "headset_clipboard") as read:
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
server.clipboard({"toComputer": True})
|
||||
self.assertEqual(cm.exception.status, 400)
|
||||
read.assert_not_called()
|
||||
|
||||
def test_flags_must_be_true(self):
|
||||
# Anything else is a send, and an empty one is refused before SSH.
|
||||
with mock.patch.object(server, "ssh") as ssh:
|
||||
for body in ({"read": "yes"}, {"toComputer": 1}):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.clipboard(body)
|
||||
ssh.assert_not_called()
|
||||
|
||||
|
||||
class SetClipboardText(unittest.TestCase):
|
||||
def put(self, platform, have, text=TEXT, fail=()):
|
||||
runs = []
|
||||
|
||||
def run(cmd, **kw):
|
||||
runs.append((cmd, kw))
|
||||
if cmd[0] in fail:
|
||||
if fail[cmd[0]] == "timeout":
|
||||
raise subprocess.TimeoutExpired(cmd, 10)
|
||||
return subprocess.CompletedProcess(cmd, 1)
|
||||
return subprocess.CompletedProcess(cmd, 0)
|
||||
flags = {"MAC": platform == "mac", "WINDOWS": platform == "windows", "LINUX": platform == "linux"}
|
||||
with mock.patch.multiple(frame_host, **flags), \
|
||||
mock.patch.object(frame_host.shutil, "which", lambda name: f"/bin/{name}" if name in have else None), \
|
||||
mock.patch.object(frame_host.subprocess, "run", run):
|
||||
frame_host.set_clipboard_text(text)
|
||||
for _, kw in runs:
|
||||
# Nothing captured or logged, and no pipes left for a clipboard daemon to hold open.
|
||||
self.assertEqual((kw["stdout"], kw["stderr"]), (subprocess.DEVNULL, subprocess.DEVNULL))
|
||||
return runs
|
||||
|
||||
def test_mac_pbcopy_in_utf8(self):
|
||||
[(cmd, kw)] = self.put("mac", {"pbcopy"})
|
||||
self.assertEqual(cmd, ["pbcopy"])
|
||||
self.assertEqual(kw["input"], TEXT.encode("utf-8"))
|
||||
self.assertEqual(kw["env"]["LC_CTYPE"], "UTF-8")
|
||||
|
||||
def test_windows_powershell_gets_base64_utf8(self):
|
||||
[(cmd, kw)] = self.put("windows", {"powershell.exe", "clip.exe"})
|
||||
self.assertEqual(cmd[0], "powershell.exe")
|
||||
self.assertIn("Set-Clipboard", cmd[-1])
|
||||
self.assertIn("FromBase64String", cmd[-1])
|
||||
self.assertEqual(base64.b64decode(kw["input"]).decode("utf-8"), TEXT)
|
||||
|
||||
def test_windows_clip_fallback_gets_utf16_with_a_bom(self):
|
||||
runs = self.put("windows", {"powershell.exe", "clip.exe"}, fail={"powershell.exe": 1})
|
||||
self.assertEqual([c[0] for c, _ in runs], ["powershell.exe", "clip.exe"])
|
||||
data = runs[1][1]["input"]
|
||||
self.assertEqual(data[:2], b"\xff\xfe")
|
||||
self.assertEqual(data[2:].decode("utf-16-le"), TEXT)
|
||||
|
||||
def test_linux_tries_wayland_then_x11(self):
|
||||
[(cmd, kw)] = self.put("linux", {"wl-copy", "xclip", "xsel"})
|
||||
self.assertEqual(cmd, ["wl-copy"])
|
||||
self.assertEqual(kw["input"], TEXT.encode())
|
||||
runs = self.put("linux", {"wl-copy", "xclip", "xsel"}, fail={"wl-copy": 1, "xclip": "timeout"})
|
||||
self.assertEqual([c for c, _ in runs], [["wl-copy"], ["xclip", "-selection", "clipboard", "-in"],
|
||||
["xsel", "--clipboard", "--input"]])
|
||||
[(cmd, _)] = self.put("linux", {"xsel"})
|
||||
self.assertEqual(cmd[0], "xsel")
|
||||
|
||||
def test_nothing_to_set_it_with(self):
|
||||
with self.assertRaises(frame_host.HostError) as cm:
|
||||
self.put("linux", set())
|
||||
self.assertIn("wl-clipboard", str(cm.exception))
|
||||
with self.assertRaises(frame_host.HostError):
|
||||
self.put("linux", {"wl-copy"}, fail={"wl-copy": 1})
|
||||
with self.assertRaises(frame_host.HostError):
|
||||
self.put("mac", set())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Type on the headset: entry points, the T key and the clipboard copy, without a browser or headset."""
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises the page code')
|
||||
class TypingUi(unittest.TestCase):
|
||||
def test_behavior(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(['node', '--test', str(root / 'tests/typing_ui.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
@@ -0,0 +1,114 @@
|
||||
// Type on the headset: where people find it, the T key, and copying the headset's clipboard.
|
||||
// Production page code with only browser and transport boundaries replaced; no headset.
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const cc=require('../ui/control-center.js');
|
||||
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
|
||||
|
||||
const between=(from,to)=>{const start=html.indexOf(from),end=html.indexOf(to,start);assert.ok(start>=0&&end>start,from);return html.slice(start,end);};
|
||||
const section=id=>between(`id="${id}"`,'</section>');
|
||||
|
||||
test('Home, the Right now card and the Tools directory all name it Type on the headset',()=>{
|
||||
const home=between('data-page="home"','data-page="capture"');
|
||||
assert.match(home,/<a href="#pad" title="Type on the headset \(T\)"><span>Type on the headset<\/span><small>Keyboard, trackpad and clipboard<\/small>/);
|
||||
assert.match(home,/<button data-go="pad" title="Type on the headset \(T\)">Type on the headset<\/button>/);
|
||||
assert.match(between('id="toolDirectory"','<h2>Your Frame</h2>'),/href="#pad"[^>]*><span>Type on the headset<\/span>/);
|
||||
assert.doesNotMatch(html,/Keyboard &(amp;)? trackpad<\/span>|Type on the Frame</);
|
||||
});
|
||||
test('the clipboard, both ways, lives beside the keyboard, not in Send to Frame',()=>{
|
||||
const pad=section('pad');
|
||||
for(const id of ['clipText','clipSend','clipMac','clipFrom'])assert.match(pad,new RegExp(`id="${id}"`),id);
|
||||
assert.match(pad,/Text only, up to 1 MB/);
|
||||
const transfer=section('transfer');
|
||||
assert.doesNotMatch(transfer,/id="clip/);
|
||||
assert.match(transfer,/<a href="#pad">Type on the headset<\/a>/);
|
||||
});
|
||||
test('the tool page is titled Type on the headset',()=>{
|
||||
const f=workspaceFixture();
|
||||
f.navigate('#pad');
|
||||
assert.equal(f.activePage,'tools');assert.equal(f.el('pad').hidden,false);
|
||||
assert.equal(f.el('toolTitle').textContent,'Type on the headset');
|
||||
assert.equal(f.el('transfer').hidden,true);
|
||||
});
|
||||
|
||||
function keyFixture() {
|
||||
const f=workspaceFixture(),keys=[];let focused=0,started=0;
|
||||
Object.assign(f.context,{pad:{state:'off'},padStart(){started++;},ctrl:{on:false},PAGES:['apps','games','tools','settings'],
|
||||
history:{pushState(_,__,url){f.context.location.hash=url;}}});
|
||||
f.context.document.addEventListener=(type,fn)=>{if(type==='keydown')keys.push(fn);};
|
||||
f.context.document.querySelector=()=>null;
|
||||
f.context.document.activeElement={tagName:'BODY'};
|
||||
f.el('padType').focus=()=>{focused++;};
|
||||
f.run(between('function showNow(','$("nowSend").onclick'));
|
||||
f.run(between('document.addEventListener("keydown", e => {\n if (e.metaKey || e.ctrlKey || e.altKey','// ---- after an APK install'));
|
||||
const press=(key,extra={})=>{let prevented=false;keys.forEach(fn=>fn({key,preventDefault(){prevented=true;},...extra}));return prevented;};
|
||||
return {f,press,get focused(){return focused;},get started(){return started;}};
|
||||
}
|
||||
test('T opens Type on the headset, turns the keyboard on and puts the cursor in its field',()=>{
|
||||
const k=keyFixture();
|
||||
k.f.navigate('#games');
|
||||
assert.equal(k.press('t'),true,'the T must not also be typed into the field it focuses');
|
||||
assert.equal(k.f.context.location.hash,'#pad');assert.equal(k.f.activePage,'tools');
|
||||
assert.equal(k.started,1);assert.equal(k.focused,1);
|
||||
k.f.context.pad.state='ready';k.press('T');assert.equal(k.started,1,'already on: not started again');
|
||||
});
|
||||
test('T stays out of the way of typing, shortcuts and keys going to the Frame',()=>{
|
||||
const k=keyFixture();
|
||||
k.f.navigate('#games');
|
||||
k.press('t',{metaKey:true});k.press('t',{ctrlKey:true});k.press('t',{altKey:true});
|
||||
k.f.context.document.activeElement={tagName:'INPUT'};k.press('t');
|
||||
k.f.context.document.activeElement={tagName:'BODY'};k.f.context.document.querySelector=s=>s==='dialog[open]'?{}:null;k.press('t');
|
||||
k.f.context.document.querySelector=()=>null;
|
||||
k.f.context.ctrl.on=true;k.f.context.document.activeElement=k.f.el('viewer');k.press('t');
|
||||
assert.equal(k.f.context.location.hash,'#games');assert.equal(k.started,0);
|
||||
k.f.context.document.activeElement={tagName:'BODY'};k.press('3');assert.equal(k.f.context.location.hash,'#tools','number keys still switch pages');
|
||||
});
|
||||
test('Home’s button goes through the same path, showing the tool once before focusing it',()=>{
|
||||
const f=workspaceFixture(),buttons=[{dataset:{go:'pad'}},{dataset:{go:'media'}}];let focusedVisible=null;
|
||||
f.context.document.querySelectorAll=(q=>s=>s==='[data-go]'?buttons:q(s))(f.context.document.querySelectorAll);
|
||||
Object.assign(f.context,{pad:{state:'error'},padStart(){f.calls.push(['padStart']);},history:{pushState(_,__,url){f.context.location.hash=url;}}});
|
||||
f.el('padType').focus=()=>{focusedVisible=f.activePage==='tools'&&!f.el('pad').hidden;};
|
||||
f.navigate('#home');f.run(between('function showNow(','$("nowSend").onclick'));
|
||||
buttons[0].onclick();
|
||||
assert.equal(focusedVisible,true);assert.equal(f.calls.filter(c=>c[0]==='padStart').length,1);
|
||||
buttons[1].onclick();assert.equal(f.context.location.hash,'#media');
|
||||
});
|
||||
|
||||
function copyFixture(frameApp) {
|
||||
const calls=[],f=workspaceFixture();
|
||||
Object.assign(f.context,{window:{frameApp},api:async(path,body)=>{calls.push(JSON.parse(JSON.stringify([path,body])));return body.read?{text:'héllo 🎧\n',message:'Copied 8 characters from the headset\'s clipboard'}:{message:'Copied to this computer'};}});
|
||||
f.run(between('async function copyHeadsetClipboard()','$("clipFrom").onclick'));
|
||||
return {f,calls};
|
||||
}
|
||||
test('in the app, the headset’s clipboard is read and the app writes it, text untouched',async()=>{
|
||||
const written=[],{f,calls}=copyFixture({writeClipboard:async text=>{written.push(text);return true;}});
|
||||
const result=await f.run('copyHeadsetClipboard()');
|
||||
assert.deepEqual(calls,[['/api/clipboard',{read:true}]]);
|
||||
assert.deepEqual(written,['héllo 🎧\n']);
|
||||
assert.equal(result.message,'Copied 8 characters from the headset\'s clipboard');
|
||||
assert.equal(result.text,undefined,'the text is not handed on to be logged or toasted');
|
||||
});
|
||||
test('in a browser, the server sets the clipboard of the computer it runs on',async()=>{
|
||||
for(const frameApp of [undefined,{readClipboard(){}}]){
|
||||
const {f,calls}=copyFixture(frameApp);
|
||||
assert.equal((await f.run('copyHeadsetClipboard()')).message,'Copied to this computer');
|
||||
assert.deepEqual(calls,[['/api/clipboard',{toComputer:true}]]);
|
||||
}
|
||||
});
|
||||
test('a failed read writes nothing',async()=>{
|
||||
const written=[],f=workspaceFixture();
|
||||
Object.assign(f.context,{window:{frameApp:{writeClipboard:async t=>written.push(t)}},api:async()=>{throw new Error("The headset's clipboard is empty");}});
|
||||
f.run(between('async function copyHeadsetClipboard()','$("clipFrom").onclick'));
|
||||
await assert.rejects(f.run('copyHeadsetClipboard()'),/empty/);
|
||||
assert.deepEqual(written,[]);
|
||||
});
|
||||
|
||||
test('the Control Center finds Type on the headset by typing, keyboard and clipboard words',()=>{
|
||||
const input=cc.catalog.find(item=>item.id==='input');
|
||||
assert.equal(input.title,'Type on the headset');
|
||||
assert.deepEqual(input.links[0],['#pad','Type on the headset']);
|
||||
for(const query of ['type','typing','keyboard','clipboard','copy paste','kde connect'])
|
||||
assert.ok(cc.matches(input,query,'all'),query);
|
||||
const files=cc.catalog.find(item=>item.id==='files');
|
||||
assert.ok(files.links.some(([href])=>href==='#pad'),'Send files points clipboard seekers to the right place');
|
||||
});
|
||||
@@ -68,9 +68,9 @@
|
||||
{id:'sideload', category:'apps', icon:'game', title:'Sideloaded games', kind:'flow', label:'Existing controls',
|
||||
description:'Send a game folder, ZIP or executable. Frame Control selects Proton or the Linux runtime and adds a Steam library entry.',
|
||||
keywords:'windows exe zip linux devkit runtime shortcuts launch remove', links:[['#sideloaded','Manage titles'],['#transfer','Send a game']]},
|
||||
{id:'files', category:'apps', icon:'files', title:'Files & clipboard', kind:'flow', label:'Existing controls',
|
||||
description:'Send files to ~/Downloads, or send text to the Frame’s clipboard. Clipboard transfer needs the desktop panel open.',
|
||||
keywords:'upload transfer downloads paste copy text sftp storage', links:[['#transfer','Send files & text']]},
|
||||
{id:'files', category:'apps', icon:'files', title:'Send files', kind:'flow', label:'Existing controls',
|
||||
description:'Send files to ~/Downloads, or a game to the Steam library. Clipboard text, both ways, is in Type on the headset.',
|
||||
keywords:'upload transfer downloads sftp storage', links:[['#transfer','Send files'],['#pad','Clipboard']]},
|
||||
{id:'media', category:'apps', icon:'play', title:'Video & stereo photos', kind:'flow', label:'Preview feature',
|
||||
description:'Send and play H.264/H.265 video, SBS/OU photos or small splats with the built-in player. Choose a layout and theatre surround.',
|
||||
keywords:'movie photo sbs ou 3d splat gaussian theatre media player', links:[['#media','Open media player']],
|
||||
@@ -85,9 +85,11 @@
|
||||
{id:'remote', category:'network', icon:'display', title:'Remote access', kind:'flow', label:'Existing controls',
|
||||
description:'Open SSH, SFTP, Steam Link or remote desktop using the host’s existing tools. Developer Mode enables headset SSH and xrdp.',
|
||||
keywords:'terminal shell remote desktop rdp xrdp streaming steam link sftp', links:[['#power','Open remote tools']]},
|
||||
{id:'input', category:'network', icon:'keyboard', title:'Keyboard & trackpad', kind:'flow', label:'Existing controls',
|
||||
description:'Type and point in the Frame’s apps with KDE Connect, or use Control on the live panel view. First use sets up the input helper.',
|
||||
keywords:'mouse pointer keyboard trackpad touch click kde input', links:[['#pad','Open keyboard & trackpad'],['#view','Panel control']]},
|
||||
{id:'input', category:'network', icon:'keyboard', title:'Type on the headset', kind:'flow', label:'Existing controls',
|
||||
description:'Type and point in the Frame’s apps with this computer’s keyboard and mouse, and copy text both ways between the clipboards. Press T anywhere to open it.',
|
||||
keywords:'mouse pointer keyboard trackpad touch click kde connect input type typing text clipboard copy paste send',
|
||||
links:[['#pad','Type on the headset'],['#view','Panel control']],
|
||||
guidance:'The keyboard sets up KDE Connect on the Frame the first time. The clipboard needs the desktop panel open in the headset.'},
|
||||
{id:'mac', category:'network', icon:'display', title:'Mac in the headset', kind:'flow', label:'macOS only', platform:'mac',
|
||||
description:'Stream Mac screens or individual windows into headset panels. Choose quality and give each window its own display.',
|
||||
keywords:'macos mac window screen sharing stream quality sharp balanced light permissions', links:[['#macview','Configure Mac streaming']],
|
||||
|
||||
@@ -5,6 +5,7 @@ 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):
|
||||
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
@@ -308,6 +309,38 @@ def clipboard_text():
|
||||
raise HostError("Can't read the clipboard")
|
||||
|
||||
|
||||
def set_clipboard_text(text):
|
||||
"""Put TEXT on this computer's clipboard."""
|
||||
data = text.encode("utf-8")
|
||||
env = None
|
||||
if MAC:
|
||||
# pbcopy reads stdin in the locale's encoding, and the app may start with none (MacRoman).
|
||||
cmds, env = [["pbcopy"]], {**os.environ, "LC_CTYPE": "UTF-8"}
|
||||
elif WINDOWS:
|
||||
# Base64 on stdin, so no console code page touches the text. clip.exe takes UTF-16 with a BOM.
|
||||
script = ("Set-Clipboard -Value ([Text.Encoding]::UTF8.GetString("
|
||||
"[Convert]::FromBase64String([Console]::In.ReadToEnd())))")
|
||||
cmds = [["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script], ["clip.exe"]]
|
||||
else:
|
||||
cmds = [["wl-copy"], ["xclip", "-selection", "clipboard", "-in"], ["xsel", "--clipboard", "--input"]]
|
||||
for cmd in cmds:
|
||||
if not shutil.which(cmd[0]):
|
||||
continue
|
||||
feed = (base64.b64encode(data) if cmd[0] == "powershell.exe"
|
||||
else b"\xff\xfe" + text.encode("utf-16-le") if cmd[0] == "clip.exe" else data)
|
||||
# wl-copy and xclip stay behind to serve the clipboard: give them no pipes to hold open.
|
||||
try:
|
||||
r = subprocess.run(cmd, input=feed, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
env=env, timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
continue
|
||||
if r.returncode == 0:
|
||||
return
|
||||
if LINUX:
|
||||
raise HostError("Can't set the clipboard: install wl-clipboard (Wayland) or xclip (X11)")
|
||||
raise HostError("Can't set the clipboard")
|
||||
|
||||
|
||||
# What Windows' OpenSSH says when it refuses ~/.ssh/config (or a key) for its ACL.
|
||||
BAD_PERMISSIONS = "Bad owner or permissions on "
|
||||
|
||||
|
||||
+35
-12
@@ -1143,7 +1143,7 @@
|
||||
<div class="workspace-heading"><div><span class="eyebrow">Your Steam Frame</span><h1 id="homeTitle">Let's connect</h1><p id="homeReadiness" role="status">Checking your headset…</p></div><a class="workspace-link" href="#devices">Devices & connection</a></div>
|
||||
<div class="home-grid"><section class="panel home-activity"><span class="eyebrow">Current activity</span><h2 id="homeActivity">Connect to see what's running</h2><p id="homeActivityNote">Your installed apps and current downloads appear here.</p><div id="homeRunning" class="home-running"></div><div class="home-actions"><a href="#library" class="workspace-link primary-link">Open Games →</a><a href="#discover" class="workspace-link">Find something new</a></div></section>
|
||||
<section class="panel home-session"><span class="eyebrow">Background tasks</span><h2 id="homeJobs">No active tasks</h2><p id="homeDownload">Install progress stays here while you explore.</p><button class="small" id="homeLog">View activity log</button></section></div>
|
||||
<div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and share the headset view</small></a><a href="#pad"><span>Keyboard & trackpad</span><small>Control apps on your Frame</small></a><a href="#comfort"><span>Family & comfort</span><small>Sessions, breaks and alerts</small></a><a href="#control"><span>Control Center</span><small>Search settings and live metrics</small></a></div>
|
||||
<div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and share the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#comfort"><span>Family & comfort</span><small>Sessions, breaks and alerts</small></a><a href="#control"><span>Control Center</span><small>Search settings and live metrics</small></a></div>
|
||||
<section class="panel now" id="device">
|
||||
<div class="shelf-head"><h2>Right now</h2></div>
|
||||
<p class="now-state" id="nowState">Waiting for the Frame…</p>
|
||||
@@ -1159,7 +1159,7 @@
|
||||
<button data-go="media">Play a video or photo</button>
|
||||
<button data-go="macview" id="nowMac" hidden>Show a Mac window</button>
|
||||
<button id="nowSend">Send files…</button>
|
||||
<button data-go="pad">Type on the Frame</button>
|
||||
<button data-go="pad" title="Type on the headset (T)">Type on the headset</button>
|
||||
</div>
|
||||
<p class="hint">Casting shows everything the wearer sees, including anything private.</p>
|
||||
</section>
|
||||
@@ -1402,7 +1402,7 @@
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad"><span>Keyboard & trackpad</span><small>Type and point in desktop apps</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="vrTools">
|
||||
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||
@@ -1479,6 +1479,15 @@
|
||||
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
|
||||
else to install. <a href="#" data-about>Licences</a></div>
|
||||
</div>
|
||||
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
|
||||
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button class="action small" id="clipSend">Send text</button>
|
||||
<button class="small" id="clipMac">Send this computer's clipboard</button>
|
||||
<button class="small" id="clipFrom">Copy the headset's clipboard here</button>
|
||||
</div>
|
||||
<div class="hint">Text goes both ways between here and the headset's desktop: copy in an app on the Frame, then
|
||||
Copy the headset's clipboard. Needs the desktop panel open in the headset. Text only, up to 1 MB.</div>
|
||||
</section><section id="androidSettings"> <h2 class="lib-section">Display and reports</h2>
|
||||
<div class="lib-manage">
|
||||
<section class="panel disp" id="display">
|
||||
@@ -1541,13 +1550,7 @@
|
||||
<option value="vr">VR app</option>
|
||||
</select>
|
||||
<div class="progress" id="prog"><i></i></div>
|
||||
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
|
||||
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button class="action small" id="clipSend">Send text</button>
|
||||
<button class="small" id="clipMac">Send this computer's clipboard</button>
|
||||
</div>
|
||||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||||
<div class="hint">Clipboard text, both ways, is in <a href="#pad">Type on the headset</a>.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="media">
|
||||
@@ -2018,6 +2021,7 @@ function applyHostWording(h) {
|
||||
if (!h.mobile) $("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||||
$("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`;
|
||||
$("clipMac").textContent = `Send ${h.computer} clipboard`;
|
||||
$("clipFrom").textContent = `Copy the headset's clipboard to this ${h.computer}`;
|
||||
$('mobileConnection').hidden = !h.mobile;
|
||||
$('desktopConnections').hidden = !!h.mobile;
|
||||
if (h.mobile) {
|
||||
@@ -2312,10 +2316,16 @@ function showNow(to) {
|
||||
showPage();
|
||||
}
|
||||
// Home's shortcuts to the things people come to do next.
|
||||
// Type on the headset: Home's button and the T key turn the keyboard on and put the cursor in its field.
|
||||
function typeOnHeadset() {
|
||||
showNow("pad");
|
||||
if (pad.state === "off" || pad.state === "error") padStart();
|
||||
$("padType").focus({ preventScroll: true });
|
||||
}
|
||||
document.querySelectorAll("[data-go]").forEach(b => b.onclick = () => {
|
||||
const to = b.dataset.go;
|
||||
if (to === "pad") return typeOnHeadset();
|
||||
showNow(to);
|
||||
if (to === "pad") { if (pad.state === "off" || pad.state === "error") padStart(); $("padType").focus({ preventScroll: true }); }
|
||||
});
|
||||
$("nowSend").onclick = () => $("fileInput").click();
|
||||
$("castBtn").onclick = () => {
|
||||
@@ -3357,6 +3367,15 @@ async function sendComputerClipboard() {
|
||||
return api("/api/clipboard", { text }, device);
|
||||
}
|
||||
$("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac"));
|
||||
// The other way. The app (or the iPhone app) writes the clipboard itself; in a browser, the
|
||||
// server does, on the computer it runs on. The text is never logged.
|
||||
async function copyHeadsetClipboard() {
|
||||
if (!window.frameApp?.writeClipboard) return api("/api/clipboard", { toComputer: true });
|
||||
const { text, message } = await api("/api/clipboard", { read: true });
|
||||
await window.frameApp.writeClipboard(text);
|
||||
return { message };
|
||||
}
|
||||
$("clipFrom").onclick = () => act("Copy the headset's clipboard", copyHeadsetClipboard, $("clipFrom"));
|
||||
|
||||
// ---- file drop ----
|
||||
const drop = $("drop");
|
||||
@@ -5252,7 +5271,7 @@ const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"ca
|
||||
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools"};
|
||||
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork",
|
||||
pad:"Keyboard & trackpad", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
let page = "apps";
|
||||
@@ -5684,6 +5703,10 @@ clientPreferences.ready.then(async () => {
|
||||
showPage();
|
||||
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.key === "t" || e.key === "T") && !(ctrl.on && document.activeElement === $("viewer"))) {
|
||||
e.preventDefault(); // or the T lands in the field it focuses, and goes to the Frame
|
||||
return typeOnHeadset();
|
||||
}
|
||||
const n = [...PAGES, 'devices', 'control'][+e.key - 1];
|
||||
if (n) location.hash = n;
|
||||
});
|
||||
|
||||
+78
-14
@@ -856,20 +856,22 @@ def set_volume(body):
|
||||
return {"message": "Volume updated"}
|
||||
|
||||
|
||||
# Runs on the Frame, clipboard text on stdin. Verified 2026-09-25 (SteamOS 0.3.0
|
||||
# vr, build 20260922): the headset desktop is a nested Plasma Wayland session
|
||||
# inside gamescope with its own D-Bus bus, and wl-copy/xclip are not installed.
|
||||
# Klipper (org.kde.klipper, served by plasmashell) is reachable with qdbus6, so
|
||||
# borrow plasmashell's bus address. Same as scripts/paste-to-frame.sh.
|
||||
PASTE = r"""set -u
|
||||
text=$(cat; printf x); text=${text%x}
|
||||
pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
|
||||
# The headset desktop's session bus. Verified 2026-09-25 (SteamOS 0.3.0 vr, build
|
||||
# 20260922): the headset desktop is a nested Plasma Wayland session inside
|
||||
# gamescope with its own D-Bus bus, and wl-copy/xclip are not installed. Klipper
|
||||
# (org.kde.klipper, served by plasmashell) is reachable with qdbus6, so borrow
|
||||
# plasmashell's bus address. Same as scripts/paste-to-frame.sh.
|
||||
DESKTOP_BUS = r"""pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
|
||||
if [ -z "$pid" ]; then
|
||||
echo "The headset's desktop isn't open (plasmashell is not running). Open the desktop in the headset, then try again." >&2
|
||||
exit 2
|
||||
fi
|
||||
bus=$(tr '\0' '\n' < "/proc/$pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')
|
||||
if DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper \
|
||||
"""
|
||||
# Runs on the Frame, clipboard text on stdin.
|
||||
PASTE = r"""set -u
|
||||
text=$(cat; printf x); text=${text%x}
|
||||
""" + DESKTOP_BUS + r"""if DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper \
|
||||
org.kde.klipper.klipper.setClipboardContents "$text" >/dev/null; then
|
||||
echo "copied via Klipper (${#text} chars)"
|
||||
else
|
||||
@@ -877,12 +879,77 @@ else
|
||||
exit 2
|
||||
fi
|
||||
"""
|
||||
DESKTOP_CLOSED = "plasmashell is not running" # what PASTE says when the headset's desktop isn't open
|
||||
DESKTOP_CLOSED = "plasmashell is not running" # what PASTE and COPY say when the headset's desktop isn't open
|
||||
# base64 keeps the script intact through every local shell's quoting rules.
|
||||
PASTE_CMD = 'bash -c "$(echo %s | base64 -d)"' % base64.b64encode(PASTE.encode()).decode()
|
||||
|
||||
# The other way: Klipper's getClipboardContents, printed by qdbus6 with a newline
|
||||
# after it, sent back as base64 so no shell or console encoding touches it.
|
||||
# Not tested on a headset: the method is Klipper's, the same interface PASTE uses.
|
||||
CLIPBOARD_LIMIT = 1_000_000 # bytes of UTF-8, about a 500-page book
|
||||
CLIPBOARD_TOO_BIG = "too much to copy"
|
||||
COPY = r"""set -u
|
||||
""" + DESKTOP_BUS + r"""out=$(mktemp) || exit 2
|
||||
trap 'rm -f "$out"' EXIT
|
||||
if ! DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper \
|
||||
org.kde.klipper.klipper.getClipboardContents >"$out"; then
|
||||
echo "Klipper call failed (bus: ${bus:-none})" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$(wc -c <"$out")" -gt %d ]; then
|
||||
echo "The headset's clipboard holds %s (over 1 MB). Copy less, or send it as a file." >&2
|
||||
exit 3
|
||||
fi
|
||||
base64 -w 0 <"$out"
|
||||
""" % (CLIPBOARD_LIMIT + 1, CLIPBOARD_TOO_BIG)
|
||||
COPY_CMD = 'bash -c "$(echo %s | base64 -d)"' % base64.b64encode(COPY.encode()).decode()
|
||||
# Klipper's text for an image it holds, e.g. "▨ 640x480 32bpp" (HistoryImageItem::text in
|
||||
# Klipper's source; not seen on a headset).
|
||||
KLIPPER_IMAGE = re.compile(r"▨ \d+x\d+ \d+bpp")
|
||||
CLIPBOARD_EMPTY = "The headset's clipboard is empty"
|
||||
|
||||
|
||||
def desktop_failure(e):
|
||||
"""The headset's desktop being closed is the person's to change, not a fault: a 409 isn't reported as one."""
|
||||
if DESKTOP_CLOSED in str(e):
|
||||
return Failure(str(e), 409)
|
||||
if CLIPBOARD_TOO_BIG in str(e):
|
||||
return Failure(str(e), 413)
|
||||
return None
|
||||
|
||||
|
||||
def headset_clipboard():
|
||||
"""The text on the headset desktop's clipboard. Never logged: it can hold anything."""
|
||||
try:
|
||||
out = ssh(COPY_CMD, timeout=30)
|
||||
except Failure as e:
|
||||
raise desktop_failure(e) or e from None
|
||||
try:
|
||||
text = base64.b64decode(out.strip(), validate=True).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
raise Failure("The headset's clipboard couldn't be read as text", 502) from None
|
||||
text = text[:-1] if text.endswith("\n") else text # qdbus6's newline, not the clipboard's
|
||||
if KLIPPER_IMAGE.fullmatch(text):
|
||||
raise Failure("The headset's clipboard holds an image. Only text can be copied.", 400)
|
||||
if not text:
|
||||
raise Failure(CLIPBOARD_EMPTY + " (or holds something other than text)", 400)
|
||||
return text
|
||||
|
||||
|
||||
def clipboard(body):
|
||||
if body.get("read") is True:
|
||||
# The page puts it on the clipboard where it runs (the app, or the iPhone).
|
||||
text = headset_clipboard()
|
||||
return {"text": text, "message": f"Copied {len(text):,} characters from the headset's clipboard"}
|
||||
if body.get("toComputer") is True:
|
||||
if LOCAL:
|
||||
raise Failure("This server runs on the headset: copy from the Frame Control app instead", 400)
|
||||
text = headset_clipboard()
|
||||
try:
|
||||
frame_host.set_clipboard_text(text)
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
return {"message": f"Copied {len(text):,} characters from the headset to this computer's clipboard"}
|
||||
if body.get("fromMac") or body.get("fromComputer"):
|
||||
try:
|
||||
text = frame_host.clipboard_text()
|
||||
@@ -897,10 +964,7 @@ def clipboard(body):
|
||||
try:
|
||||
return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
|
||||
except Failure as e:
|
||||
if DESKTOP_CLOSED in str(e):
|
||||
# The headset's state, for the person to change, not a fault: a 409 isn't reported as one.
|
||||
raise Failure(str(e), 409) from None
|
||||
raise
|
||||
raise desktop_failure(e) or e from None
|
||||
|
||||
|
||||
# ---- keyboard and pointer (KDE Connect on the Frame, see frame_input_agent.py) ----
|
||||
|
||||
Reference in new issue
Block a user