Add phone remote control: pair a phone with a 6-digit code or QR to drive a desktop instance's playback, queue, volume, sleep timer and service mode

This commit is contained in:
Jonathan Sykes
2026-09-18 20:19:00 +08:00
parent 524db986d2
commit b82f17cd46
9 changed files with 1300 additions and 4 deletions

153
server/remote.test.js Normal file
View File

@@ -0,0 +1,153 @@
// Phone-remote relay: real Bun server + real WebSocket clients.
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { createRemoteHub, roomIdFor } from './remote.js';
const SECRET = 'a'.repeat(43);
let hub, server, base, ipFor;
function start(opts) {
hub = createRemoteHub(opts);
ipFor = () => '203.0.113.1';
server = Bun.serve({
port: 0,
async fetch(req, srv) {
const u = new URL(req.url);
if (u.pathname === '/ws/remote') return hub.upgrade(req, srv, ipFor(req));
if (u.pathname === '/pair') {
const body = await req.json();
const r = hub.pair({ ...body, ip: ipFor(req) });
return Response.json(r.body, { status: r.status });
}
return new Response('nope', { status: 404 });
},
websocket: hub.websocket,
});
base = `localhost:${server.port}`;
}
// A WebSocket client that queues messages and lets a test await the next
// one of a given type.
function client(query) {
const ws = new WebSocket(`ws://${base}/ws/remote?${new URLSearchParams(query)}`);
const inbox = [];
const waiters = [];
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
const i = waiters.findIndex((w) => w.type === m.type);
if (i >= 0) waiters.splice(i, 1)[0].resolve(m);
else inbox.push(m);
};
const closed = new Promise((r) => { ws.onclose = (e) => r(e.code); });
return {
ws,
closed,
send: (m) => ws.send(JSON.stringify(m)),
next(type, ms = 2000) {
const i = inbox.findIndex((m) => m.type === type);
if (i >= 0) return Promise.resolve(inbox.splice(i, 1)[0]);
return new Promise((resolve, reject) => {
waiters.push({ type, resolve });
setTimeout(() => reject(new Error(`timeout waiting for ${type}`)), ms);
});
},
opened: new Promise((r) => { ws.onopen = r; }),
};
}
const pair = async (code, name = 'Josh iPhone') => {
const res = await fetch(`http://${base}/pair`, { method: 'POST', body: JSON.stringify({ code, name }) });
return { status: res.status, body: await res.json() };
};
describe('remote relay', () => {
beforeAll(() => start());
afterAll(() => { server.stop(true); hub.stop(); });
let host, remote, creds;
test('host gets a room and a 6-digit code', async () => {
host = client({ role: 'host', secret: SECRET, name: 'Living Room TV' });
const hello = await host.next('hello');
expect(hello.roomId).toBe(roomIdFor(SECRET));
expect(hello.code).toMatch(/^\d{6}$/);
host.code = hello.code;
});
test('wrong code is refused; right code pairs once and rotates', async () => {
expect((await pair('000000')).status === 404 || host.code === '000000').toBe(true);
const ok = await pair(host.code);
expect(ok.status).toBe(200);
expect(ok.body.hostName).toBe('Living Room TV');
creds = ok.body;
const fresh = await host.next('code');
expect(fresh.code).not.toBe(host.code);
expect((await pair(host.code)).status).toBe(404); // one-time
host.code = fresh.code;
});
test('remote connects, host sees it, state flows down, commands flow up', async () => {
host.send({ type: 'state', state: { title: 'Living Hope', cur: 84, paused: false } });
host.send({ type: 'queue', queue: { items: [{ id: 'x', title: 'House of the Lord' }], idx: -1 } });
remote = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: creds.token, name: 'Josh iPhone' });
const hello = await remote.next('hello');
expect(hello.state.title).toBe('Living Hope');
expect(hello.queue.items[0].title).toBe('House of the Lord');
const list = await host.next('remotes');
expect(list.remotes.map((r) => r.name)).toEqual(['Josh iPhone']);
remote.send({ type: 'cmd', cmd: 'seek', t: 120 });
remote.send({ type: 'cmd', cmd: 'rm -rf' }); // not a command → dropped
remote.send({ type: 'cmd', cmd: 'next' });
const c1 = await host.next('cmd');
expect(c1).toMatchObject({ cmd: 'seek', t: 120, from: 'Josh iPhone' });
expect((await host.next('cmd')).cmd).toBe('next');
host.send({ type: 'state', state: { title: 'Living Hope', cur: 121, paused: true } });
expect((await remote.next('state')).state.cur).toBe(121);
});
test('ping goes through the host and back', async () => {
remote.send({ type: 'ping', t: 42 });
const p = await host.next('ping');
expect(p.t).toBe(42);
host.send({ type: 'pong', rid: p.rid, t: p.t });
expect((await remote.next('pong')).t).toBe(42);
});
test('a forged token is rejected', async () => {
const bad = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: 'forged', name: 'x' });
expect(await bad.closed).toBe(4001);
});
test('desktop offline: remotes are told, reconnects wait; desktop back → phone still paired', async () => {
host.ws.close();
await remote.next('host-offline');
const early = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: creds.token, name: 'x' });
expect(await early.closed).toBe(4004);
host = client({ role: 'host', secret: SECRET, name: 'Living Room TV' });
await host.next('hello');
expect((await remote.next('hello')).hostName).toBe('Living Room TV');
});
test('unpair-all disconnects every phone', async () => {
host.send({ type: 'unpair-all' });
expect(await remote.closed).toBe(4001);
host.ws.close();
});
});
describe('same-network pairing', () => {
beforeAll(() => start({ requireSameNetwork: true }));
afterAll(() => { server.stop(true); hub.stop(); });
test('a phone on another network cannot pair', async () => {
const host = client({ role: 'host', secret: 'b'.repeat(40), name: 'TV' });
const { code } = await host.next('hello');
ipFor = () => '198.51.100.7';
const r = await pair(code);
expect(r.status).toBe(403);
ipFor = () => '203.0.113.1';
expect((await pair(code)).status).toBe(200);
host.ws.close();
});
});