Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions LinuxHost/README.md
Original file line number Diff line number Diff line change
@@ -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/`.
Expand Down Expand Up @@ -76,10 +76,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
Expand Down
10 changes: 5 additions & 5 deletions LinuxHost/src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -132,13 +132,13 @@ static bool start_stream(Session *s, bool supports_hevc, int max_fps, bool local
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\"}",
Expand All @@ -156,7 +156,7 @@ static bool start_stream(Session *s, bool supports_hevc, int max_fps, bool local
};
s->streamer = streamer_start(s->cap, s->enc, sc, session_send, s);
host_log("streaming %dx%d @ %d fps, %s, start %.0f Mbps, ⌘ → %s, cursor %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",
local_cursor ? "drawn by the client" : "in the video");
return true;
}
Expand All @@ -175,7 +175,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);
Expand Down
13 changes: 7 additions & 6 deletions Windows/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down
6 changes: 3 additions & 3 deletions Windows/src/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion Windows/src/protocol.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
8 changes: 4 additions & 4 deletions Windows/src/session.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand All @@ -41,12 +41,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,
// localCursor: the Mac sends its cursor shape and leaves the pointer out of the video.
localCursor: true, maxFPS: o.fps, display: { width: o.width, height: o.height, uiScale: o.scale } })));
this.timer = setInterval(() => {
Expand Down
16 changes: 9 additions & 7 deletions Windows/ui/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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 {}
Expand All @@ -19,7 +20,8 @@ const video = new Video($('video'), api, fail, ms => { frames++; decodeTotal +=
const macCursor = new MacCursor($('video'));
window.addEventListener('resize', () => macCursor.apply());
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(); }
Expand Down Expand Up @@ -62,13 +64,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(); macCursor.setWelcome(welcome);
} else if (!connecting) {
releaseInput(); active = false; video.close(); macCursor.reset(); 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 {
Expand All @@ -86,8 +88,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);
Expand Down Expand Up @@ -122,7 +124,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);
Expand All @@ -135,7 +137,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);
Loading
Loading