diff --git a/CLAUDE.md b/CLAUDE.md index 8b2d566b..c3bc77c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,8 +145,9 @@ mpv host's installed copy, WSL): `docs/notes/local-pc.md`. - **SPLICER only processes `.mjs` and `.js`.** Code moved into a `.ts` file silently stops being spliced. Stay on `.mjs` plus JSDoc. - **A change to `native-host/faststream-mpv-host.mjs` or `install.ps1` raises the host's - version**: `HostVersion` there, `RequiredHostVersion` in `MpvBackend.mjs`, and the record - in `tests/unit/mpvHostVersion.test.mjs`, which fails until they agree. + version**: `HostVersion` there and the record in `tests/unit/mpvHostVersion.test.mjs`, + which fails until they agree; `RequiredHostVersion` in `MpvBackend.mjs` too only when the + extension needs the new host (a change with no new behaviour leaves it: no reinstall). - **A file with `// @ts-check` keeps it**, and is listed in `tests/unit/typeChecked.test.mjs`. `types/messages.d.ts` gets a message only after its real payload was read. diff --git a/chrome/player/players/vm/VMPlayer.mjs b/chrome/player/players/vm/VMPlayer.mjs index 96e859c7..92ef117e 100644 --- a/chrome/player/players/vm/VMPlayer.mjs +++ b/chrome/player/players/vm/VMPlayer.mjs @@ -5,6 +5,10 @@ import {RequestUtils} from '../../utils/RequestUtils.mjs'; import HLSPlayer from '../hls/HLSPlayer.mjs'; export default class VMPlayer extends HLSPlayer { + /** + * @param {import('../../FastStreamClient.mjs').FastStreamClient} client - The player client the player talks through. + * @param {*} options - The player's options, passed unchanged to the base player. + */ constructor(client, options) { super(client, options); } @@ -37,6 +41,9 @@ export default class VMPlayer extends HLSPlayer { return 'https://player.vimeo.com/video/' + encodeURIComponent(path[1]) + (path[2] ? '/config' : '') + query; } + /** + * @param {import('../../VideoSource.mjs').VideoSource} source - The Vimeo source to play. + */ async setSource(source) { try { const requestUrl = VMPlayer.vimeoRequestUrl(source.url); @@ -48,13 +55,13 @@ export default class VMPlayer extends HLSPlayer { const isEmbed = !new URL(requestUrl).pathname.endsWith('/config'); const hc = []; if (Array.isArray(source.headers)) { - source.headers.forEach((h) => { + source.headers.forEach(/** @type {(h: {name: string, value: string}) => void} */ ((h) => { hc.push({ operation: 'set', header: h.name, value: h.value, }); - }); + })); } else { for (const key in source.headers) { if (Object.hasOwn(source.headers, key)) { @@ -114,6 +121,9 @@ export default class VMPlayer extends HLSPlayer { return this.source; } + /** + * @param {string} html - The HTML of the Vimeo player page. + */ extractVimeoHlsUrlFromIframePlayer(html) { const config = this.extractJsonConfig(html, 'window.playerConfig ='); @@ -124,6 +134,10 @@ export default class VMPlayer extends HLSPlayer { return config?.request?.files?.hls; } + /** + * @param {string} html - The HTML to search for the JSON config. + * @param {string} prefix - The text that marks where the JSON starts. + */ extractJsonConfig(html, prefix) { const startIndex = html.indexOf(prefix); if (startIndex === -1) return null; diff --git a/chrome/player/ui/VolumeControls.mjs b/chrome/player/ui/VolumeControls.mjs index 948610fd..3c330188 100644 --- a/chrome/player/ui/VolumeControls.mjs +++ b/chrome/player/ui/VolumeControls.mjs @@ -15,6 +15,7 @@ if (!EnvUtils.isWebAudioSupported()) { const VolumeSaveDelayMs = 500; export class VolumeControls extends EventEmitter { + /** @param {import('../FastStreamClient.mjs').FastStreamClient} client */ constructor(client) { super(); this.client = client; @@ -61,6 +62,7 @@ export class VolumeControls extends EventEmitter { this.loadVolumeState(); } + /** @param {number} volume */ setVolume(volume, dontSave = false) { if (volume < 0) { volume = 0; @@ -95,6 +97,7 @@ export class VolumeControls extends EventEmitter { } } + /** @param {MouseEvent} event */ onVolumeBarMouseDown(event) { // Only the left button drags. A right-click's context menu takes the mouseup, and the // volume then followed the mouse until the next click. @@ -102,6 +105,7 @@ export class VolumeControls extends EventEmitter { return; } + /** @param {number} volumeBarX */ const shiftVolume = (volumeBarX) => { const totalWidth = /** @type {HTMLElement} */ (DOMElements.volumeControlBar).clientWidth; @@ -120,13 +124,14 @@ export class VolumeControls extends EventEmitter { } }; + /** @param {MouseEvent|TouchEvent} event */ const onVolumeBarMouseMove = (event) => { // No button held: it was let go where this drag never heard of it. - if (event.type === 'mousemove' && event.buttons === 0) { + if (event.type === 'mousemove' && /** @type {MouseEvent} */ (event).buttons === 0) { stopDrag(); return; } - const currentX = event.clientX - WebUtils.getOffsetLeft(/** @type {HTMLElement} */ (DOMElements.volumeContainer)) - 10; + const currentX = /** @type {MouseEvent} */ (event).clientX - WebUtils.getOffsetLeft(/** @type {HTMLElement} */ (DOMElements.volumeContainer)) - 10; shiftVolume(currentX); }; @@ -138,10 +143,11 @@ export class VolumeControls extends EventEmitter { document.removeEventListener('mouseup', onVolumeBarMouseUp); }; + /** @param {MouseEvent|TouchEvent} event */ const onVolumeBarMouseUp = (event) => { stopDrag(); - const currentX = event.clientX - WebUtils.getOffsetLeft(/** @type {HTMLElement} */ (DOMElements.volumeContainer)) - 10; + const currentX = /** @type {MouseEvent} */ (event).clientX - WebUtils.getOffsetLeft(/** @type {HTMLElement} */ (DOMElements.volumeContainer)) - 10; if (!isNaN(currentX)) { shiftVolume(currentX); @@ -159,6 +165,7 @@ export class VolumeControls extends EventEmitter { event.stopPropagation(); } + /** @param {number} volume */ updateVolumeBar(volume) { const currentVolumeTag = /** @type {HTMLElement} */ (DOMElements.currentVolume); const muteButtonTag = /** @type {HTMLElement} */ (DOMElements.muteBtn); @@ -192,7 +199,7 @@ export class VolumeControls extends EventEmitter { const state = await Utils.loadAndParseOptions('volumeState', { volume: 1, }); - this.setVolume(state.volume, true); + this.setVolume(/** @type {{volume: number}} */ (state).volume, true); } /** Saves a volume change still waiting for its delay. */ diff --git a/chrome/player/ui/VpnPrompt.mjs b/chrome/player/ui/VpnPrompt.mjs index c9dbbe19..48656873 100644 --- a/chrome/player/ui/VpnPrompt.mjs +++ b/chrome/player/ui/VpnPrompt.mjs @@ -16,7 +16,7 @@ import {DOMElements} from './DOMElements.mjs'; */ export class VpnPrompt { /** - * @param {Object} client - The FastStreamClient. + * @param {import('../FastStreamClient.mjs').FastStreamClient} client - The FastStreamClient. */ constructor(client) { this.client = client; @@ -42,10 +42,10 @@ export class VpnPrompt { async check(source) { this.hide(); this.source = source; - if (!EnvUtils.isExtension() || !source?.url) return; + if (!EnvUtils.isExtension() || !/** @type {{url: string}} */ (source)?.url) return; let status; try { - status = await chrome.runtime.sendMessage({type: MessageTypes.VPN_STATUS, url: source.url}); + status = await chrome.runtime.sendMessage({type: MessageTypes.VPN_STATUS, url: /** @type {{url: string}} */ (source).url}); } catch (e) { return; } diff --git a/chrome/player/ui/audio/AbstractAudioModule.mjs b/chrome/player/ui/audio/AbstractAudioModule.mjs index d7ce43f2..567a947f 100644 --- a/chrome/player/ui/audio/AbstractAudioModule.mjs +++ b/chrome/player/ui/audio/AbstractAudioModule.mjs @@ -3,6 +3,9 @@ import {EventEmitter} from '../../modules/eventemitter.mjs'; import {VirtualAudioNode} from './VirtualAudioNode.mjs'; export class AbstractAudioModule extends EventEmitter { + /** + * @param {string} name + */ constructor(name) { super(); this.audioContext = null; diff --git a/chrome/player/utils/BlobManager.mjs b/chrome/player/utils/BlobManager.mjs index 1c253e70..6633437a 100644 --- a/chrome/player/utils/BlobManager.mjs +++ b/chrome/player/utils/BlobManager.mjs @@ -5,7 +5,7 @@ export class BlobManager { /** * Creates a blob from data and MIME type. - * @param {Array} data - The data to store in the blob. + * @param {Array} data - The data to store in the blob. * @param {string} mimeType - The MIME type of the blob. * @return {Blob} The created blob. */ diff --git a/docs/notes/mpv.md b/docs/notes/mpv.md index 568f1017..0be1c0fb 100644 --- a/docs/notes/mpv.md +++ b/docs/notes/mpv.md @@ -27,10 +27,12 @@ to it, and the result carries `hostOutdated`. That shows as "!" on the toolbar b in MPV mode with what to run in the tooltip (`tab.mpvHostOutdated`, persisted like `mpvError`, whose reason comes first), in the player's "Sent to mpv" message, and after "Test mpv connection". This replaced `mpv-host-changed.yml`'s e-mail. **Every change to -the host file or to `install.ps1` raises both numbers by one**: -`tests/unit/mpvHostVersion.test.mjs` records both files' SHA-256 beside the version and -fails until `HostVersion`, `RequiredHostVersion` and the record agree (the failure prints -the new hashes). The installer counts because `update-local.ps1` tells an installed host +the host file or to `install.ps1` raises `HostVersion` by one**, and `RequiredHostVersion` +with it only when the extension needs the new host (since 2026-10-23: a change that alters +no behaviour, such as the host's types, has every installed host go on working, and nobody +is asked to reinstall): `tests/unit/mpvHostVersion.test.mjs` records both files' SHA-256 +beside the version, fails until `HostVersion` and the record agree (the failure prints the +new hashes), and checks `RequiredHostVersion <= HostVersion`. The installer counts because `update-local.ps1` tells an installed host from the repository's by the host file alone; a raised `HostVersion` changes that file. The extension changes with it, so such a push releases, Firefox updates the extension, and the next hand-off on the owner's PC shows the "!" until `update-local.cmd` (or diff --git a/docs/notes/type-checking.md b/docs/notes/type-checking.md index 001d4ed9..e22dc7b1 100644 --- a/docs/notes/type-checking.md +++ b/docs/notes/type-checking.md @@ -46,10 +46,10 @@ is lowered with `pnpm run typecheck:strict -- --update`, so a typed file cannot unnoticed. `tests/unit/checkImplicitAny.test.mjs` covers the rules (each broken once, and caught), and an untyped parameter planted in a file at 0 failed the check with its line. -The mpv host (`native-host/faststream-mpv-host.mjs`, 76 untyped places) is left out on -purpose: any change to it raises `HostVersion` and `RequiredHostVersion`, and the add-on -then has every user reinstall the helper, for comments that change no behaviour. It is -typed the next time it changes for a real reason; until then the ratchet holds its count. +The mpv host (`native-host/faststream-mpv-host.mjs`) was typed on 2026-10-23 (76 to 1 +untyped places) with `HostVersion` 20 and `RequiredHostVersion` left at 19: a change +that alters no behaviour raises only the host's own version, so installed helpers go on +working and nobody is asked to reinstall for comments (the owner's choice). ### The libraries' own types (2026-10-22) diff --git a/native-host/faststream-mpv-host.mjs b/native-host/faststream-mpv-host.mjs index 8861c386..7c6eb214 100644 --- a/native-host/faststream-mpv-host.mjs +++ b/native-host/faststream-mpv-host.mjs @@ -87,7 +87,7 @@ const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); // install.ps1 (what it installs is part of the host a PC has), together with // RequiredHostVersion and the hashes in tests/unit/mpvHostVersion.test.mjs, which fails // until they agree. -export const HostVersion = 19; +export const HostVersion = 20; // The messages of the thin helper (host 15) this host understands, sent with every answer // so that the extension knows what it may ask for without a version table of its own. A @@ -127,7 +127,7 @@ export function debugFromEnvironment(value) { * Never writes to stdout: that channel carries length-prefixed native * messages, and stray bytes on it corrupt the reply the browser is parsing. * - * @param {Object} config - The parsed config.json. + * @param {{debug?: boolean}} config - The parsed config.json. * @param {string} label - Short tag for the entry. * @param {*} [detail] - Optional JSON-serializable payload. * @param {string} [file] - The log; faststream-mpv-host.log next to this script by default. @@ -166,7 +166,7 @@ export function loggedMessage(message) { if (!message || typeof message !== 'object' || !Array.isArray(message.subtitles)) { return message; } - const chars = message.subtitles.reduce((sum, s) => sum + (s && typeof s.srt === 'string' ? s.srt.length : 0), 0); + const chars = message.subtitles.reduce((/** @type {number} */ sum, /** @type {{srt?: string}} */ s) => sum + (s && typeof s.srt === 'string' ? s.srt.length : 0), 0); return {...message, subtitles: {count: message.subtitles.length, chars}}; } @@ -390,14 +390,14 @@ export function readMessage(input = process.stdin) { // The bytes of an over-size frame still to be read past. let skipping = 0; - const finish = (result) => { + const finish = (/** @type {Object|typeof TooLarge|null} */ result) => { input.removeListener('data', onData); input.removeListener('end', onEnd); input.removeListener('error', onEnd); resolve(result); }; - const onData = (chunk) => { + const onData = (/** @type {Buffer} */ chunk) => { let offset = 0; while (offset < chunk.length) { if (skipping > 0) { @@ -475,7 +475,7 @@ export function readMessage(input = process.stdin) { * relative path: mpv made a file of that name in its working directory, and reuse only * worked when mpv and the host happened to share one. * - * @param {Object} config - The parsed config.json. + * @param {{ipcToken?: string}} config - The parsed config.json. * @param {string} [platform] - process.platform, or a stand-in. * @param {Object} [env] - The environment. * @return {string} The pipe or socket. @@ -542,14 +542,14 @@ export const ReusedWindowWaitSeconds = 5; export function mpvIpcRequest(commands, timeoutMs = 1500, replyTimeoutMs = 6000, pipe = ipcPipeFor(readConfig())) { return new Promise((resolve) => { let settled = false; - const replies = []; + const replies = /** @type {Object[]} */ ([]); let buffer = ''; const socket = net.connect(pipe); // Decoded as a stream: a character mpv sends in two chunks stays one character. socket.setEncoding('utf8'); - const finish = (value) => { + const finish = (/** @type {{ok: boolean, replies?: Array, error?: string, busy?: boolean}} */ value) => { if (settled) { return; } @@ -733,7 +733,7 @@ export function watchMpv(send, io = {}) { lastBeat = Math.max(Date.now(), lastBeat + 1); write(['set_property', 'user-data/faststream/heartbeat', lastBeat]); }; - const finish = (reason) => { + const finish = (/** @type {string} */ reason) => { if (finished) return; finished = true; if (heartbeat) clearInterval(heartbeat); @@ -814,7 +814,7 @@ export function watchMpv(send, io = {}) { endChecks.delete(event.request_id); if (event.error === 'success' && typeof event.data === 'number' && event.data <= EndSlackSeconds) ended(); } else if (event.event === 'client-message' && Array.isArray(event.args)) { - const action = EpisodeMessages[event.args[0]]; + const action = /** @type {'next'|'previous'|'autoplay'} */ (EpisodeMessages[/** @type {keyof typeof EpisodeMessages} */ (event.args[0])]); if (action) send({type: 'request', action}); } else if (notices.has(event.request_id)) { const notice = notices.get(event.request_id); @@ -844,7 +844,7 @@ export function watchMpv(send, io = {}) { // No request id: an mpv without notify.lua refuses it, and there is nothing to take down. write(['script-message-to', 'notify', 'hide', 'faststream']); }, - state(state) { + state(/** @type {{autoplay?: boolean}} */ state) { if (state && typeof state.autoplay === 'boolean') { write(['set_property', 'user-data/faststream/autoplay', state.autoplay]); } @@ -872,7 +872,7 @@ export function readMessages(input, onMessage, onEnd) { input.removeListener('data', onData); onEnd(); }; - const onData = (chunk) => { + const onData = (/** @type {Buffer} */ chunk) => { pending = Buffer.concat([pending, chunk]); while (pending.length >= 4) { const length = pending.readUInt32LE(0); @@ -1079,6 +1079,16 @@ export function qualityFragmentFor(qualities) { return list.length >= 2 ? encodeURIComponent(JSON.stringify(list)) : undefined; } +/** + * The open message from the extension (the list at the top of this file): the + * stream to play, and what goes with it. Only url is always there; the rest is + * the sender's choice, checked where the host uses each one. + * @typedef {{url: string, headers?: Object, title?: string, + * fullscreen?: boolean, singleInstance?: boolean, contentType?: string, + * pageUrl?: string, start?: number, subtitles?: Array<{srt: string, + * label?: string}>, qualities?: Array<{url: string, label?: string, + * height?: number}>}} OpenMessage + */ /** * The URL to hand mpv for an open message: the stream URL plus its * fs-content=, fs-id=, fs-page= and fs-quality= fragment tags. @@ -1088,7 +1098,7 @@ export function qualityFragmentFor(qualities) { * and other characters a request line cannot carry. (mpv 0.41 cleans those up itself, * measured; the host does not count on every mpv doing so.) * - * @param {Object} message - The open message from the extension; its url passed + * @param {OpenMessage} message - The open message from the extension; its url passed * isStreamUrl. * @return {string} The URL for mpv. */ @@ -1239,7 +1249,7 @@ export function perFileOptions(headerFields, title, extras = {}, delimiter = pat /** * Loads a URL into the mpv instance already running on our pipe. * - * @param {Object} message - The open message from the extension. + * @param {OpenMessage} message - The open message from the extension. * @param {Array} headerFields - "Name: value" strings for mpv. * @param {string} title - Media title to display. * @param {typeof mpvIpcRequest} [ipcRequest] - Injectable for tests; defaults @@ -1299,7 +1309,7 @@ export async function loadInto(target, options, fullscreen, ipcRequest) { // fresh instance that would have played it. Nor is it "no instance": ours // answered something, so it runs, and a second mpv on the same pipe would // get no IPC (busy). - const replies = result.replies || []; + const replies = /** @type {Array<{request_id: number, error?: string, data?: *}>} */ (result.replies || []); const loadReply = replies.find((r) => r.request_id === commands.length - 1); if (!loadReply) { return {ok: false, busy: true}; @@ -1345,15 +1355,15 @@ export function decoderFromReplies(replies) { * @return {*} Its value, or undefined for an error or no reply. */ const value = (index) => { - const reply = (replies || []).find((r) => r && r.request_id === index + 1); + const reply = /** @type {Array<{request_id: number, error?: string, data?: *}>} */ (replies || []).find((r) => r && r.request_id === index + 1); return reply && (reply.error === undefined || reply.error === 'success') ? reply.data : undefined; }; const api = value(0); if (typeof api !== 'string' || !api) { return null; } - const text = (v) => (typeof v === 'string' && v ? v : null); - const size = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null); + const text = (/** @type {*} */ v) => (typeof v === 'string' && v ? v : null); + const size = (/** @type {*} */ v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null); return { hardware: api !== 'no', api, @@ -1369,7 +1379,7 @@ export function decoderFromReplies(replies) { * waiting up to waitMs for one to load. Only an instance started for single-instance use * has the pipe, so without it the answer is "not running" even when an mpv is open. * - * @param {number} waitMs - How long to keep asking while mpv has no decoder yet. + * @param {number} [waitMs] - How long to keep asking while mpv has no decoder yet (none: 0). * @param {typeof mpvIpcRequest} [ipcRequest] - Injectable for tests. * @param {(ms: number) => Promise} [sleep] - Injectable for tests. * @param {() => number} [now] - Injectable for tests. @@ -1758,7 +1768,7 @@ export function streamTitle(message) { * Plays an open message's stream in mpv: in the instance this host started before, when * the message asks for that and one runs, else in a new one. * @param {string} mpvPath - The mpv executable (resolveMpvPath). - * @param {Object} message - The open message; its url passed isStreamUrl. + * @param {OpenMessage} message - The open message; its url passed isStreamUrl. * @param {Object} config - The parsed config.json. * @param {{ipcRequest?: typeof mpvIpcRequest, focus?: typeof focusPid, * start?: (mpvPath: string, args: Array) => Promise<{ok: boolean, error?: string, @@ -1894,8 +1904,8 @@ export async function launchMpv(mpvPath, message, config, io = {}) { // launchViaWmi. // The launch lock (above) waits on the mpv started from then on: this helper ends now, // and the next send waits for that mpv's pipe while it runs. - const settleLock = (result) => { - if (result.ok && Number.isInteger(result.pid)) lock?.hand(result.pid); + const settleLock = (/** @type {{ok: boolean, error?: string, pid?: number, focus?: string, foreground?: string}} */ result) => { + if (result.ok && Number.isInteger(result.pid)) lock?.hand(/** @type {number} */ (result.pid)); else lock?.release(); return result; }; @@ -2262,7 +2272,7 @@ const PlayHeader = /^(referer|origin|user-agent): [ -~]{1,4096}$/i; * @return {{ok: true, play: PlaySpec}|{ok: false, error: string}} */ export function checkPlayMessage(message) { - const refuse = (error) => ({ok: /** @type {false} */ (false), error}); + const refuse = (/** @type {string} */ error) => ({ok: /** @type {false} */ (false), error}); const {target} = message; if (typeof target !== 'string' || !isStreamUrl(target)) { return refuse('mpv is only given http(s) streams'); @@ -2350,7 +2360,7 @@ export const LaunchLockMs = PipeWaitMs + 5000; * @return {string} */ export function launchLockFile(config, dir = __dirname) { - const token = config && typeof config.ipcToken === 'string' && /^[0-9a-f]{32}$/.test(config.ipcToken) ? config.ipcToken : ''; + const token = config && typeof (/** @type {{ipcToken?: string}} */ (config)).ipcToken === 'string' && /^[0-9a-f]{32}$/.test((/** @type {{ipcToken: string}} */ (config)).ipcToken) ? (/** @type {{ipcToken: string}} */ (config)).ipcToken : ''; const id = crypto.createHash('sha256').update('faststream-mpv-launch:' + token).digest('hex').slice(0, 16); return path.join(dir, `faststream-mpv-launch-${id}.lock`); } @@ -2689,7 +2699,7 @@ export async function runIpc(commands, io = {}) { if (!result.ok) { return {ok: false, error: result.error || 'no mpv ipc', ...(result.busy ? {busy: true} : {})}; } - const replies = result.replies || []; + const replies = /** @type {Array<{request_id: number, error: *, data: *}>} */ (result.replies || []); return { ok: true, replies: commands.map((command, index) => { @@ -2967,8 +2977,8 @@ async function handleMessage() { return; } - if (message.type === 'ping') { - const mpvPath = resolveMpvPath(message.mpvPath); + if ((/** @type {{type: string}} */ (message)).type === 'ping') { + const mpvPath = resolveMpvPath((/** @type {{mpvPath?: string}} */ (message)).mpvPath); debugLog(config, 'ping', {mpvPath}); if (mpvPath) { await sendMessage({ok: true, mpv: true, path: mpvPath}); @@ -2980,15 +2990,15 @@ async function handleMessage() { // Which video decoder the mpv this host started uses: the extension asks after a // hand-off, and "Test mpv connection" asks too. Read-only: nothing is changed in mpv. - if (message.type === 'status') { - const status = await queryDecoder(message.waitMs); + if ((/** @type {{type: string}} */ (message)).type === 'status') { + const status = await queryDecoder((/** @type {{waitMs?: number}} */ (message)).waitMs); debugLog(config, 'status', status); await sendMessage({ok: true, ...status}); return; } // MPV autoplay: follows mpv for as long as the extension keeps the port open. - if (message.type === 'watch') { + if ((/** @type {{type: string}} */ (message)).type === 'watch') { const watch = watchMpv((event) => { debugLog(config, 'watch', event); sendMessage(event); @@ -2998,9 +3008,9 @@ async function handleMessage() { // episode buttons up with no one to hear them. It ends as when the extension lets go. process.stdout.on('error', () => watch.stop()); readMessages(process.stdin, (next) => { - if (next.type === 'notify') watch.notify(String(next.title || ''), String(next.detail || ''), next.seconds); - else if (next.type === 'hide') watch.hide(); - else if (next.type === 'state') watch.state(next); + if ((/** @type {{type: string}} */ (next)).type === 'notify') watch.notify(String((/** @type {{title?: *}} */ (next)).title || ''), String((/** @type {{detail?: *}} */ (next)).detail || ''), (/** @type {{seconds?: *}} */ (next)).seconds); + else if ((/** @type {{type: string}} */ (next)).type === 'hide') watch.hide(); + else if ((/** @type {{type: string}} */ (next)).type === 'state') watch.state(next); }, () => watch.stop()); await watch.done; // The last message ('closed') out before the host goes. @@ -3012,7 +3022,7 @@ async function handleMessage() { // may name the options page's mpv path; resolveMpvPath takes it only as one of the mpv // candidates it checks (an existing executable named mpv), so the extension never makes // the helper start another program. - if (message.type === 'play') { + if ((/** @type {{type: string}} */ (message)).type === 'play') { const checked = checkPlayMessage(message); if (!checked.ok) { debugLog(config, 'play-refused', {error: checked.error}); @@ -3026,8 +3036,8 @@ async function handleMessage() { } // Host 15: allowlisted mpv commands, one answer (runIpc). - if (message.type === 'ipc') { - const result = await runIpc(message.commands, {pipe: ipcPipeFor(config)}); + if ((/** @type {{type: string}} */ (message)).type === 'ipc') { + const result = await runIpc((/** @type {{commands?: *}} */ (message)).commands, {pipe: ipcPipeFor(config)}); debugLog(config, 'ipc', {ok: result.ok, error: result.error, index: result.index, reason: result.reason}); await sendMessage(result); return; @@ -3035,7 +3045,7 @@ async function handleMessage() { // Host 15: mpv's lines to the extension and its commands to mpv, for as long as the // extension keeps the port open (relayMpv). - if (message.type === 'relay') { + if ((/** @type {{type: string}} */ (message)).type === 'relay') { const checked = checkRelayMessage(message); if (!checked.ok) { debugLog(config, 'relay-refused', {error: checked.error}); @@ -3046,7 +3056,7 @@ async function handleMessage() { let last = Promise.resolve(); const relay = relayMpv((event) => { // mpv's own lines (time-pos many times a second) stay out of the log. - if (event.type !== 'mpv' && event.type !== 'mpv-reply') debugLog(config, 'relay', event); + if ((/** @type {{type: string}} */ (event)).type !== 'mpv' && (/** @type {{type: string}} */ (event)).type !== 'mpv-reply') debugLog(config, 'relay', event); last = sendMessage(event); }, checked, {pipe: ipcPipeFor(config)}); // The browser went while mpv plays on: as when the extension lets go (the watch's reason). @@ -3058,19 +3068,19 @@ async function handleMessage() { return; } - if (message.type === 'open' && typeof message.url === 'string' && message.url.length > 0) { - if (!isStreamUrl(message.url)) { - debugLog(config, 'refused', {url: message.url}); + if ((/** @type {{type: string}} */ (message)).type === 'open' && typeof (/** @type {{url?: string}} */ (message)).url === 'string' && (/** @type {{url: string}} */ (message)).url.length > 0) { + if (!isStreamUrl((/** @type {{url: string}} */ (message)).url)) { + debugLog(config, 'refused', {url: (/** @type {{url: string}} */ (message)).url}); await sendMessage({ok: false, error: 'mpv is only given http(s) streams'}); return; } - const mpvPath = resolveMpvPath(message.mpvPath); + const mpvPath = resolveMpvPath((/** @type {{mpvPath?: string}} */ (message)).mpvPath); if (!mpvPath) { - await sendMessage({ok: false, error: mpvNotFoundError(message.mpvPath)}); + await sendMessage({ok: false, error: mpvNotFoundError((/** @type {{mpvPath?: string}} */ (message)).mpvPath)}); return; } const result = await launchMpv(mpvPath, message, config); - debugLog(config, 'launched', {mpvPath, url: message.url, result}); + debugLog(config, 'launched', {mpvPath, url: (/** @type {{url: string}} */ (message)).url, result}); await sendMessage(result); return; } diff --git a/tests/unit/mpvHostVersion.test.mjs b/tests/unit/mpvHostVersion.test.mjs index 0ed41503..0139e422 100644 --- a/tests/unit/mpvHostVersion.test.mjs +++ b/tests/unit/mpvHostVersion.test.mjs @@ -16,8 +16,8 @@ import {Capabilities, HostVersion, withHostVersion} from '../../native-host/fast // that is older than the host it was released with. // // That only works while the version moves with the host, so this file is the ratchet: a -// change to the host or its installer fails it until HostVersion, RequiredHostVersion and -// RECORDED agree. +// change to the host or its installer fails it until HostVersion and RECORDED agree. +// RequiredHostVersion follows only when the extension needs what the new host does. const root = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '../..'); const hostFile = path.join(root, 'native-host/faststream-mpv-host.mjs'); @@ -28,12 +28,12 @@ const installerFile = path.join(root, 'native-host/install.ps1'); // manifest, config.json) is part of the host a PC has, and update-local.ps1 tells an // installed host from the repository's by the host file alone: raising HostVersion changes // that file, so a change to install.ps1 reaches the PC the same way. After a change to -// either: raise HostVersion in the host and RequiredHostVersion in -// chrome/background/MpvBackend.mjs by one, then put the new values here (a failure prints -// the hashes). +// either: raise HostVersion in the host by one, and RequiredHostVersion in +// chrome/background/MpvBackend.mjs with it when the extension needs the new host; then put +// the new values here (a failure prints the hashes). const RECORDED = { - version: 19, - host: 'e29a8f4398ec221ae204bd2fbf674edf9d456d703f73ceb2260e80a4002bed32', + version: 20, + host: '9284b9810ff7f4842d8bbd7ef9b679818be82e7fddd9e2b7ca657699125578ab', installer: 'db3b8027b508556b8af66eb4b8188460c2a5a6e1feb32d32b490f2e234b29b3a', }; @@ -52,15 +52,16 @@ describe('the mpv host\'s version', () => { const next = HostVersion === RECORDED.version ? HostVersion + 1 : HostVersion; expect({version: HostVersion, host: sha256Of(hostFile), installer: sha256Of(installerFile)}, 'native-host/faststream-mpv-host.mjs or install.ps1 changed. A PC keeps running its ' + - 'installed copy, so the version has to move: set HostVersion in the host and ' + - `RequiredHostVersion in chrome/background/MpvBackend.mjs to ${next}, then run this test ` + + 'installed copy, so the version has to move: set HostVersion in the host to ' + + `${next} (and RequiredHostVersion in chrome/background/MpvBackend.mjs too if the extension needs it), then run this test ` + 'again and record the version and the hashes it prints in RECORDED').toEqual(RECORDED); }); - it('is the one the extension asks for', () => { - // Lower, and an extension release would call the host it ships beside outdated; - // higher, and a changed host would never be asked for. - expect(RequiredHostVersion).toBe(HostVersion); + it('is at least the one the extension asks for', () => { + // Higher than the host, and an extension release would call the host it ships beside + // outdated. Lower is a host change the extension does not need (comments, 2026-10-23): + // installed hosts keep working, and nobody is asked to reinstall for it. + expect(RequiredHostVersion).toBeLessThanOrEqual(HostVersion); expect(Number.isInteger(HostVersion) && HostVersion >= 1).toBe(true); }); diff --git a/tools/ast-compare.mjs b/tools/ast-compare.mjs index 0eb52161..13be0177 100644 --- a/tools/ast-compare.mjs +++ b/tools/ast-compare.mjs @@ -22,7 +22,10 @@ import fs from 'node:fs'; import * as acorn from 'acorn'; -/** Wraps a bare statement in a block, so `curly` cannot cause a mismatch. */ +/** + * Wraps a bare statement in a block, so `curly` cannot cause a mismatch. + * @param {any} n an AST node, array of nodes, or primitive + */ function block(n) { if (!n || typeof n !== 'object' || Array.isArray(n)) return n; if (n.type === 'BlockStatement') return n; @@ -75,6 +78,7 @@ export function normalise(node) { } if (node && typeof node === 'object') { + /** @type {{[key: string]: any}} */ const out = {}; // Sorted so that a node this function rebuilds cannot differ from an // equivalent one merely by key order. @@ -160,6 +164,9 @@ export function fingerprint(src) { */ export function declarations(src) { const out = new Map(); + /** + * @param {any[]} body an array of AST statement nodes + */ const walk = (body) => { for (const node of body) { if (node.type === 'ExportNamedDeclaration' || @@ -213,6 +220,9 @@ export function declarations(src) { */ export function prototypeAssignments(src) { const out = new Map(); + /** + * @param {any} node an AST expression node + */ const pathOf = (node) => { if (!node || node.type !== 'MemberExpression') return null; const parts = []; diff --git a/tools/check-patched-updates.mjs b/tools/check-patched-updates.mjs index c8fcc16a..4625720a 100644 --- a/tools/check-patched-updates.mjs +++ b/tools/check-patched-updates.mjs @@ -67,6 +67,9 @@ export function unappliedPatches(vendor, yaml, installed) { * @return {number} Negative when a is older, 0 when equal, positive when newer. */ export function compareVersions(a, b) { + /** + * @param {string} v + */ const parts = (v) => String(v).replace(/^v/i, '').split(/[.-]/).map((p) => parseInt(p, 10) || 0); const pa = parts(a); const pb = parts(b); @@ -77,7 +80,11 @@ export function compareVersions(a, b) { return 0; } +/** + * @param {string} url + */ async function getJson(url) { + /** @type {Object} */ const headers = {'accept': 'application/json', 'user-agent': 'faststream-check-patched-updates'}; if (url.startsWith('https://api.github.com/') && process.env.GH_TOKEN) { headers.authorization = `Bearer ${process.env.GH_TOKEN}`; @@ -111,6 +118,11 @@ export function githubRepo(spec) { return {owner: github[1], repo: github[2]}; } +/** + * @param {string} name - The package name. + * @param {string} spec - Its specifier in package.json. + * @return {Promise<{latest: string, url: string}>} + */ async function latestRelease(name, spec) { // Both hosts are pinned; the name comes from this repo's package.json // (CodeQL js/request-forgery). diff --git a/tools/fetch-amo-signed.mjs b/tools/fetch-amo-signed.mjs index 1543d6ea..f1b60fd5 100644 --- a/tools/fetch-amo-signed.mjs +++ b/tools/fetch-amo-signed.mjs @@ -29,6 +29,8 @@ import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; +// @ts-ignore - web-ext/util/submit-addon resolves to untyped JavaScript with no published +// declarations (TS7016), so JwtApiAuth has no real type that can be written here by comment import {JwtApiAuth} from 'web-ext/util/submit-addon'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); diff --git a/types/implicit-any.json b/types/implicit-any.json index 7f6867cd..d21b0549 100644 --- a/types/implicit-any.json +++ b/types/implicit-any.json @@ -34,26 +34,19 @@ "chrome/player/players/mp4/MP4FragmentRequester.mjs": 13, "chrome/player/players/mp4/SegmentAppender.mjs": 2, "chrome/player/players/mp4/SourceBufferWrapper.mjs": 8, - "chrome/player/players/vm/VMPlayer.mjs": 7, "chrome/player/ui/FineTimeControls.mjs": 30, "chrome/player/ui/InterfaceController.mjs": 41, "chrome/player/ui/SaveManager.mjs": 1, "chrome/player/ui/SourcesBrowser.mjs": 16, "chrome/player/ui/ToolManager.mjs": 6, - "chrome/player/ui/VolumeControls.mjs": 8, - "chrome/player/ui/VpnPrompt.mjs": 6, - "chrome/player/ui/audio/AbstractAudioModule.mjs": 2, + "chrome/player/ui/audio/AbstractAudioModule.mjs": 1, "chrome/player/ui/menus/LoopMenu.mjs": 10, "chrome/player/ui/subtitles/SubtitlesManager.mjs": 21, "chrome/player/ui/subtitles/SubtitlesSettingsManager.mjs": 11, - "chrome/player/utils/BlobManager.mjs": 1, "chrome/player/utils/FastStreamArchiveUtils.mjs": 7, "chrome/player/utils/SubtitleFetch.mjs": 10, "chrome/player/utils/SubtitleUtils.mjs": 11, "chrome/player/utils/Utils.mjs": 41, - "native-host/faststream-mpv-host.mjs": 76, - "tools/ast-compare.mjs": 6, - "tools/check-patched-updates.mjs": 5, - "tools/fetch-amo-signed.mjs": 1, + "native-host/faststream-mpv-host.mjs": 1, "tools/sign-amo.mjs": 1 }