diff --git a/docs/features.md b/docs/features.md index bce3a51a..3e65e844 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1517,14 +1517,17 @@ changing a feature: each entry records what the owner decided, why, and what hol it is the owner's decision, not made. Held by `phoneAccess.test.ts`, `places.test.ts`, the server test "never lists or reads a folder that is really on another computer" and the `phone` e2e ("a code is refused once the dialog closed"). - **THE ADDRESS PHONES USE** (#406; owner, 2026-10-16: "so that we can support custom web - addresses"): optional, More > Phone. The QR and link carry it, so a phone can reach Prism by - a friendly name or over real HTTPS through the user's own reverse proxy, which is what - encrypts the token. Only an http(s) origin is kept (`parseAddress`); empty is this PC's - address. Through a proxy, every client address the request names must also be on the user's - own network (`forwardedLocal`), so an internet-facing proxy cannot carry the internet in. - Setup guide: `docs/phone-address.md`. Held by `store.test.ts`, `lan.test.ts`, the server test - "refuses a request a proxy forwarded from outside the network" and the `phone` e2e. + **NO "ADDRESS PHONES USE"; REMOTE ACCESS BECOMES AN APP** (owner, 2026-10-16, reversing + #406 the same day: "remove the reverse proxy thing from Prism... we'll instead start + eventually looking at making it an app where you can just remote into your PC safely... the + profile can be combined with that app"). The optional address (#406, 0.116.0) is gone: a + trusted HTTPS name needed either the user's own domain and proxy, or a trust profile + installed on every phone, which is not something people should have to do by hand. HTTPS + and remote access come later, in a dedicated app that can carry the trust itself. What stays + from #406 is a wall: through any proxy a user puts in front of Prism by hand, every client + address the request names must also be on the user's own network (`forwardedLocal`). Held by + `lan.test.ts` and the server test "refuses a request a proxy forwarded from outside the + network". - **THE PHONE SEES THE OPEN TABS AND SWITCHES BETWEEN THEM** (superseded 2026-10-14, above; owner, 2026-09-08, from the same session: "i should be able to switch tabs without scanning a new qr code. i should be able to see the available tabs and switch"; #107). This REVERSES the design's own first diff --git a/docs/phone-address.md b/docs/phone-address.md deleted file mode 100644 index 58b85a15..00000000 --- a/docs/phone-address.md +++ /dev/null @@ -1,45 +0,0 @@ -# The address phones use (#406) - -Optional. Without it a phone pairs with `http://:`, as it always has. -With it, the pairing QR and link carry an address of your choosing: a friendly name on your -own network, or HTTPS through your own reverse proxy. Prism never ships a certificate and never -needs any of this. - -Set it in **More > Phone > Address phones use**. Only an origin is kept: `http://` or -`https://`, a host and an optional port. No path, query or login. Empty goes back to this PC's -address. - -## Why HTTPS matters here - -Prism's phone server is plain HTTP, so the phone's access token travels unencrypted on your -Wi-Fi (security review, `research/prism/2026-10-14-phone-access-review.md`). A reverse proxy -with a real certificate encrypts it, and the phone shows no warning. A certificate from Let's -Encrypt needs a domain you own. A name that only your own network knows (`prism.home`) can be -friendly, but it cannot get a certificate a phone trusts. - -## One way to set it up (Nginx Proxy Manager and AdGuard Home) - -The owner's setup, `Documents\homelab\compose.yaml`; any reverse proxy and local DNS work the -same way. - -1. **Name to PC.** In AdGuard Home (admin `http://127.0.0.1:8080`), Filters > DNS rewrites, add - `prism.example.com` -> this PC's LAN address. The phone must use AdGuard for DNS (router DHCP - or the phone's own DNS setting). -2. **Proxy to Prism.** In Nginx Proxy Manager (admin `http://127.0.0.1:81`), add a proxy host for - `prism.example.com`, forwarding to `http://:` (the - port is shown in More > Phone). Turn on "Websockets support" and "Block common exploits". -3. **Certificate.** On the SSL tab, request a Let's Encrypt certificate with a **DNS challenge** - (Cloudflare and others): it works without opening any port to the internet. Turn on "Force - SSL". -4. **Prism.** More > Phone > Address phones use: `https://prism.example.com`, Save. Pair again. - -## The walls, through a proxy - -- Every request reaches Prism from the proxy, which is on your own network, so Prism's - "own network only" rule passes it. Every client address the proxy reports (`X-Real-IP`, each - `X-Forwarded-For` hop, `Forwarded: for=`) must ALSO be on your own network, or the request is - refused (`forwardedLocal` in `src/main/phone/lan.ts`). A proxy that is reachable from the - internet therefore still cannot carry the internet in. -- **Do not forward ports 80 or 443 to this PC on your router.** The DNS challenge in step 3 is - what makes that unnecessary. -- Pairing, the code's two minutes, read-only access and the per-phone walls are unchanged. diff --git a/package-lock.json b/package-lock.json index 2ec9f76f..9c6a2d97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.117.8", + "version": "0.117.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.117.8", + "version": "0.117.9", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/package.json b/package.json index 543638fb..02f55908 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.117.8", + "version": "0.117.9", "description": "A fast, universal media viewer for Windows.", "author": "Max", "license": "MIT", diff --git a/src/main/phone/index.ts b/src/main/phone/index.ts index 886d272d..defc7215 100644 --- a/src/main/phone/index.ts +++ b/src/main/phone/index.ts @@ -7,9 +7,9 @@ import { parsePins, type PhonePin } from './places' import { DEFAULT_PORT, PhoneServer } from './server' import type { HlsJobs } from './jobs' import type { Positions } from '../positions' -import { parseAddress, parseStore, serializeStore, type PhoneStore } from './store' +import { parseStore, serializeStore, type PhoneStore } from './store' import { lanAddresses } from './lan' -import { pairLink, pairLinkAt } from './routes' +import { pairLink } from './routes' import { qrSvg } from './qr' import { forget as forgetPhone } from './pairing' import { writeAtomic } from '../latestWrite' @@ -117,18 +117,15 @@ export function startPhoneService(opts: { addresses: E2E ? ['127.0.0.1'] : lanAddresses(networkInterfaces()), phones: phoneStore.pairing.phones.map((p) => ({ ...p })), watching: phone?.watching() ?? [], - error: phoneError || undefined, - address: phoneStore.address + error: phoneError || undefined } // A code from ANY tab (2026-10-14): it carries the folder that tab is // showing as the phone's first stop, or nothing (This PC). - if (phone && (phoneStore.address || state.addresses[0])) { + if (phone && state.addresses[0]) { const now = Date.now() const live = phoneStore.pairing.codes.find((c) => c.start === start && c.expires > now) const { code, expires } = live ?? phone.issue(start) - const link = phoneStore.address - ? pairLinkAt(phoneStore.address, code) - : pairLink(state.addresses[0], phone.port ?? DEFAULT_PORT, code) + const link = pairLink(state.addresses[0], phone.port ?? DEFAULT_PORT, code) state.code = { code, link, svg: await qrSvg(link), expires } } return state @@ -144,18 +141,6 @@ export function startPhoneService(opts: { else await stopPhone() return phoneState(startArg(start)) }) - // The address phones use (#406): empty clears it; anything that is not an - // http(s) origin is refused and the old value stays. - ipcMain.handle(CALL.phoneAddressSet, async (_e, raw: unknown, start: unknown) => { - await phoneReady - const empty = typeof raw === 'string' && !raw.trim() - const address = parseAddress(raw) - if (empty || address) { - phoneStore.address = empty ? null : address - savePhone() - } - return phoneState(startArg(start)) - }) ipcMain.handle(CALL.phoneCode, async (_e, start: unknown) => { await phoneReady const at = startArg(start) diff --git a/src/main/phone/routes.ts b/src/main/phone/routes.ts index d99bb02d..baa7690e 100644 --- a/src/main/phone/routes.ts +++ b/src/main/phone/routes.ts @@ -95,8 +95,3 @@ export function tokenOf(query: URLSearchParams, auth: string | undefined): strin export function pairLink(address: string, port: number, code: string): string { return `http://${address}:${port}/?code=${code}` } - -/** The same link through the address phones use (#406): an origin `parseAddress` kept. */ -export function pairLinkAt(origin: string, code: string): string { - return `${origin}/?code=${code}` -} diff --git a/src/main/phone/store.test.ts b/src/main/phone/store.test.ts index 8c4f61bf..9131c569 100644 --- a/src/main/phone/store.test.ts +++ b/src/main/phone/store.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseAddress, parseStore, serializeStore } from './store' +import { parseStore, serializeStore } from './store' describe('phone store', () => { it('round-trips and defaults', () => { @@ -9,8 +9,8 @@ describe('phone store', () => { expect(s.on).toBe(true) expect(s.port).toBe(47321) expect(s.pairing.phones).toHaveLength(1) - expect(parseStore('soup')).toEqual({ on: false, port: null, address: null, pairing: { codes: [], phones: [] } }) - expect(parseStore('')).toEqual({ on: false, port: null, address: null, pairing: { codes: [], phones: [] } }) + expect(parseStore('soup')).toEqual({ on: false, port: null, pairing: { codes: [], phones: [] } }) + expect(parseStore('')).toEqual({ on: false, port: null, pairing: { codes: [], phones: [] } }) expect(JSON.parse(serializeStore(s))).toMatchObject({ on: true, port: 47321 }) }) @@ -25,18 +25,4 @@ describe('phone store', () => { expect(serializeStore(s)).not.toContain('ABCDEF') expect(JSON.parse(serializeStore(s))).toEqual({ on: false, port: null, phones: [] }) }) - - it('keeps the address phones use (#406), as an origin only', () => { - const s = parseStore('{"on":true,"port":47321,"address":"https://prism.example.com/","phones":[]}') - expect(s.address).toBe('https://prism.example.com') - expect(JSON.parse(serializeStore(s)).address).toBe('https://prism.example.com') - expect(JSON.parse(serializeStore({ ...s, address: null }))).not.toHaveProperty('address') - }) - - it('takes http or https with a host and a port, nothing else', () => { - expect(parseAddress('http://prism.home')).toBe('http://prism.home') - expect(parseAddress(' https://Prism.Example.com:8443 ')).toBe('https://prism.example.com:8443') - for (const bad of ['', 'prism.home', 'ftp://x', 'javascript:alert(1)', 'https://u:p@x.com', 'https://x.com/path', 'https://x.com/?q=1', 'https://x.com/#h', 42, null]) - expect(parseAddress(bad)).toBeNull() - }) }) diff --git a/src/main/phone/store.ts b/src/main/phone/store.ts index 7e662892..31a58eb3 100644 --- a/src/main/phone/store.ts +++ b/src/main/phone/store.ts @@ -9,55 +9,23 @@ import { emptyState, parseState, type PairState } from './pairing' export interface PhoneStore { on: boolean port: number | null - /** The address phones use (#406), or null for this PC's own address. */ - address: string | null pairing: PairState } -/** - * THE ADDRESS PHONES USE (#406; owner, 2026-10-16: "so that we can support - * custom web addresses"). Optional: a friendly name the user's own network - * resolves (`http://prism.home`) or HTTPS through the user's own reverse - * proxy (`https://prism.example.com`), which is what lets the token travel - * encrypted with no certificate warning on the phone. Prism never ships a - * certificate and never needs one of these. Only an origin is kept: http or - * https, a host, an optional port; no user, no path, no query. - */ -export function parseAddress(raw: unknown): string | null { - if (typeof raw !== 'string') return null - const text = raw.trim() - if (!text || text.length > 300) return null - let url: URL - try { - url = new URL(text) - } catch { - return null - } - if (url.protocol !== 'http:' && url.protocol !== 'https:') return null - if (!url.hostname || url.username || url.password || url.search || url.hash) return null - if (url.pathname !== '/' && url.pathname !== '') return null - return url.origin -} - export function parseStore(raw: string): PhoneStore { try { - const j = JSON.parse(raw) as { on?: unknown; port?: unknown; address?: unknown } | null - if (!j || typeof j !== 'object') return { on: false, port: null, address: null, pairing: emptyState() } + const j = JSON.parse(raw) as { on?: unknown; port?: unknown } | null + if (!j || typeof j !== 'object') return { on: false, port: null, pairing: emptyState() } return { on: j.on === true, port: typeof j.port === 'number' && Number.isInteger(j.port) && j.port > 0 && j.port < 65536 ? j.port : null, - address: parseAddress(j.address), pairing: parseState(raw) } } catch { - return { on: false, port: null, address: null, pairing: emptyState() } + return { on: false, port: null, pairing: emptyState() } } } export function serializeStore(s: PhoneStore): string { - return JSON.stringify( - { on: s.on, port: s.port, ...(s.address ? { address: s.address } : {}), phones: s.pairing.phones }, - null, - 2 - ) + return JSON.stringify({ on: s.on, port: s.port, phones: s.pairing.phones }, null, 2) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 85879127..a0bd088b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -225,9 +225,6 @@ const api = { ipcRenderer.invoke(CALL.phoneSetOn, on, start), /** A fresh code for `start`, replacing the one it had. */ phoneCode: (start: string | null): Promise => ipcRenderer.invoke(CALL.phoneCode, start), - /** The address phones use (#406); an empty string clears it. */ - phoneAddressSet: (address: string, start: string | null): Promise => - ipcRenderer.invoke(CALL.phoneAddressSet, address, start), /** The dialog closed: no code it showed may still pair a phone. */ phoneCodeDrop: (): void => ipcRenderer.send(CALL.phoneCodeDrop), /** Forget a paired phone by token. */ diff --git a/src/renderer/src/components/PhoneDialog.tsx b/src/renderer/src/components/PhoneDialog.tsx index 4b4cb567..1056d4ac 100644 --- a/src/renderer/src/components/PhoneDialog.tsx +++ b/src/renderer/src/components/PhoneDialog.tsx @@ -80,7 +80,6 @@ export function PhoneDialog({

)} {state.error &&

{state.error}

} - {state.on && state.code && (
{/* The SVG is main's own: `qrcode` rendering a link main built from @@ -148,71 +147,3 @@ export function PhoneDialog({ /> ) } - -/** What `parseAddress` in main would keep, for telling a refusal from a save. */ -function originOf(text: string): string | null { - try { - return new URL(text.trim()).origin - } catch { - return null - } -} - -/** - * THE ADDRESS PHONES USE (#406; owner, 2026-10-16). Optional: empty means this - * PC's own address, as before. Set, the QR and the link use it: a name the - * user's network resolves, or HTTPS through the user's own reverse proxy. - * Main validates and keeps only an http(s) origin; a refusal leaves the old - * value and says so here. - */ -function AddressField({ - address, - start, - onState -}: { - address: string | null - start: string | null - onState: (s: PhoneState) => void -}): JSX.Element { - const [text, setText] = useState(address ?? '') - const [refused, setRefused] = useState(false) - const save = async (): Promise => { - const next = await window.prism.phoneAddressSet(text, start) - setRefused(!!text.trim() && next.address !== originOf(text)) - onState(next) - } - return ( -
- - {refused && ( - - Not an address: use http:// or https:// and a name, with no path. - - )} -
- ) -} diff --git a/src/shared/channels.ts b/src/shared/channels.ts index 1261ac5d..025c72f6 100644 --- a/src/shared/channels.ts +++ b/src/shared/channels.ts @@ -74,7 +74,6 @@ export const CALL = { phoneGet: 'phone:get', phoneSetOn: 'phone:set-on', phoneCode: 'phone:code', - phoneAddressSet: 'phone:address-set', phoneCodeDrop: 'phone:code-drop', phoneForget: 'phone:forget', phonePins: 'phone:pins', diff --git a/src/shared/types.ts b/src/shared/types.ts index fdeb9f5a..8c5ccd7d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -385,6 +385,4 @@ export interface PhoneState { /** A pairing code for the folder in front (or This PC): link and QR (SVG), when the server is up. */ code?: { code: string; link: string; svg: string; expires: number } error?: string - /** The address phones use (#406), or null for this PC's own address. */ - address: string | null } diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 87d7f48f..1f6d870e 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -13732,17 +13732,6 @@ async function phoneScenario(fixtures) { }) ok(!!shown.code && late.status !== 200, `a code is refused once the dialog closed (${late.status})`) } - // THE ADDRESS PHONES USE (#406): the QR and the link carry it; anything - // that is not an http(s) origin is refused; empty goes back to this PC. - { - const set = await win.evaluate(() => window.prism.phoneAddressSet('https://prism.example.com/', null)) - ok(set.address === 'https://prism.example.com', `the address is kept as an origin (${set.address})`) - ok(!!set.code?.link.startsWith('https://prism.example.com/?code='), `the link uses it (${set.code?.link})`) - const bad = await win.evaluate(() => window.prism.phoneAddressSet('prism.example.com/path', null)) - ok(bad.address === 'https://prism.example.com', 'a bad address is refused and the old one stays') - const cleared = await win.evaluate(() => window.prism.phoneAddressSet('', null)) - ok(cleared.address === null && !!cleared.code?.link.startsWith('http://127.0.0.1:'), `empty goes back to this PC (${cleared.code?.link})`) - } ok(!!state.code && state.code.svg.startsWith('