From 4cb2509481f7a848d848275e263b56154c405d38 Mon Sep 17 00:00:00 2001 From: Todd Faulls Date: Mon, 5 Oct 2026 15:56:44 -0400 Subject: [PATCH] Windows client: support the Linux host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Say "os":"windows" in the hello. iframe-linux-host now treats Windows clients like Linux ones (⌘ → Super), and the Mac host ignores the field. - On a Linux host, map keys one-to-one: Ctrl → Ctrl, Windows key → Super (so it opens the desktop's menu in fullscreen); the Mac-only Ctrl/Command swap is ignored. - Mark discovered hosts as Mac or Linux and use neutral wording in the UI and error messages. Co-Authored-By: Claude Opus 5.5 --- LinuxHost/README.md | 11 ++++++----- LinuxHost/src/main.c | 10 +++++----- Windows/README.md | 13 +++++++------ Windows/src/main.cjs | 6 +++--- Windows/src/protocol.cjs | 2 +- Windows/src/session.cjs | 8 ++++---- Windows/ui/app.mjs | 16 +++++++++------- Windows/ui/index.html | 26 +++++++++++++------------- Windows/ui/video.mjs | 2 +- 9 files changed, 49 insertions(+), 45 deletions(-) diff --git a/LinuxHost/README.md b/LinuxHost/README.md index 9df3be5..064d4f2 100644 --- a/LinuxHost/README.md +++ b/LinuxHost/README.md @@ -1,6 +1,6 @@ # iframe-linux-host -Streams a Linux (X11) desktop to iFrame clients: the iPad app and the Linux client. It's a +Streams a Linux (X11) desktop to iFrame clients: the iPad app, the Linux client and the Windows client. It's a **separate host** from the Mac's `iframe-host` (`Host/`, Swift). They share only the wire protocol, so either kind of client connects to either kind of host. The C protocol helpers (framing, JSON, key table) come from `Linux/src/`. @@ -72,10 +72,11 @@ one decision is what ⌘ becomes: - **iPad (and any client that doesn't say otherwise): ⌘ → Ctrl**, so ⌘C copies, ⌘V pastes and ⌘T opens a tab, as a Mac user expects. ⌃ is also Ctrl, so Ctrl+C in a terminal still works. -- **Linux client: ⌘ → Super.** The Linux client tells the host it's a Linux machine - (`"os":"linux"` in its hello), and the host tells the client it's Linux (`"os":"linux"` in its - welcome). The client then sends Ctrl, Alt and Super as themselves, so every key lands exactly - where it is on your keyboard. +- **Linux and Windows clients: ⌘ → Super.** These clients say what they are (`"os":"linux"` or + `"os":"windows"` in their hello), and the host tells the client it's Linux (`"os":"linux"` in its + welcome). The client then sends Ctrl, Alt and Super (the Windows key) as themselves, so every + key lands exactly where it is on your keyboard. On Windows the Windows key reaches the host + only while the stream is fullscreen; otherwise Windows keeps it for the Start menu. `--cmd-key ctrl|super` overrides this. Text from the iPad's on-screen keyboard is typed as Unicode; characters with no key on the layout are typed through a temporarily remapped spare diff --git a/LinuxHost/src/main.c b/LinuxHost/src/main.c index 8ed4b5e..d6dc840 100644 --- a/LinuxHost/src/main.c +++ b/LinuxHost/src/main.c @@ -65,7 +65,7 @@ typedef struct Session { pthread_mutex_t send_lock; char name[192]; bool authenticated; - bool linux_client; + bool pc_client; // Linux or Windows: its Ctrl, Alt and Super keys map one-to-one Capture *cap; Encoder *enc; Streamer *streamer; @@ -132,13 +132,13 @@ static bool start_stream(Session *s, bool supports_hevc, int max_fps) { capture_monitor_origin(s->cap, &ox, &oy); pthread_mutex_lock(&input_lock); input_set_region(input, ox, oy, w, h); - input_set_cmd(input, cfg.cmd != CMD_AUTO ? cfg.cmd : s->linux_client ? CMD_SUPER : CMD_CTRL); + input_set_cmd(input, cfg.cmd != CMD_AUTO ? cfg.cmd : s->pc_client ? CMD_SUPER : CMD_CTRL); input_keep_awake(input); pthread_mutex_unlock(&input_lock); char name[256], json[512]; json_escape(host_name, name, sizeof name); - // "os" is an addition the iPad app ignores; the Linux client uses it to map keys 1:1. + // "os" is an addition the iPad app ignores; the Linux and Windows clients use it to map keys 1:1. int n = snprintf(json, sizeof json, "{\"width\":%d,\"height\":%d,\"pointWidth\":%d,\"pointHeight\":%d,\"codec\":%d,\"fps\":%d," "\"hostName\":\"%s\",\"isVirtual\":false,\"os\":\"linux\"}", @@ -155,7 +155,7 @@ static bool start_stream(Session *s, bool supports_hevc, int max_fps) { }; s->streamer = streamer_start(s->cap, s->enc, sc, session_send, s); host_log("streaming %dx%d @ %d fps, %s, start %.0f Mbps, ⌘ → %s", w, h, fps, encoder_name(s->enc), mbps, - (cfg.cmd != CMD_AUTO ? cfg.cmd : s->linux_client ? CMD_SUPER : CMD_CTRL) == CMD_CTRL ? "Ctrl" : "Super"); + (cfg.cmd != CMD_AUTO ? cfg.cmd : s->pc_client ? CMD_SUPER : CMD_CTRL) == CMD_CTRL ? "Ctrl" : "Super"); return true; } @@ -172,7 +172,7 @@ static bool handle_hello(Session *s, const char *j, size_t len) { char peer[INET6_ADDRSTRLEN + 8]; snprintf(peer, sizeof peer, "%.*s", (int)sizeof peer - 1, s->name); // still just the address snprintf(s->name, sizeof s->name, "%s (%s)", name, peer); - s->linux_client = strcmp(os, "linux") == 0; + s->pc_client = strcmp(os, "linux") == 0 || strcmp(os, "windows") == 0; pthread_mutex_lock(&server_lock); bool ok = (int)version == IFRAME_PROTOCOL_VERSION && constant_time_equals(pin, cfg.pin); diff --git a/Windows/README.md b/Windows/README.md index a7b8ee0..ad72e07 100644 --- a/Windows/README.md +++ b/Windows/README.md @@ -1,17 +1,17 @@ # iFrame for Windows -A Windows 10/11 x64 client for the existing `iframe-host` on your Mac. The portable app includes its runtime; users do not need Node, Python, FFmpeg, or a separate codec installation. +A Windows 10/11 x64 client for `iframe-host` on a Mac or `iframe-linux-host` on a Linux PC ([LinuxHost](../LinuxHost/README.md)). The portable app includes its runtime; users do not need Node, Python, FFmpeg, or a separate codec installation. ## Connect -1. Start the Mac host using the instructions in the repository README. Grant its Screen Recording and Accessibility permissions. +1. Start the host. On a Mac, follow the repository README and grant Screen Recording and Accessibility permissions; on Linux, see [LinuxHost](../LinuxHost/README.md). 2. Open `iFrame-0.1.0-Windows-x64.exe` from `Windows/dist`. -3. Choose a nearby Mac, or enter its IP address, Tailscale address, or hostname. The default TCP port is **7878**. Discovery uses mDNS on the local network; manual addresses also work when discovery is unavailable. Select **☆ Save** next to the port to keep an address under **Saved Macs**; select a saved Mac to fill in its address and port. -4. Enter the host PIN, choose the display size/density, and click **Connect to Mac**. +3. Choose a nearby computer (marked Mac or Linux), or enter its IP address, Tailscale address, or hostname. The default TCP port is **7878**. Discovery uses mDNS on the local network; manual addresses also work when discovery is unavailable. Select **☆ Save** next to the port to keep an address under **Saved computers**; select a saved computer to fill in its address and port. +4. Enter the host PIN, choose the display size/density, and click **Connect**. The app remembers saved Macs, the last address and display settings, but never saves the PIN. Preferences are stored in Electron's per-user app data directory. Windows may ask to allow local network access for discovery. The distributed executable is unsigned. -**Retina** requests a virtual Mac display at the selected pixel size with two pixels per Mac point. **Native** gives more desktop space. **Mirror** uses the Mac's existing display. Display size is chosen at connection time; resizing the app scales the picture without restarting the Mac display. Reconnect to change resolution or density. +**Retina** requests a virtual Mac display at the selected pixel size with two pixels per Mac point. **Native** gives more desktop space. **Mirror** uses the Mac's existing display. Display size is chosen at connection time; resizing the app scales the picture without restarting the Mac display. Reconnect to change resolution or density. A Linux host streams its monitor as it is, so these settings apply only to Macs. ## Controls @@ -23,13 +23,14 @@ The app remembers saved Macs, the last address and display settings, but never s | Alt | Mac Option | | Windows key, fullscreen | Mac Command; the Start menu and Windows+ shortcuts are captured while the stream is fullscreen and focused | | Windows key, windowed (default mapping) | Mac Control, when Windows does not intercept it | +| Linux host | Keys map one-to-one: Ctrl → Ctrl, Alt → Alt, Windows key → Super (fullscreen), so Super opens the desktop's menu; the Ctrl/Command option is ignored | | Ctrl+Alt+F | Toggle fullscreen | | Ctrl+Alt+R | Release keyboard focus to the toolbar | | Ctrl+Alt+Q | Disconnect | | Send text | Send pasted or typed Unicode text to the focused Mac field | | Refresh | Request a new keyframe | -Disable **Use Ctrl for Mac Command shortcuts** for literal Ctrl → Control and Windows → Command mapping. Windows reserves shortcuts such as Alt+Tab, Windows+L, and Ctrl+Alt+Delete; these stay local. Held keys and mouse buttons are released when focus leaves the desktop. The host also releases input when the connection ends. +Disable **On a Mac, use Ctrl for Command shortcuts** for literal Ctrl → Control and Windows → Command mapping. Windows reserves shortcuts such as Alt+Tab, Windows+L, and Ctrl+Alt+Delete; these stay local. Held keys and mouse buttons are released when focus leaves the desktop. The host also releases input when the connection ends. ## Build and test diff --git a/Windows/src/main.cjs b/Windows/src/main.cjs index af0fd51..e3bd452 100644 --- a/Windows/src/main.cjs +++ b/Windows/src/main.cjs @@ -41,16 +41,16 @@ listen('ack', (id, micros) => session.ack(id, micros)); listen('keyframe', () => session.keyframe()); function startDiscovery() { try { - discovery = new Bonjour({}, () => emit('discovery-warning', 'Discovery is unavailable. Enter the Mac address below.')); + discovery = new Bonjour({}, () => emit('discovery-warning', 'Discovery is unavailable. Enter the host address below.')); browser = discovery.find({ type: 'iframe', protocol: 'tcp' }); browser.on('up', service => { const host = service.addresses?.find(a => /^\d+\.\d+\.\d+\.\d+$/.test(a)) ?? service.host; if (!host) return; - hosts.set(service.fqdn, { id: service.fqdn, name: service.name, host, port: service.port }); + hosts.set(service.fqdn, { id: service.fqdn, name: service.name, host, port: service.port, os: service.txt?.os === 'linux' ? 'linux' : 'mac' }); emit('hosts', [...hosts.values()]); }); browser.on('down', service => { hosts.delete(service.fqdn); emit('hosts', [...hosts.values()]); }); - } catch { emit('discovery-warning', 'Discovery is unavailable. Enter the Mac address below.'); } + } catch { emit('discovery-warning', 'Discovery is unavailable. Enter the host address below.'); } } app.whenReady().then(async () => { window = new BrowserWindow({ width: 1180, height: 820, minWidth: 760, minHeight: 620, show: !smoke, diff --git a/Windows/src/protocol.cjs b/Windows/src/protocol.cjs index 2425e16..8db1388 100644 --- a/Windows/src/protocol.cjs +++ b/Windows/src/protocol.cjs @@ -66,7 +66,7 @@ function inputPacket(input) { function validateOptions(o) { if (!o || typeof o.host !== 'string' || !o.host.trim() || o.host.length > 253 || /[\s/\\]/.test(o.host.trim())) - throw new Error('Enter a Mac IP address or hostname, without a URL prefix.'); + throw new Error('Enter the host IP address or hostname, without a URL prefix.'); if (!Number.isInteger(o.port) || o.port < 1 || o.port > 65535) throw new Error('Port must be between 1 and 65535.'); if (typeof o.pin !== 'string' || o.pin.length > 256) throw new Error('Invalid PIN.'); if (![30, 60, 120].includes(o.fps) || ![0, 1, 1.5, 2].includes(o.scale)) throw new Error('Invalid stream settings.'); diff --git a/Windows/src/session.cjs b/Windows/src/session.cjs index 6986b0f..d1b6fcd 100644 --- a/Windows/src/session.cjs +++ b/Windows/src/session.cjs @@ -14,7 +14,7 @@ class Session extends EventEmitter { const fail = message => { if (this.socket !== socket) return; this.close(); this.emit('state', { phase: 'failed', message }); }; const parser = new Parser((type, data) => { if (this.socket !== socket) return; - if (type === T.authFailed) return fail('Wrong PIN or incompatible host protocol. Check the PIN on your Mac.'); + if (type === T.authFailed) return fail('Wrong PIN or incompatible host protocol. Check the PIN on the host.'); if (type === T.welcome) { const w = JSON.parse(data.toString()); if (w.codec !== 0) return fail('The host is forcing HEVC. Restart it with H.264 or automatic codec selection.'); @@ -39,12 +39,12 @@ class Session extends EventEmitter { } }); socket.setNoDelay(true); socket.setKeepAlive(true, 5000); socket.setTimeout(15000); - socket.on('timeout', () => fail('The Mac stopped responding. Check the network and reconnect.')); + socket.on('timeout', () => fail('The host stopped responding. Check the network and reconnect.')); socket.on('data', chunk => { try { parser.push(chunk); } catch (e) { fail(`Stream error: ${e.message}`); } }); socket.on('error', e => fail(`Cannot connect to ${o.host}:${o.port} (${e.code ?? e.message}).`)); - socket.on('close', () => fail('The Mac closed the connection. Check its screen recording permission and host log.')); + socket.on('close', () => fail('The host closed the connection. Check the host log (on a Mac, also its screen recording permission).')); socket.on('connect', () => { - this.send(T.hello, Buffer.from(JSON.stringify({ version: 1, pin: o.pin, name: os.hostname(), supportsHEVC: false, + this.send(T.hello, Buffer.from(JSON.stringify({ version: 1, pin: o.pin, name: os.hostname(), os: 'windows', supportsHEVC: false, maxFPS: o.fps, display: { width: o.width, height: o.height, uiScale: o.scale } }))); this.timer = setInterval(() => { if (!this.ready) return; diff --git a/Windows/ui/app.mjs b/Windows/ui/app.mjs index ecf0a6c..8cb630b 100644 --- a/Windows/ui/app.mjs +++ b/Windows/ui/app.mjs @@ -8,6 +8,7 @@ try { for (const id of settings) if (saved[id] !== undefined) $(id).value = saved[id]; $('swap').checked = saved.swap !== false; } catch { /* Ignore an unreadable preference file. The PIN is never persisted. */ } +const linuxHost = () => welcome?.os === 'linux'; const status = message => { $('status').textContent = message; }; function saveSettings() { try { localStorage.setItem('iframe-settings', JSON.stringify(Object.fromEntries([...settings.map(id => [id, $(id).value]), ['swap', $('swap').checked]]))); } catch {} @@ -16,7 +17,8 @@ async function fail(message) { await api.disconnect(); status(message); } const video = new Video($('video'), api, fail, ms => { frames++; decodeTotal += ms; $('waiting').hidden = true; }, message => { $('waiting').textContent = message; }); const releaseInput = attachInput($('video'), api, { - active: () => active, swap: () => $('swap').checked, + // A Linux host maps keys one-to-one (Ctrl → Ctrl, Windows → Super); the Ctrl/Command swap is for Macs. + active: () => active, swap: () => $('swap').checked && !linuxHost(), shortcut: code => { if (code === 'KeyF') toggleFullscreen(); else if (code === 'KeyQ') disconnect(); else $('fullscreen').focus(); } }); function disconnect() { releaseInput(); return api.disconnect(); } @@ -59,13 +61,13 @@ api.onState(state => { $('waiting').textContent = 'Waiting for the first frame…'; $('host-name').textContent = welcome.hostName; $('stream-info').textContent = `${welcome.width} × ${welcome.height} · H.264 · ${welcome.fps} fps`; - $('keyboard-mode').textContent = $('swap').checked ? 'Ctrl → ⌘ Command' : 'Windows → ⌘ Command'; + $('keyboard-mode').textContent = linuxHost() ? 'Ctrl → Ctrl · Windows → Super' : $('swap').checked ? 'Ctrl → ⌘ Command' : 'Windows → ⌘ Command'; $('pin').value = ''; $('video').focus(); } else if (!connecting) { releaseInput(); active = false; video.close(); welcome = null; frames = 0; decodeTotal = 0; hostStats = {}; rtt = 0; $('text-dialog').close('cancel'); $('connect-page').hidden = false; $('stream-page').hidden = true; status(state.phase === 'failed' ? state.message : ''); $('connect').focus(); - } else status('Connecting to your Mac…'); + } else status('Connecting…'); }); api.onMessage(({type, data}) => { try { @@ -79,8 +81,8 @@ api.onRtt(value => { rtt = value; }); let knownHosts = []; function showHosts(hosts) { knownHosts = hosts; const selected = $('discovered').value; - $('discovered').replaceChildren(new Option(hosts.length ? 'Choose a Mac…' : 'No Macs found yet · enter an address below', '')); - for (const host of hosts) $('discovered').add(new Option(`${host.name} · ${host.host}`, host.id)); + $('discovered').replaceChildren(new Option(hosts.length ? 'Choose a computer…' : 'No computers found yet · enter an address below', '')); + for (const host of hosts) $('discovered').add(new Option(`${host.name} · ${host.os === 'linux' ? 'Linux' : 'Mac'} · ${host.host}`, host.id)); $('discovered').value = selected; } api.onHosts(showHosts); api.hosts().then(showHosts); @@ -115,7 +117,7 @@ function updateBookmarkButton() { $('bookmark').disabled = !a.host || !Number.isInteger(a.port) || a.port < 1 || a.port > 65535; $('bookmark').textContent = saved ? '★ Saved' : '☆ Save'; $('bookmark').setAttribute('aria-pressed', String(saved)); - $('bookmark').title = saved ? 'Remove this Mac from saved Macs' : 'Save this address'; + $('bookmark').title = saved ? 'Remove from saved computers' : 'Save this address'; } $('bookmark').onclick = () => { const a = address(), i = bookmarkIndex(a); @@ -128,7 +130,7 @@ showBookmarks(); setInterval(() => { if (active) { const n = v => Number.isFinite(v) ? v.toFixed(1) : '—'; - $('stats').textContent = `${welcome?.isVirtual ? 'Virtual display' : 'Mac display'} · H.264\nReceived ${frames} fps\nDecode ${n(frames ? decodeTotal/frames : 0)} ms\nRound trip ${n(rtt)} ms\nBitrate ${n(hostStats.mbps)} Mbps\nHost encode ${n(hostStats.encodeMs)} ms\nHost drops ${hostStats.dropped ?? '—'}`; + $('stats').textContent = `${welcome?.isVirtual ? 'Virtual display' : linuxHost() ? 'Linux display' : 'Mac display'} · H.264\nReceived ${frames} fps\nDecode ${n(frames ? decodeTotal/frames : 0)} ms\nRound trip ${n(rtt)} ms\nBitrate ${n(hostStats.mbps)} Mbps\nHost encode ${n(hostStats.encodeMs)} ms\nHost drops ${hostStats.dropped ?? '—'}`; } frames = 0; decodeTotal = 0; }, 1000); diff --git a/Windows/ui/index.html b/Windows/ui/index.html index 5c172eb..e47fc40 100644 --- a/Windows/ui/index.html +++ b/Windows/ui/index.html @@ -14,38 +14,38 @@
YOUR MAC, WITHIN REACH

A little window.
A whole Mac.

-

Bring your Mac desktop to Windows.
Connect, settle in, and make yourself at home.

+

Bring your Mac or Linux desktop to Windows.
Connect, settle in, and make yourself at home.

Use a trusted local network or Tailscale.
iFrame uses a PIN; the stream is not encrypted.

-

Connect to your Mac

01
- - - +

Connect to a Mac or Linux PC

01
+ + +

Or enter an IP address or hostname below.

-
- +
+
- +
- +
- +
MAC → WINDOWSYour workspace, a little closer.
-
Mac
-
Waiting for the first frame…
+
Host
+
Waiting for the first frame…
-

Send text to your Mac

Type or paste text here, then send it to the focused field.

+

Send text to the host

Type or paste text here, then send it to the focused field.

diff --git a/Windows/ui/video.mjs b/Windows/ui/video.mjs index 9f196e5..d25797a 100644 --- a/Windows/ui/video.mjs +++ b/Windows/ui/video.mjs @@ -1,7 +1,7 @@ import { lowDelaySPS } from './sps.mjs'; export function parseFormat(bytes) { const b = new Uint8Array(bytes), view = new DataView(b.buffer, b.byteOffset, b.byteLength); - if (b.length < 2 || b[0] !== 0) throw new Error('Only H.264 is supported. Disable forced HEVC on the Mac.'); + if (b.length < 2 || b[0] !== 0) throw new Error('Only H.264 is supported. Disable forced HEVC on the host.'); let offset = 2; const sets = []; for (let i = 0; i < b[1]; i++) { if (offset + 4 > b.length) throw new Error('Truncated video format');