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
19 changes: 11 additions & 8 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 0 additions & 45 deletions docs/phone-address.md

This file was deleted.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
25 changes: 5 additions & 20 deletions src/main/phone/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
5 changes: 0 additions & 5 deletions src/main/phone/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
20 changes: 3 additions & 17 deletions src/main/phone/store.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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 })
})

Expand All @@ -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()
})
})
40 changes: 4 additions & 36 deletions src/main/phone/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
3 changes: 0 additions & 3 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PhoneState> => ipcRenderer.invoke(CALL.phoneCode, start),
/** The address phones use (#406); an empty string clears it. */
phoneAddressSet: (address: string, start: string | null): Promise<PhoneState> =>
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. */
Expand Down
69 changes: 0 additions & 69 deletions src/renderer/src/components/PhoneDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export function PhoneDialog({
</p>
)}
{state.error && <p className="text-[#d97b84]">{state.error}</p>}
<AddressField key={state.address ?? ''} address={state.address} start={start} onState={setState} />
{state.on && state.code && (
<div className="flex gap-4">
{/* The SVG is main's own: `qrcode` rendering a link main built from
Expand Down Expand Up @@ -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<void> => {
const next = await window.prism.phoneAddressSet(text, start)
setRefused(!!text.trim() && next.address !== originOf(text))
onState(next)
}
return (
<div className="flex flex-col gap-1" data-phone-address>
<label className="flex flex-col gap-1">
<span className="font-medium text-[var(--p-text)]">Address phones use (optional)</span>
<span>
A name or HTTPS address from your own network setup, for example a reverse proxy.
Leave empty to use this PC's address.
</span>
<div className="flex gap-2">
<input
className="min-w-0 flex-1 rounded border border-[color:var(--p-line)] bg-transparent px-2 py-0.5 text-[var(--p-text)]"
placeholder="https://prism.example.com"
value={text}
spellCheck={false}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void save()
}}
/>
<button
className="shrink-0 rounded border border-[color:var(--p-line)] px-2 py-0.5 text-[var(--p-text-soft)] transition-colors hover:bg-[var(--p-hover)] hover:text-[var(--p-text)]"
onClick={() => void save()}
>
Save
</button>
</div>
</label>
{refused && (
<span className="text-[#d97b84]" role="status">
Not an address: use http:// or https:// and a name, with no path.
</span>
)}
</div>
)
}
1 change: 0 additions & 1 deletion src/shared/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 0 additions & 2 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
11 changes: 0 additions & 11 deletions tools/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('<svg'), 'the QR renders as SVG')
ok(status === 200, 'pairing succeeds')
ok(root.toLowerCase() === fixtures.toLowerCase(), 'the phone starts in the folder the PC is showing')
Expand Down
Loading