From 040acbd976408124ad58eab49124cfd8e7364bf2 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:46:32 +0200 Subject: [PATCH 01/32] feat(tools): land mixins from another Sass module where they are included (#1795) A mixin included from another Sass module (`@use 'x'; @include x.m`) now lands where it is included, as a same-file mixin already did: its top-level weights meet the including rule's family, and its family becomes that rule's, in the order Sass writes them out. Include sites resolve through the existing `@use`/`@forward` scope resolution, the definition Sass resolves is the one that runs, and an include inside a mixin body resolves where that mixin runs. The header's "Not traced" list keeps what stays out (positional arguments, content blocks placed by another module's mixin, a name two `@import`ed files define). Co-Authored-By: Claude Opus 5.5 --- docs/architecture/iptvnator-ui-guidelines.md | 6 +- tools/nx/check-font-weights.mjs | 529 +++++++++++++---- tools/nx/check-font-weights.test.mjs | 579 +++++++++++++++++++ tools/nx/font-weight-family.mjs | 273 +++++++-- tools/nx/font-weight-scope.mjs | 25 +- 5 files changed, 1270 insertions(+), 142 deletions(-) diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 7b57fb1d6..1e6ebff09 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -908,8 +908,10 @@ heavier faces would exceed the initial-bytes ratchet). - JetBrains Mono text stays at 500 or lighter, also where it is a fallback behind `ui-monospace` (only macOS resolves that). The check enforces this in any rule that sets the family, directly or through a variable, or inherits - it from an enclosing rule. It cannot see what a mono modifier class inherits - from its base rule; set `font-weight: 500` there. + it from an enclosing rule. A mixin's family and weights count where it is + included, from its own stylesheet module or another one. It cannot see what + a mono modifier class inherits from its base rule; set `font-weight: 500` + there. - Import whole `@fontsource//.css` files. The single-script files such as `cyrillic-600.css` have no `unicode-range`, so a Cyrillic-only face wins the weight match for Latin text in `Roboto, …` stacks and sends it diff --git a/tools/nx/check-font-weights.mjs b/tools/nx/check-font-weights.mjs index c112d133d..2f26d2b26 100644 --- a/tools/nx/check-font-weights.mjs +++ b/tools/nx/check-font-weights.mjs @@ -73,7 +73,9 @@ import { * the callables their named arguments go to are followed by name (`-` and `_` * alike), through `@forward … as prefix-*` and its `show`/`hide` lists too. * A parameter default counts where a call leaves it out, or when no call is - * in sight. A weight set from code is read per value it can take, so a + * in sight; against the JetBrains Mono cap, only a call that meets that + * family passes a weight (one in a mixin another module includes always + * may). A weight set from code is read per value it can take, so a * condition's numbers are not weights, a template literal as each text its * literal `${…}` parts produce (`` `65${0}` `` is 650; a weight it builds * around another value is computed), and CSS text that a string leaves to @@ -94,35 +96,45 @@ import { * A stylesheet rule set in JetBrains Mono (its own `font-family` or `font`, * written out or through variables, or one a nested rule inherits; see * `familiesOf`) is capped at `MONO_WEIGHT_CAP`. A mixin's top-level - * declarations land where this file includes it, in the order Sass writes - * them out, a content block's too where the mixin places `@content` at its - * top level, a rule this file `@extend`s whole applies to its extenders, and - * a keyframe's declarations (of its last definition) apply where a rule's - * last `animation` runs it, over the rule's own and under `!important` ones, - * while it runs and, unless it holds a frame (`forwards`, `both`, - * `infinite`), the rule's own after. An `:is()` or `:where()` reads as the - * selectors it holds (`:where(.p) .c` is `.p .c`). A rule also sets the - * family of the narrower selectors it reaches (`.x` for `.x:hover`, `.p .x` - * for `.w .p .x:hover`), compound by compound across what its combinators - * allow; of those, the element's own rule and `*`, the cascade winner counts - * (`!important`, layer, specificity, source order; a `@layer` always - * applies, and `revert-layer` falls back past its own). Without a family of - * its own, a rule takes one from an ancestor its compiled selector names (an - * `@at-root` rule's as Sass writes it out), else from the document root - * (`:host`, `body`, `html`, `:root`) in its file; a family applies under - * conditions (`@media`, `@supports`, `@if`) that the reader shares, or - * always. Sass conditions are not evaluated: each `@if`/`@else` branch - * counts as one that may run, in a rule, a mixin or a content block alike. + * declarations land where it is included, in the order Sass writes them + * out: in its own file, as the definition in scope when the include runs + * (a rule's declared before it, a mixin body's where that mixin is + * included), or in another module that includes its last definition by a + * name Sass resolves to it (`ns.m`, + * through `@forward` prefixes and `show`/`hide`, or a bare `m` that + * `@use … as *` or `@import` brings in; see `scanWorkspace`). There its weights meet the including rule's family, + * reported once at the mixin's own line, and its family becomes that + * rule's. A content block lands too where a mixin of the same file places + * `@content` at its top level, a rule this file `@extend`s whole applies to + * its extenders, and a keyframe's declarations (of its last definition) + * apply where a rule's last `animation` runs it, over the rule's own and + * under `!important` ones, while it runs and, unless it holds a frame + * (`forwards`, `both`, `infinite`), the rule's own after. An `:is()` or + * `:where()` reads as the selectors it holds (`:where(.p) .c` is `.p .c`). + * A rule also sets the family of the narrower selectors it reaches (`.x` + * for `.x:hover`, `.p .x` for `.w .p .x:hover`), compound by compound + * across what its combinators allow; of those, the element's own rule and + * `*`, the cascade winner counts (`!important`, layer, specificity, source + * order; a `@layer` always applies, and `revert-layer` falls back past its + * own). Without a family of its own, a rule takes one from an ancestor its + * compiled selector names (an `@at-root` rule's as Sass writes it out), + * else from the document root (`:host`, `body`, `html`, `:root`) in its + * file; a family applies under conditions (`@media`, `@supports`, `@if`) + * that the reader shares, or always. Sass conditions are not evaluated: each + * `@if`/`@else` branch counts as one that may run, in a rule, a mixin or a + * content block alike. * * Not traced: global styles in another file, a weight inherited from another - * rule, a mixin from another module, a mixin's nested rules and at-rules - * (and a `@content` placed in one), a family set on an element from code, - * and one that reaches only some of a rule's elements (a more specific - * `.x.active`, or `@extend .m` into `.m.active`). Animations are read as a - * whole: a keyframe's steps cascade as one rule rather than as states in - * turn, a rule's last `animation` runs whatever an earlier `!important` one - * sets, one layer holding a frame holds all of them, and a quoted name is - * read word by word. + * rule, a mixin's nested rules and at-rules (and a `@content` placed in one, + * or in another module's mixin), a mixin name that two `@import`ed files + * define (the later wins), a custom property a mixin's family reads + * (resolved where the mixin is written, not on the rule that includes it), + * a family set on an element from code, and one that reaches only some of a + * rule's elements (a more specific `.x.active`, or `@extend .m` into + * `.m.active`). Animations are read as a whole: a keyframe's steps cascade + * as one rule rather than as states in turn, a rule's last `animation` runs + * whatever an earlier `!important` one sets, one layer holding a frame + * holds all of them, and a quoted name is read word by word. */ export const WEIGHT_SCALE = Object.freeze([400, 500, 600, 700]); @@ -886,17 +898,41 @@ function callSitesOf(text, blocks) { return calls; } +/** + * An `@include` of a mixin: `ns.name` names another module's, as does a + * bare name that no mixin of the including file has. + */ +const INCLUDE = /@include\s+(?:([\w-]+)\.)?([\w-]+)(?![\w.-])/g; + /** * One file's weight declarations: off-scale findings, the custom properties * and Sass variables its weights refer to, and every such variable it defines * (checked later, once the whole workspace has named what it refers to). * A keyframe that sets a font only while it runs is read both ways: its * frames over the rule that runs it, and that rule after it. + * Its module mixins' top-level families and weights (`mixins`) land where + * other modules include them, and theirs here: `included` gives, by the + * position of each such `@include` (`includes`), the mixins it reaches, and + * `elsewhere` names this file's mixins that other modules include (see + * `scanWorkspace`). A mixin read both ways carries the second (`after`), so + * a module that includes it reads it both ways too. */ -export function scanWeights(file, written) { - const running = scanPass(file, written, true); - if (!running.transient) return running; - const after = scanPass(file, written, false); +export function scanWeights(file, written, modules = {}) { + const { included = new Map() } = modules; + const running = scanPass(file, written, true, modules); + const mixins = [...included.values()].flat(); + if (!running.transient && !mixins.some((mixin) => mixin.after)) { + return running; + } + const after = scanPass(file, written, false, { + ...modules, + included: new Map( + [...included].map(([site, reached]) => [ + site, + reached.map((mixin) => mixin.after ?? mixin), + ]) + ), + }); const keyOf = (item) => JSON.stringify(item); const union = (a, b) => [ ...new Map([...a, ...b].map((item) => [keyOf(item), item])).values(), @@ -905,10 +941,35 @@ export function scanWeights(file, written) { ...running, findings: union(running.findings, after.findings), deferred: union(running.deferred, after.deferred), + // A call meets the families of either reading. + includeCalls: new Map( + [...running.includeCalls].map(([index, call]) => { + const other = after.includeCalls.get(index); + return [ + index, + { + ...call, + mono: call.mono || Boolean(other?.mono), + families: union(call.families, other?.families ?? []), + }, + ]; + }) + ), + mixins: new Map( + [...running.mixins].map(([name, mixin]) => [ + name, + { ...mixin, after: after.mixins.get(name) }, + ]) + ), }; } -function scanPass(file, written, transient) { +function scanPass( + file, + written, + transient, + { included = new Map(), elsewhere = new Set() } +) { // Read as the browser reads it (markup references and CSS escapes // decoded); lines are the file's own. const { text: source, origin } = decodeSource(file, written); @@ -1067,12 +1128,52 @@ function scanPass(file, written, transient) { selectors: selectorsOf(place.scopes), }); const monoAt = stylesheet - ? familiesOf(lexed, blocks, { - ...{ inString, placeOf, refsIn, rulesOf, transient }, - }) + ? familiesOf( + lexed, + blocks, + { inString, placeOf, refsIn, rulesOf, transient }, + { + included: new Map( + [...included].map(([site, mixins]) => [ + site, + mixins.flatMap((mixin) => mixin.families), + ]) + ), + elsewhere, + } + ) : Object.assign(() => ({ mono: false, refs: [] }), { - landings: () => [], + ...{ landings: () => [], landingsAt: () => [] }, + ...{ memberOf: () => null, exported: new Map() }, + ...{ definitionsAt: () => ({ defs: [], open: true }) }, + callAt: () => null, }); + // Where a family declaration sits, for its variables to resolve later + // there; one from another module's mixin carries its own. + const siteOf = (entry) => + entry.at.file + ? entry.at + : { + ...{ ...entry.at, file }, + guards: guardsAt(entry.at.index), + rule: blockAt.get(entry.at.scope)?.prelude ?? null, + selectors: selectorsOf(entry.at.scopes ?? []), + }; + // A weight's terms and variables as a JetBrains Mono rule caps them, + // with where to report them. + const capped = (name, index, mode, value) => { + const analysis = analyse(mode, value, { cap: MONO_WEIGHT_CAP }); + const place = placeOf(blocks, index); + return { + ...{ file, line: lineOf(index), name }, + terms: analysis.terms.filter(capOnly), + references: analysis.references.map((reference) => ({ + ...{ ...reference, file, index }, + ...{ scopes: place.scopes, callable: place.callable }, + inCallable: place.inCallable, + })), + }; + }; // Weights in rules whose family is named through variables: capped once // that family resolves to JetBrains Mono. const deferred = []; @@ -1080,15 +1181,31 @@ function scanPass(file, written, transient) { // shorthand): a later one, unless only the earlier is `!important`, // replaces it, so only the one in effect meets the cap. const setters = new Map(); + // The weight declarations landing in this file's module mixins (one of + // another module's too), for the modules that include them, and how + // this file's own read where a JetBrains Mono rule caps them. + const setInMixins = []; + const weightAt = new Map(); // A weight declaration registers in each rule it lands in (a mixin's - // where it is included), at the place it lands. - const setAt = (index, important) => { - // A keyframe's weight, where a rule runs it, outranks the rule's own. - for (const { rules, key, animated } of monoAt.landings(index)) { + // where it is included), at the place it lands; `id` is its position, + // or for another module's, its file and position there. A keyframe's + // weight, where a rule runs it, outranks the rule's own (`animated`, on + // the way to a mixin another module includes, or here). + const setAt = (id, important, landings, weight = null, ran = false) => { + for (const landing of landings) { + const { rules, key, scope, animated: here } = landing; + const animated = ran || Boolean(here); for (const rule of rules) { if (!setters.has(rule)) setters.set(rule, []); const level = levelOf({ important, animated }); - setters.get(rule).push({ key, index, level }); + setters.get(rule).push({ key, index: id, level }); + } + const mixin = monoAt.memberOf(scope); + if (mixin !== null && landing.external !== false) { + setInMixins.push({ + ...{ mixin, key, id }, + ...{ important, animated, weight }, + }); } } }; @@ -1109,15 +1226,30 @@ function scanPass(file, written, transient) { if (match[1].toLowerCase() === 'weight' && namespace === undefined) { continue; } - setAt(match.index, IMPORTANT.test(value)); + setAt(match.index, IMPORTANT.test(value), monoAt.landings(match.index)); } // An `all` reset replaces an earlier weight too. for (const match of stylesheet ? text.matchAll(ALL_RESET) : []) { if (inString(match.index) || inConditionPrelude(lexed, match.index)) { continue; } - if (startsDeclaration(text, match.index)) - setAt(match.index, Boolean(match[2])); + if (startsDeclaration(text, match.index)) { + const landings = monoAt.landings(match.index); + setAt(match.index, Boolean(match[2]), landings); + } + } + // Another module's mixin sets its weights where this file includes it. + const landed = [...included].flatMap(([site, mixins]) => + mixins.flatMap((mixin) => + mixin.setters.map((setter) => ({ + ...{ site, setter }, + landings: monoAt.landingsAt(site, setter.key), + })) + ) + ); + for (const { setter, landings } of landed) { + const { id, important, weight, animated } = setter; + setAt(id, important, landings, weight, animated); } // A shorthand that fails to parse is dropped, so it sets nothing. @@ -1126,8 +1258,8 @@ function scanPass(file, written, transient) { // replaces it and nothing earlier outranks it (`!important`): the // scopes of those landings, the only ones whose family it meets. const after = (a, b) => keyOrder(a.key, b.key) > 0; - const effectiveIn = (index) => { - const landings = monoAt.landings(index).filter(({ rules, key }) => + const effectiveIn = (index, landings = monoAt.landings(index)) => { + const kept = landings.filter(({ rules, key }) => rules.some((id) => { const rule = setters.get(id) ?? []; const own = rule.find( @@ -1147,7 +1279,7 @@ function scanPass(file, written, transient) { ); }) ); - return new Set(landings.map(({ scope }) => scope)); + return new Set(kept.map(({ scope }) => scope)); }; // CSS text in a string (an inline `style="…"`, a component style) meets @@ -1247,26 +1379,45 @@ function scanPass(file, written, transient) { const cssOf = (text) => analyse(mode, text, { cap }); record(name, match.index, merged((texts ?? [css]).map(cssOf))); if (!family.mono && family.refs.length > 0) { - const capped = analyse(mode, value, { cap: MONO_WEIGHT_CAP }); - const place = placeOf(blocks, match.index); deferred.push({ - ...{ file, line: lineOf(match.index), name }, + ...capped(name, match.index, mode, value), family: family.text, shorthand: family.shorthand, - at: { - ...{ ...family.at, file }, - guards: guardsAt(family.at.index), - rule: blockAt.get(family.at.scope)?.prelude ?? null, - selectors: selectorsOf(family.at.scopes ?? []), - }, - terms: capped.terms.filter(capOnly), - references: capped.references.map((reference) => ({ - ...{ ...reference, file, index: match.index }, - ...{ scopes: place.scopes, callable: place.callable }, - inCallable: place.inCallable, - })), + at: siteOf(family), }); } + if (stylesheet && weightName) { + weightAt.set(match.index, () => + capped(name, match.index, mode, value) + ); + } + } + } + // Another module's weight landing here meets the family of the rule + // that includes it, and is reported where it is written. + for (const { site, setter, landings } of landed) { + const { id, weight } = setter; + if (!weight) continue; + const effective = effectiveIn(id, landings); + const family = + effective.size > 0 + ? monoAt(site, (scope) => effective.has(scope)) + : { mono: false, refs: [] }; + // Reported once, however many rules it lands in. + const terms = weight.terms.map((term) => ({ ...term, landed: true })); + if (family.mono) { + const { file: from, line, name } = weight; + findings.push( + ...terms.map((term) => ({ file: from, line, name, ...term })) + ); + references.push(...weight.references); + } else if (family.refs.length > 0) { + deferred.push({ + ...{ ...weight, terms }, + family: family.text, + shorthand: family.shorthand, + at: siteOf(family), + }); } } // A weight set from code is checked here; any other custom property it @@ -1517,12 +1668,70 @@ function scanPass(file, written, transient) { references: analyses.flatMap((analysis) => analysis.references), }); } + // This file's module mixins as a module that includes one sees them: + // the families and weights they set at their top level, each at its + // place in the mixin (`key`), a family with where to resolve it. + const mixins = new Map(); + for (const block of blocks.filter((b) => b.kind === 'callable')) { + const name = monoAt.memberOf(block.start); + if (name !== null && !mixins.has(name)) { + mixins.set(name, { families: [], setters: [] }); + } + } + const portable = new Map(); + const portableOf = (entry) => { + if (!portable.has(entry)) { + portable.set(entry, { ...entry, at: siteOf(entry) }); + } + return portable.get(entry); + }; + for (const [name, families] of monoAt.exported) { + mixins.get(name)?.families.push( + ...families.map(({ key, entry }) => ({ + key, + entry: portableOf(entry), + })) + ); + } + for (const { mixin, key, id, important, animated, weight } of setInMixins) { + const own = typeof id === 'number'; + mixins.get(mixin)?.setters.push({ + ...{ key, important, animated, id: own ? `${file}:${id}` : id }, + weight: own ? (weightAt.get(id)?.() ?? null) : weight, + }); + } + // Where it includes another module's mixin (see `INCLUDE`): a bare + // name runs one of this file's own where one is in scope there. + const includes = []; + for (const match of stylesheet ? text.matchAll(INCLUDE) : []) { + if (inString(match.index)) continue; + const namespace = match[1] ?? null; + const name = match[2].replace(/_/g, '-'); + const { defs, open } = monoAt.definitionsAt(name, match.index); + if (namespace === null && defs.length > 0 && !open) continue; + includes.push({ index: match.index, callee: { name, namespace } }); + } const loads = stylesheet ? extractStylesheetLoads(source) : []; const calls = callSitesOf(text, blocks); const invocations = stylesheet ? invocationsOf(lexed) : []; + // Each `@include` here, by position, with the families it meets (see + // `callAt` in `familiesOf`): a parameter capped for JetBrains Mono takes + // only the arguments of a call that meets one. + const includeCalls = new Map(); + for (const { index, paren } of invocations) { + if (!/^@include\b/.test(text.slice(index, index + 8))) continue; + const { mono, families } = monoAt.callAt(index); + includeCalls.set(index, { + ...{ paren, mono }, + families: families.map((entry) => ({ + ...{ text: entry.text, shorthand: entry.shorthand }, + at: siteOf(entry), + })), + }); + } return { ...{ file, loads, declarations, findings, references, definitions }, - ...{ calls, invocations, deferred }, + ...{ calls, invocations, deferred, mixins, includes, includeCalls }, transient: Boolean(monoAt.transient), }; } @@ -1539,7 +1748,8 @@ function scanPass(file, written, transient) { export function findIndirectWeights(scans) { const definitions = scans.flatMap((scan) => scan.definitions); const pending = scans.flatMap((scan) => scan.references); - const { qualified, unqualified, imports, loadsOf } = sassScopes(scans); + const { qualified, unqualified, imports, loadsOf, reaches } = + sassScopes(scans); const callsByFile = new Map(scans.map((scan) => [scan.file, scan.calls])); // Whether a declaration is what `name` reads, through one of the ways // `access` exposes its file (a `@forward` prefix, `show`/`hide`). @@ -1563,35 +1773,45 @@ export function findIndirectWeights(scans) { : exposedName(exposure, definition.key) === name) ) ); - // Whether an argument is passed to `callable`, defined in `file`: - // `ns.name(` must load `file` (or a module forwarding it) as `ns`, under - // the name it exposes `callable` by; a bare `name(` is defined in the - // caller itself or in what it brings in. - const passedTo = ({ callee, file: caller }, file, callable) => { - if (!callee || !callable) return false; - if (!callee.namespace && caller === file) { - return callee.name === callable; - } - const scope = callee.namespace - ? qualified(caller, callee.namespace) - : unqualified(caller); - const access = scope.get(file); - return ( - Boolean(access?.declarations) && - access.exposures.some( - (exposure) => exposedName(exposure, callable) === callee.name - ) - ); - }; + // Whether an argument is passed to `callable`, defined in `file` (see + // `reaches` in `sassScopes`). + const passedTo = reaches; // A parameter default is the value only at calls that leave it out. A // callable with no call in sight may be called from where the scan // cannot see, so its defaults count. const invocations = scans.flatMap(({ file, invocations: calls = [] }) => calls.map((call) => ({ ...call, file })) ); + // A parameter that a JetBrains Mono rule caps (`capped`) takes only the + // arguments, or default, of an `@include` that meets one (see + // `includeCalls` in `scanWeights`); any other call is read as before. + const includeCalls = new Map( + scans.map(({ file, includeCalls: calls = new Map() }) => [file, calls]) + ); + const monoCalls = new Map(); + const meetsMono = (call) => { + if (!call) return true; + if (!monoCalls.has(call)) { + monoCalls.set( + call, + call.mono || + call.families.some((f) => + familyIsMono(f.text, f.at, new Set(), f.shorthand) + ) + ); + } + return monoCalls.get(call); + }; + const includeAt = (file, paren) => + [...(includeCalls.get(file)?.values() ?? [])].find( + (call) => call.paren === paren + ); const defaultUsed = new Map(); - const usesDefault = (definition) => { - if (defaultUsed.has(definition)) return defaultUsed.get(definition); + const usesDefault = (definition, capped = false) => { + const id = `${capped}`; + if (!defaultUsed.has(definition)) defaultUsed.set(definition, {}); + const cached = defaultUsed.get(definition); + if (id in cached) return cached[id]; const callable = definition.callee.name; const calls = invocations.filter( (call) => @@ -1606,8 +1826,13 @@ export function findIndirectWeights(scans) { d.callee?.paren === call.paren && d.key === definition.key ); - const used = calls.length === 0 || calls.some((call) => !names(call)); - defaultUsed.set(definition, used); + const counted = capped + ? calls.filter((call) => + meetsMono(includeCalls.get(call.file)?.get(call.index)) + ) + : calls; + const used = calls.length === 0 || counted.some((call) => !names(call)); + cached[id] = used; return used; }; // A partial runs only where it is loaded, so its `!default` for a name @@ -1806,10 +2031,19 @@ export function findIndirectWeights(scans) { if (!visible) return false; } if (scope) { + const capped = Boolean(reference.cap); const passed = definition.key === name && passedTo(definition, file, reference.callable) && - (!definition.callee.signature || usesDefault(definition)); + (definition.callee.signature + ? usesDefault(definition, capped) + : !capped || + meetsMono( + includeAt( + definition.file, + definition.callee.paren + ) + )); const configured = configures(access, definition, name); // A textual importer's later code has not run when this // file's rules render, unless they sit in a mixin body. @@ -2131,6 +2365,108 @@ export function findIndirectWeights(scans) { return findings; } +/** + * Every file's scan (`sources` holds `{ file, source }`), with the mixins + * it includes from other modules landed where it includes them: an + * `@include ns.name`, or a bare name that `@use … as *` or `@import` brings + * in, resolved as Sass resolves the call (see `sassScopes`). A file that + * includes one, or whose mixin another includes, is scanned again with + * them, after the modules it includes (Sass rejects a loop of `@use`s; a + * file met again on one keeps its first scan). + */ +export function scanWorkspace(sources) { + const sourceOf = new Map(sources.map(({ file, source }) => [file, source])); + const first = new Map( + sources.map(({ file, source }) => [file, scanWeights(file, source)]) + ); + const { reaches } = sassScopes([...first.values()]); + const providers = [...first.values()].filter( + (scan) => scan.mixins?.size > 0 + ); + // Each file's includes of other modules' mixins, by `@include`, with + // the mixins each reaches; and each file's mixins others include. + const reached = new Map(); + const elsewhere = new Map(); + for (const scan of first.values()) { + for (const { index, callee } of scan.includes ?? []) { + const call = { callee, file: scan.file }; + // A file's own definitions land through `familiesOf`. + const found = providers + .filter(({ file }) => file !== scan.file) + .flatMap(({ file, mixins }) => + [...mixins.keys()] + .filter((name) => reaches(call, file, name)) + .map((name) => ({ file, name })) + ); + // Only `@import`s can bring in two mixins of one name (anything + // else is a Sass error), and the later one wins; the scan does + // not order them, so it leaves such an include out. + if (found.length !== 1) continue; + if (!reached.has(scan.file)) reached.set(scan.file, new Map()); + reached.get(scan.file).set(index, found); + for (const { file, name } of found) { + if (!elsewhere.has(file)) elsewhere.set(file, new Set()); + elsewhere.get(file).add(name); + } + } + } + const done = new Map(); + const scanning = new Set(); + const scanOf = (file) => { + if (done.has(file)) return done.get(file); + const sites = reached.get(file) ?? new Map(); + const involved = sites.size > 0 || elsewhere.has(file); + if (!involved || scanning.has(file)) return first.get(file); + scanning.add(file); + const included = new Map( + [...sites].map(([index, found]) => [ + index, + found.map(({ file: from, name }) => + scanOf(from).mixins.get(name) + ), + ]) + ); + const scan = scanWeights(file, sourceOf.get(file), { + included, + elsewhere: elsewhere.get(file), + }); + scanning.delete(file); + done.set(file, scan); + return scan; + }; + return sources.map(({ file }) => scanOf(file)); +} + +/** + * Every finding in the workspace (see `scanWorkspace`), and how many weight + * declarations it checked. A mixin's weight landing in several JetBrains + * Mono rules, in other modules or its own, is reported once. + */ +export function findWorkspaceWeights(sources) { + const scans = scanWorkspace(sources); + const all = [ + ...scans.flatMap((scan) => scan.findings), + ...findIndirectWeights(scans), + ]; + const keyOf = ({ file, line, name, value, cap, computed }) => + JSON.stringify([file, line, name, value, cap, computed]); + const seen = new Set(all.filter((f) => !f.landed).map(keyOf)); + const findings = []; + for (const { landed, ...finding } of all) { + if (landed) { + const key = keyOf(finding); + if (seen.has(key)) continue; + seen.add(key); + } + findings.push(finding); + } + const declarations = scans.reduce( + (sum, scan) => sum + scan.declarations, + 0 + ); + return { declarations, findings }; +} + /** Every off-scale weight one file can reach on its own. */ export function findOffScaleWeights(file, source) { const scan = scanWeights(file, source); @@ -2183,23 +2519,16 @@ if (isMain) { .filter(Boolean) .filter(isScannedFile); - const scans = []; + const sources = []; for (const file of files) { const source = await readFile(path.resolve(rootDir, file), 'utf8'); - scans.push(scanWeights(file, source)); + sources.push({ file, source }); } - const findings = [ - ...scans.flatMap((scan) => scan.findings), - ...findIndirectWeights(scans), - ]; + const { declarations, findings } = findWorkspaceWeights(sources); const diagnostics = [ ...validateScanCoverage(files), ...findings.map(describeFinding), ]; - const declarations = scans.reduce( - (sum, scan) => sum + scan.declarations, - 0 - ); if (diagnostics.length > 0) { console.error('Font weight scale check failed:'); diff --git a/tools/nx/check-font-weights.test.mjs b/tools/nx/check-font-weights.test.mjs index 84b4fd1fc..79eecf32b 100644 --- a/tools/nx/check-font-weights.test.mjs +++ b/tools/nx/check-font-weights.test.mjs @@ -7,6 +7,7 @@ import { describeFinding, findIndirectWeights, findOffScaleWeights, + findWorkspaceWeights, isScannedFile, nearestScaleWeight, scanWeights, @@ -19,6 +20,14 @@ const offScale = (file, source) => ({ line, name, value }) => `${line} ${name}: ${value}` ); +/** The findings across `files` (`{ path: source }`), scanned together. */ +const workspace = (files) => + findWorkspaceWeights( + Object.entries(files).map(([file, source]) => ({ file, source })) + ).findings.map( + ({ file, line, name, value }) => `${file}:${line} ${name}: ${value}` + ); + test('flags an off-scale font-weight with its line number', () => { const source = ['.title {', ' font-weight: 650;', '}'].join('\n'); @@ -3962,6 +3971,576 @@ test('reads mixin weights where they land, through nested includes', () => { } }); +test("lands another module's mixin weights where they are included", () => { + const mono = "'JetBrains Mono'"; + const type = [ + '@mixin heavy {', + ' font-weight: 700;', + '}', + '@mixin light { font-weight: 400; }', + '@mixin firm { font-weight: 700 !important; }', + '@mixin settled { font-weight: 700; font-weight: 500; }', + ].join('\n'); + const report = (rules) => + workspace({ + 'libs/w1/_type.scss': type, + 'libs/w1/c.scss': `@use 'type';\n${rules}`, + }); + const heavy = ['libs/w1/_type.scss:2 font-weight: 700']; + for (const [rules, expected] of [ + [`.x { font-family: ${mono}; @include type.heavy; }`, heavy], + [`.x { font-family: Roboto; @include type.heavy; }`, []], + // At the `@include`, in Sass's output order with the rule's own. + [ + `.x { font-family: ${mono}; @include type.heavy; font-weight: 500; }`, + [], + ], + [ + `.x { font-family: ${mono}; font-weight: 500; @include type.heavy; }`, + heavy, + ], + [`.x { @include type.heavy; font-family: ${mono}; }`, heavy], + [ + `.x { font-family: ${mono}; @include type.heavy; @include type.light; }`, + [], + ], + [ + `.x { font-family: ${mono}; @include type.firm; font-weight: 500; }`, + ['libs/w1/_type.scss:5 font-weight: 700'], + ], + // And in its order within the mixin. + [`.x { font-family: ${mono}; @include type.settled; }`, []], + // A nested rule inherits the family; one named through a variable + // is resolved once the workspace is scanned. + [`.x { font-family: ${mono}; .y { @include type.heavy; } }`, heavy], + [ + `:root { --face: ${mono}; } .x { font-family: var(--face); @include type.heavy; }`, + heavy, + ], + ]) { + assert.deepEqual(report(rules), expected, rules); + } +}); + +test("reports another module's mixin weight once, at its own line", () => { + const mono = "'JetBrains Mono'"; + const users = { + 'libs/w2/a.scss': `@use 'type'; .a { font-family: ${mono}; @include type.heavy; }`, + 'libs/w2/b.scss': [ + "@use 'type';", + `:root { --face: ${mono}; }`, + '.b { font-family: var(--face); @include type.heavy; }', + ].join('\n'), + }; + const heavy = ['libs/w2/_type.scss:1 font-weight: 700']; + assert.deepEqual( + workspace({ + 'libs/w2/_type.scss': '@mixin heavy { font-weight: 700; }', + ...users, + }), + heavy + ); + // Its own module includes it in a JetBrains Mono rule too. + assert.deepEqual( + workspace({ + 'libs/w2/_type.scss': `@mixin heavy { font-weight: 700; } .own { font-family: ${mono}; @include heavy; }`, + ...users, + }), + heavy + ); +}); + +test("gives a rule the family another module's mixin sets there", () => { + const mono = "'JetBrains Mono'"; + const report = (rules, face = `${mono}, monospace`) => + workspace({ + 'libs/w3/_mono.scss': `$face: ${mono}, monospace;\n@mixin face {\n font-family: ${face};\n}`, + 'libs/w3/_other.scss': '@mixin face { font-family: Roboto; }', + 'libs/w3/c.scss': `@use 'mono';\n@use 'other';\n${rules}`, + }); + const heavy = ['libs/w3/c.scss:3 font-weight: 700']; + for (const [rules, expected, face] of [ + [`.x { @include mono.face; font-weight: 700; }`, heavy], + [ + `.x { @include mono.face; font-family: Roboto; font-weight: 700; }`, + [], + ], + [ + `.x { font-family: Roboto; @include mono.face; font-weight: 700; }`, + heavy, + ], + [`.x { @include mono.face; .y { font-weight: 700; } }`, heavy], + [ + `.x { @include mono.face; &:hover { font-weight: 600; } }`, + ['libs/w3/c.scss:3 font-weight: 600'], + ], + // Named through its own module's variable, resolved there. + [`.x { @include mono.face; font-weight: 700; }`, heavy, '$face'], + // A namesake in another module is not the mixin included. + [`.x { @include other.face; font-weight: 700; }`, []], + ]) { + assert.deepEqual(report(rules, face), expected, rules); + } +}); + +test('reads a mixin another module includes where it lands', () => { + const mono = "'JetBrains Mono'"; + const report = (rules) => + workspace({ + 'libs/w4/_badge.scss': `@mixin badge {\n font-family: ${mono};\n font-weight: 700;\n}`, + 'libs/w4/c.scss': `@use 'badge';\n${rules}`, + }); + const heavy = ['libs/w4/_badge.scss:3 font-weight: 700']; + assert.deepEqual( + report('.x { @include badge.badge; font-family: Roboto; }'), + [] + ); + assert.deepEqual(report('.x { @include badge.badge; }'), heavy); + // Included nowhere, it is read where it is written. + assert.deepEqual(report('.x { color: red; }'), heavy); +}); + +test("resolves another module's mixin as Sass does", () => { + const mono = "'JetBrains Mono'"; + // A bare name a mixin of the file has is its own. + assert.deepEqual( + scanWeights( + 'libs/w5/a.scss', + '@mixin m_a { color: red; } .x { @include m-a; @include n_b; @include t.c_d; }' + ).includes.map(({ callee }) => callee), + [ + { name: 'n-b', namespace: null }, + { name: 'c-d', namespace: 't' }, + ] + ); + const type = { 'libs/w5/_type.scss': '@mixin heavy { font-weight: 700; }' }; + const heavy = ['libs/w5/_type.scss:1 font-weight: 700']; + // Through a `@forward` prefix, and as a bare name `as *` or `@import` + // brings in. + assert.deepEqual( + workspace({ + ...type, + 'libs/w5/_theme.scss': "@forward 'type' as type-*;", + 'libs/w5/c.scss': `@use 'theme'; .x { font-family: ${mono}; @include theme.type-heavy; }`, + }), + heavy + ); + for (const load of ["@use 'type' as *;", "@import 'type';"]) { + assert.deepEqual( + workspace({ + ...type, + 'libs/w5/c.scss': `${load} .x { font-family: ${mono}; @include heavy; }`, + }), + heavy, + load + ); + } + // A mixin that `hide` leaves out is not the one included. + assert.deepEqual( + workspace({ + ...type, + 'libs/w5/_quiet.scss': '@mixin heavy { font-weight: 400; }', + 'libs/w5/_theme.scss': + "@forward 'type' hide heavy;\n@forward 'quiet';", + 'libs/w5/c.scss': `@use 'theme'; .x { font-family: ${mono}; @include theme.heavy; }`, + }), + [] + ); + // Nor one declared in a rule, which is local to it. + assert.deepEqual( + workspace({ + 'libs/w5/_type.scss': + '@mixin heavy { font-weight: 400; } .p { @mixin heavy { font-weight: 700; } @include heavy; }', + 'libs/w5/c.scss': `@use 'type'; .x { font-family: ${mono}; @include type.heavy; }`, + }), + [] + ); + // Two `@import`ed files that define it: Sass includes the later one, + // which the scan does not order, so neither lands. + assert.deepEqual( + workspace({ + 'libs/w5/_a.scss': '\n\n@mixin heavy { font-weight: 700; }', + 'libs/w5/_b.scss': '@mixin heavy { font-weight: 400; }', + 'libs/w5/c.scss': `@import 'a'; @import 'b'; .x { font-family: ${mono}; @include heavy; }`, + }), + [] + ); +}); + +test("follows another module's mixin through the mixins it includes", () => { + const mono = "'JetBrains Mono'"; + for (const [files, expected] of [ + // Its family, from a mixin it includes from a third module or its + // own. + [ + { + 'libs/w6/_inner.scss': `@mixin face { font-family: ${mono}; }`, + 'libs/w6/_outer.scss': + "@use 'inner'; @mixin title { @include inner.face; }", + 'libs/w6/c.scss': + "@use 'outer'; .x { @include outer.title; font-weight: 700; }", + }, + ['libs/w6/c.scss:1 font-weight: 700'], + ], + [ + { + 'libs/w6/_outer.scss': `@mixin face { font-family: ${mono}; } @mixin title { @include face; }`, + 'libs/w6/c.scss': + "@use 'outer'; .x { @include outer.title; font-weight: 700; }", + }, + ['libs/w6/c.scss:1 font-weight: 700'], + ], + // In the order Sass writes them out, wherever each is declared. + [ + { + 'libs/w6/_outer.scss': `@mixin title { @include face; font-family: ${mono}; } @mixin face { font-family: Roboto; }`, + 'libs/w6/c.scss': + "@use 'outer'; .x { @include outer.title; font-weight: 700; }", + }, + ['libs/w6/c.scss:1 font-weight: 700'], + ], + // Its weight, from a third module's mixin. + [ + { + 'libs/w6/_inner.scss': '@mixin heavy { font-weight: 700; }', + 'libs/w6/_outer.scss': + "@use 'inner'; @mixin title { @include inner.heavy; }", + 'libs/w6/c.scss': `@use 'outer'; .x { font-family: ${mono}; @include outer.title; }`, + }, + ['libs/w6/_inner.scss:1 font-weight: 700'], + ], + ]) { + assert.deepEqual(workspace(files), expected, JSON.stringify(files)); + } +}); + +test("reads another module's mixin while its animation runs and after", () => { + const mono = "'JetBrains Mono'"; + const anim = { + 'libs/w8/_anim.scss': [ + `@keyframes face { from { font-family: ${mono}; } }`, + '@keyframes swap { from { font-family: Roboto; } }', + '@keyframes heavy { from { font-weight: 700; } }', + '@mixin mono-hold { animation: face 1s forwards; }', + '@mixin roboto-while { animation: swap 1s; }', + '@mixin heavy { animation: heavy 1s; }', + `@mixin mono { font-family: ${mono}; }`, + ].join('\n'), + 'libs/w8/_outer.scss': + "@use 'anim'; @mixin title { @include anim.roboto-while; }", + }; + const report = (rules) => + workspace({ ...anim, 'libs/w8/c.scss': `@use 'anim';\n${rules}` }); + const own = ['libs/w8/c.scss:2 font-weight: 700']; + for (const [rules, expected] of [ + // A frame it holds sets the family over the rule's own. + [ + `.x { font-family: Roboto; @include anim.mono-hold; font-weight: 700; }`, + own, + ], + // One it only runs leaves the rule its own family after, also + // through a third module's mixin. + [ + `.x { font-family: ${mono}; @include anim.roboto-while; font-weight: 700; }`, + own, + ], + [ + `.x { font-family: Roboto; @include anim.roboto-while; font-weight: 700; }`, + [], + ], + // Its family in a keyframe here outranks the rule's own too. + [ + `@keyframes k { from { @include anim.mono; } } .x { animation: k 1s forwards; font-family: Roboto; font-weight: 700; }`, + own, + ], + [ + `@use 'outer'; .x { font-family: ${mono}; @include outer.title; font-weight: 700; }`, + own, + ], + // A keyframe's weight outranks the rule's own while it runs, a + // later one too. + [ + `.x { font-family: ${mono}; font-weight: 500; @include anim.heavy; }`, + ['libs/w8/_anim.scss:3 font-weight: 700'], + ], + [ + `.x { font-family: ${mono}; @include anim.heavy; font-weight: 500; }`, + ['libs/w8/_anim.scss:3 font-weight: 700'], + ], + ]) { + assert.deepEqual(report(rules), expected, rules); + } +}); + +test("caps a named argument another module's mixin sets a weight from", () => { + const mono = "'JetBrains Mono'"; + const report = (mixin, rules) => + workspace({ + 'libs/w7/_type.scss': mixin, + 'libs/w7/c.scss': `@use 'type';\n${rules}`, + }); + assert.deepEqual( + report( + '@mixin heavy($w: 400) { font-weight: $w; }', + `.x {\n font-family: ${mono};\n @include type.heavy($w: 700);\n}` + ), + ['libs/w7/c.scss:4 $w: 700'] + ); + // Its default, where the call leaves it out. + assert.deepEqual( + report( + '@mixin heavy($w: 700) { font-weight: $w; }', + `.x { font-family: ${mono}; @include type.heavy; }` + ), + ['libs/w7/_type.scss:1 $w: 700'] + ); +}); + +test('runs the definition of a mixin Sass resolves at each include', () => { + const mono = "'JetBrains Mono'"; + const heavy = { + 'libs/w9/_type.scss': '@mixin heavy { font-weight: 700; }', + }; + const imported = ['libs/w9/_type.scss:1 font-weight: 700']; + for (const [files, expected] of [ + // One declared in a rule is visible there, once declared; elsewhere + // the name runs the one brought in. + [ + { + ...heavy, + 'libs/w9/c.scss': `@use 'type' as *;\n.p { @mixin heavy { font-weight: 400; } @include heavy; }\n.x { font-family: ${mono}; @include heavy; }`, + }, + imported, + ], + [ + { + ...heavy, + 'libs/w9/c.scss': `@use 'type' as *; .p { font-family: ${mono}; @mixin heavy { font-weight: 400; } @include heavy; }`, + }, + [], + ], + [ + { + ...heavy, + 'libs/w9/c.scss': `@use 'type' as *; .p { font-family: ${mono}; @include heavy; @mixin heavy { font-weight: 400; } }`, + }, + imported, + ], + // A rule runs the definition declared before it. + [ + { + 'libs/w9/c.scss': `@mixin m { font-weight: 700; } .x { font-family: ${mono}; @include m; } @mixin m { font-weight: 400; }`, + }, + ['libs/w9/c.scss:1 font-weight: 700'], + ], + [ + { + 'libs/w9/c.scss': `@mixin m { font-weight: 700; } @mixin m { font-weight: 400; } .x { font-family: ${mono}; @include m; }`, + }, + [], + ], + // A mixin body runs the definition in scope where it is included. + [ + { + 'libs/w9/c.scss': `@mixin m { font-weight: 700 !important; }\n@mixin m { font-weight: 400; }\n@mixin outer { @include m; }\n.x { font-family: ${mono}; @include outer; }`, + }, + [], + ], + [ + { + 'libs/w9/c.scss': `@mixin m { font-weight: 700; }\n@mixin outer { @include m; }\n.x { font-family: ${mono}; @include outer; }\n@mixin m { font-weight: 400; }\n.y { font-family: Roboto; @include outer; }`, + }, + ['libs/w9/c.scss:1 font-weight: 700'], + ], + [ + { + ...heavy, + 'libs/w9/c.scss': `@use 'type' as *;\n@mixin outer { @include heavy; }\n.x { font-family: ${mono}; @include outer; }\n@mixin heavy { font-weight: 400; }`, + }, + imported, + ], + [ + { + ...heavy, + 'libs/w9/c.scss': `@use 'type' as *;\n@mixin outer { @include heavy; }\n@mixin heavy { font-weight: 400; }\n.x { font-family: ${mono}; @include outer; }`, + }, + [], + ], + // Where it is included both before and after a local definition, + // each include runs its own. + [ + { + ...heavy, + 'libs/w9/c.scss': `@use 'type' as *;\n@mixin outer { @include heavy; }\n.x { font-family: ${mono}; @include outer; }\n@mixin heavy { font-weight: 400; }\n.y { font-family: Roboto; @include outer; }`, + }, + imported, + ], + [ + { + 'libs/w9/_firm.scss': + '@mixin heavy { font-weight: 700 !important; }', + 'libs/w9/c.scss': `@use 'firm' as *;\n@mixin outer { @include heavy; }\n.x { font-family: Roboto; @include outer; }\n@mixin heavy { font-weight: 400; }\n.y { font-family: ${mono}; @include outer; }`, + }, + [], + ], + [ + { + 'libs/w9/c.scss': `@mixin w { @content; font-weight: 400; }\n@mixin outer { @include w { font-weight: 700; } }\n.x { font-family: ${mono}; @include outer; }\n@mixin w { font-weight: 400; @content; }\n.y { font-family: Roboto; @include outer; }`, + }, + [], + ], + // A call in a definition that does not run passes nothing. + [ + { + 'libs/w9/c.scss': `@mixin w($weight) { font-weight: $weight; }\n@mixin m { @include w($weight: 700); }\n@mixin outer { @include m; }\n.x { font-family: Roboto; @include outer; }\n@mixin m { @include w($weight: 400); }\n.y { font-family: ${mono}; @include outer; }`, + }, + [], + ], + // Included from another module, once its own module has run. + [ + { + ...heavy, + 'libs/w9/_c.scss': + "@use 'type' as *;\n@mixin outer { @include heavy; }\n@mixin heavy { font-weight: 400; }", + 'libs/w9/d.scss': `@use 'c'; .x { font-family: ${mono}; @include c.outer; }`, + }, + [], + ], + [ + { + ...heavy, + 'libs/w9/_c.scss': + "@use 'type' as *;\n@mixin outer { @include heavy; }", + 'libs/w9/d.scss': `@use 'c'; .x { font-family: ${mono}; @include c.outer; }`, + }, + imported, + ], + // A content block goes where that definition places `@content`. + [ + { + 'libs/w9/c.scss': `@mixin w { @content; font-weight: 500; } .x { font-family: ${mono}; @include w { font-weight: 700; } } @mixin w { font-weight: 500; @content; }`, + }, + [], + ], + // Another module sees the last definition only. + [ + { + 'libs/w9/_t.scss': `@mixin m { font-family: ${mono}; }\n@mixin m { font-weight: 700; }`, + 'libs/w9/c.scss': "@use 't'; .x { @include t.m; }", + }, + [], + ], + [ + { + 'libs/w9/_t.scss': + '@mixin m { font-weight: 700; }\n@mixin m { font-weight: 400; }', + 'libs/w9/c.scss': `@use 't'; .x { font-family: ${mono}; @include t.m; }`, + }, + [], + ], + [ + { + 'libs/w9/_t.scss': + '@mixin m { font-weight: 400; }\n@mixin m { font-weight: 700; }', + 'libs/w9/c.scss': `@use 't'; .x { font-family: ${mono}; @include t.m; }`, + }, + ['libs/w9/_t.scss:2 font-weight: 700'], + ], + [ + { + 'libs/w9/_p.scss': + '@mixin m { font-weight: 700 !important; }\n@mixin outer { @include m; }\n.p { @include outer; }\n@mixin m { font-weight: 400; }', + 'libs/w9/d.scss': `@use 'p'; .x { font-family: ${mono}; @include p.outer; }`, + }, + [], + ], + ]) { + assert.deepEqual(workspace(files), expected, JSON.stringify(files)); + } +}); + +test('caps a parameter only with what calls in JetBrains Mono rules pass', () => { + const mono = "'JetBrains Mono'"; + const w = (fallback) => ({ + 'libs/w10/_m.scss': `@mixin w($weight: ${fallback}) { font-weight: $weight; }`, + }); + const hops = { + 'libs/w10/_p.scss': + '@mixin inner($weight) { font-weight: $weight; }\n@mixin outer($w: 400) { @include inner($weight: $w); }', + }; + for (const [files, expected] of [ + // A call in a Roboto rule passes its own weight. + [ + { + ...w(400), + 'libs/w10/c.scss': `@use 'm';\n.a { font-family: ${mono}; @include m.w($weight: 400); }\n.b { font-family: Roboto; @include m.w($weight: 700); }`, + }, + [], + ], + [ + { + 'libs/w10/c.scss': `@mixin w($weight: 400) { font-weight: $weight; }\n.a { font-family: ${mono}; @include w($weight: 400); }\n.b { font-family: Roboto; @include w($weight: 700); }`, + }, + [], + ], + // Its default counts where a JetBrains Mono call leaves it out. + [ + { + ...w(700), + 'libs/w10/c.scss': `@use 'm';\n.a { font-family: ${mono}; @include m.w($weight: 400); }\n.b { font-family: Roboto; @include m.w; }`, + }, + [], + ], + [ + { + ...w(700), + 'libs/w10/c.scss': `@use 'm';\n.a { font-family: ${mono}; @include m.w; }\n.b { font-family: Roboto; @include m.w($weight: 400); }`, + }, + ['libs/w10/_m.scss:1 $weight: 700'], + ], + // Through a mixin that passes its own parameter on. + [ + { + ...hops, + 'libs/w10/c.scss': `@use 'p';\n.a { font-family: ${mono}; @include p.outer($w: 400); }\n.b { font-family: Roboto; @include p.outer($w: 700); }`, + }, + [], + ], + [ + { + ...hops, + 'libs/w10/c.scss': `@use 'p';\n.a { font-family: ${mono}; @include p.outer($w: 700); }\n.b { font-family: Roboto; @include p.outer($w: 400); }`, + }, + ['libs/w10/c.scss:2 $w: 700'], + ], + // A call's family named through a variable, resolved. + [ + { + ...w(400), + 'libs/w10/c.scss': `@use 'm';\n:root { --f: ${mono}; }\n.a { font-family: var(--f); @include m.w($weight: 700); }`, + }, + ['libs/w10/c.scss:3 $weight: 700'], + ], + [ + { + ...w(400), + 'libs/w10/c.scss': `@use 'm';\n:root { --f: Roboto; }\n.a { font-family: var(--f); @include m.w($weight: 700); }\n.b { font-family: ${mono}; @include m.w($weight: 400); }`, + }, + [], + ], + // Or once a keyframe that sets another family for a while ends. + [ + { + ...w(400), + 'libs/w10/c.scss': `@use 'm';\n:root { --f: ${mono}; }\n@keyframes swap { from { font-family: Roboto; } }\n.a { font-family: var(--f); animation: swap 1s; @include m.w($weight: 700); }`, + }, + ['libs/w10/c.scss:4 $weight: 700'], + ], + ]) { + assert.deepEqual(workspace(files), expected, JSON.stringify(files)); + } +}); + test("inherits the document root's family", () => { const mono = "'JetBrains Mono'"; const media = '@media (min-width: 1px)'; diff --git a/tools/nx/font-weight-family.mjs b/tools/nx/font-weight-family.mjs index 2732c64dd..a369a9a96 100644 --- a/tools/nx/font-weight-family.mjs +++ b/tools/nx/font-weight-family.mjs @@ -33,6 +33,8 @@ export function fontNamespaceRule(blocks, { scope, scopes }) { /** At-rules whose body styles the enclosing rule's own element. */ const SAME_ELEMENT = /^@(?:media|supports|container|layer|include)\b/i; const NONE = Object.freeze({ mono: false, refs: [] }); +/** A family met in another module, which the scan of this one cannot read. */ +const OUTSIDE = Object.freeze({ mono: false, refs: [] }); /** * A declaration's whole value, to its `;`: a family list is comma-separated. @@ -915,13 +917,116 @@ export function keyOrder(a, b) { * `monoAt(index)`: the family in effect for a declaration there, from the * innermost rule that sets one and that the declaration's rule inherits * from. `@font-face` describes a face, so nothing in it is capped. + * + * Another module's mixin lands too: `included` gives, by the position of + * each `@include` of one, the families its top-level declarations set + * (`{ key, entry }`, `key` its place in that mixin), and `elsewhere` names + * this file's module mixins that another module includes, which style + * nothing where they are written (see `scanWorkspace`). */ export function familiesOf( lexed, blocks, - { inString, placeOf, refsIn, rulesOf, transient = true } + { inString, placeOf, refsIn, rulesOf, transient = true }, + { included = new Map(), elsewhere = new Set() } = {} ) { const family = new Map(); + // This file's mixins, in source order, with the scope each is declared + // in (`null` for the module; one declared in a rule is local to it). + const mixins = blocks.filter( + (b) => b.kind === 'callable' && /^@mixin\b/i.test(b.prelude) + ); + const declaredIn = new Map( + mixins.map((b) => [b.start, placeOf(blocks, b.start).scope]) + ); + const named = (name, scope) => + mixins.filter( + (b) => b.name === name && declaredIn.get(b.start) === scope + ); + // The outermost callable body between `scope` and `site`, if any: a + // site in one runs where that callable is included, not where it is. + const deferredBy = (scope, site) => + blocks.find( + (b) => + b.kind === 'callable' && + b.start < site && + site < b.end && + (scope === null || b.start > scope) + ) ?? null; + // When a site runs, as places in the module's own run: itself outside + // any mixin body, else where each `@include` of the mixin it sits in + // runs, and the module's end (`Infinity`) where another module includes + // it or nothing here does. + const runsAt = (site, seen = new Set()) => { + const callable = deferredBy(null, site); + if (!callable) return [site]; + if (seen.has(callable.start)) return []; + const scope = declaredIn.get(callable.start); + if (scope === undefined) return [Infinity]; + const next = new Set([...seen, callable.start]); + const sites = (includes.get(callable.name) ?? []).filter((t) => + placeOf(blocks, t).scopes.includes(scope) + ); + const outside = + elsewhere.has(memberOf(callable.start)) || + (sites.length === 0 && scope === null); + return [ + ...sites.flatMap((t) => runsAt(t, next)), + ...(outside ? [Infinity] : []), + ]; + }; + // The definition of `name` an `@include` at `site` runs when the module + // has run to `point` (see `runsAt`), as Sass resolves it: in the + // innermost scope around the site that has declared one by then, the + // last; in a mixin body declared in a rule or another mixin, any. + const resolveAt = (name, site, point) => { + for (const scope of placeOf(blocks, site).scopes) { + const declared = named(name, scope); + if (declared.length === 0) continue; + const deferred = deferredBy(scope, site) !== null; + if (deferred && scope !== null) return declared; + const by = deferred ? point : site; + const ran = declared.filter((b) => b.start < by).slice(-1); + if (ran.length > 0) return ran; + } + return []; + }; + // The definitions an `@include` of `name` at `site` can run, wherever + // it runs (`defs`), and whether somewhere none of this file's is in + // scope (`open`), so one another module brings in runs. + const definitions = new Map(); + const definitionsAt = (name, site) => { + const id = `${name} ${site}`; + if (!definitions.has(id)) { + const each = runsAt(site).map((point) => + resolveAt(name, site, point) + ); + definitions.set(id, { + defs: [...new Set(each.flat())], + open: each.some((found) => found.length === 0), + }); + } + return definitions.get(id); + }; + // Whether the definitions a landing went through (`{ name, site, def }`, + // `def` `null` for another module's) are the ones that run when the + // module has run to `point`. + const ranAt = (checks, point) => + checks.every(({ name, site, def }) => { + const found = resolveAt(name, site, point); + return def === null ? found.length === 0 : found.includes(def); + }); + // A module mixin's name, for its last module-level definition: the one + // another module includes (one declared in a rule never is). + const memberOf = (scope) => { + const block = mixins.find((b) => b.start === scope); + if (!block) return null; + const last = mixins + .filter((b) => b.name === block.name) + .filter((b) => declaredIn.get(b.start) === null) + .at(-1); + return last === block ? block.name : null; + }; // This file's `@include` sites, by mixin: a declaration in a mixin's // body lands in the rule that includes it, at the `@include`. const includes = new Map(); @@ -931,12 +1036,14 @@ export function familiesOf( if (!includes.has(name)) includes.set(name, []); includes.get(name).push(match.index); } + // Only a mixin is included (a function's body sets nothing), where the + // name runs this definition of it. const sitesOf = (scope) => { - const block = blocks.find((b) => b.start === scope); - // Only a mixin is included (a function's body sets nothing). - return block?.kind === 'callable' - ? (includes.get(block.name) ?? []) - : []; + const block = mixins.find((b) => b.start === scope); + if (!block) return []; + return (includes.get(block.name) ?? []).filter((site) => + definitionsAt(block.name, site).defs.includes(block) + ); }; // A content block (`@include m { … }`) passed to a mixin of this file // that places `@content` at its top level is the including rule's @@ -945,24 +1052,24 @@ export function familiesOf( // `@media`) is not traced, as the mixin's own nested blocks are not. const contentOf = (scope) => { const block = blocks.find((b) => b.start === scope); - const named = /^@include\s+([\w-]+)/i.exec(block?.prelude ?? ''); + const named = /^@include\s+([\w-]+)(?![\w.-])/i.exec( + block?.prelude ?? '' + ); if (!named) return null; const name = named[1].replace(/_/g, '-'); - const places = blocks - .filter((b) => b.kind === 'callable' && b.name === name) - .filter((b) => /^@mixin\b/i.test(b.prelude)) - .flatMap((mixin) => - [...lexed.text.slice(mixin.start, mixin.end).matchAll(CONTENT)] - .map((match) => mixin.start + match.index) - .filter((index) => !inString(index)) - .filter( - (index) => placeOf(blocks, index).scope === mixin.start - ) - ); const site = Math.max( ...(includes.get(name) ?? []).filter((index) => index < scope) ); - return places.length && Number.isFinite(site) ? { places, site } : null; + if (!Number.isFinite(site)) return null; + // Each place with the definition it sits in, as that runs there. + const places = definitionsAt(name, site).defs.flatMap((mixin) => + [...lexed.text.slice(mixin.start, mixin.end).matchAll(CONTENT)] + .map((match) => mixin.start + match.index) + .filter((index) => !inString(index)) + .filter((index) => placeOf(blocks, index).scope === mixin.start) + .map((index) => ({ index, check: { name, site, def: mixin } })) + ); + return places.length ? { places, site } : null; }; // Cascade layers in declared order within their parent layer: as // `@layer a, b;` names them, or as a `@layer name { … }` block first @@ -1128,22 +1235,33 @@ export function familiesOf( (run) => transient || run.hold ) : []; - const landingsOf = (scope, at, path = [], inner = []) => { + const landingsOf = (scope, at, path = [], inner = [], checks = []) => { if (path.includes(scope)) return []; const next = [...path, scope]; const key = [at, ...inner]; + // Outside any mixin body it runs at `at`, where each definition it + // went through must be the one that runs; a module mixin runs for + // another module once the module has run (`external`). + if (!deferredBy(null, at) && !ranAt(checks, at)) return []; + const own = { + ...{ at, scope, rules: rulesOf(scope), key }, + ...(memberOf(scope) === null + ? {} + : { external: ranAt(checks, Infinity) }), + }; // A keyframe's declarations meet each other in its step, and run // from elsewhere too (another file, unseen here). const frames = scope === null ? null : framesOf(scope); if (frames) { return [ - { at, scope, rules: rulesOf(scope), key }, + own, ...runsOf(frames).flatMap(({ index: site }) => landingsOf( placeOf(blocks, site).scope, site, next, - key + key, + checks ).map((landing) => ({ ...landing, animated: true })) ), ]; @@ -1151,23 +1269,45 @@ export function familiesOf( const content = contentOf(scope); if (content) { const { places, site } = content; - return places.flatMap((place) => - landingsOf(placeOf(blocks, site).scope, site, next, [ - place, - ...key, - ]) + return places.flatMap(({ index: place, check }) => + landingsOf( + placeOf(blocks, site).scope, + site, + next, + [place, ...key], + [...checks, check] + ) ); } + const block = mixins.find((b) => b.start === scope); return [ - { at, scope, rules: rulesOf(scope), key }, + own, ...sitesOf(scope).flatMap((site) => - landingsOf(placeOf(blocks, site).scope, site, next, key) + landingsOf(placeOf(blocks, site).scope, site, next, key, [ + ...checks, + { name: block.name, site, def: block }, + ]) ), ...extendersOf(scope).flatMap((extender) => - landingsOf(extender.scope, at, next, inner) + landingsOf(extender.scope, at, next, inner, checks) ), ]; }; + + // Where a declaration at `key` in another module's mixin lands through + // the `@include` at `site`: as one written there, its key that place's + // followed by `key`. + const landingsAt = (site, key) => { + // A bare name lands another module's only where none of this file's + // runs there. + const bare = /^@include\s+([\w-]+)(?![\w.-])/i.exec( + lexed.text.slice(site, site + 256) + ); + const checks = bare + ? [{ name: bare[1].replace(/_/g, '-'), site, def: null }] + : []; + return landingsOf(placeOf(blocks, site).scope, site, [], key, checks); + }; // Each declaration, where it applies, in source order. const applied = []; for (const match of lexed.text.matchAll(FONT_FAMILY)) { @@ -1230,6 +1370,27 @@ export function familiesOf( }); } } + for (const [site, families] of included) { + for (const { key, entry } of families) { + for (const landing of landingsAt(site, key)) { + applied.push({ + ...landing, + entry: landing.animated + ? { ...entry, animated: true } + : entry, + }); + } + } + } + // What each module mixin sets at its top level, for the modules that + // include it: its own declarations and those landing in it. + const exported = new Map(); + for (const { scope, key, entry, external } of applied) { + const name = memberOf(scope); + if (name === null || external === false) continue; + if (!exported.has(name)) exported.set(name, []); + exported.get(name).push({ key, entry }); + } // Keyed by rule, so a later block with one of its selectors wins. applied.sort((a, b) => keyOrder(a.key, b.key)); // Where each rule's family was set, for the cascade between rules. @@ -1520,41 +1681,57 @@ export function familiesOf( // family where it lands: each rule that includes or extends it (whose // own later family wins), of those `keep` accepts (where the weight is // in effect). A mixin's body, or a placeholder (`%x`), styles nothing - // where it is written. - const familyAt = (index, keep, seen = new Set()) => { + // where it is written; one another module includes meets the family + // there instead. + const familyAt = (index, keep, seen = new Set(), checks = []) => { const place = placeOf(blocks, index); const scope = fontNamespaceRule(blocks, place) ?? place.scope; if (seen.has(scope)) return []; seen.add(scope); + // Read where it runs, if the definitions on the way run there (see + // `landingsOf`); a mixin body included nowhere, once the module ran. + const runs = ranAt(checks, deferredBy(null, index) ? Infinity : index); // A content block's declaration meets the family at its `@include`, // a keyframe's where a rule runs it. const content = contentOf(scope); - if (content) return familyAt(content.site, keep, seen); + if (content) return familyAt(content.site, keep, seen, checks); const frames = scope === null ? null : framesOf(scope); if (frames) { if (!frames.live) return []; return [ - ...(keep(scope) ? [lookup(index)] : []), + ...(keep(scope) && runs ? [lookup(index)] : []), ...runsOf(frames).flatMap(({ index: site }) => - familyAt(site, keep, seen) + familyAt(site, keep, seen, checks) ), ]; } const block = blocks.find((b) => b.start === scope); const includes = sitesOf(scope); const extended = extendersOf(scope).map((extender) => extender.index); + // Where another module includes it, it meets families unseen here. + const outside = + elsewhere.has(memberOf(scope)) && ranAt(checks, Infinity); const silent = includes.length > 0 || + elsewhere.has(memberOf(scope)) || (extended.length > 0 && /^%/.test(block?.prelude ?? '')); return [ - ...(silent || !keep(scope) ? [] : [lookup(index)]), - ...[...includes, ...extended].flatMap((site) => - familyAt(site, keep, seen) + ...(silent || !keep(scope) || !runs ? [] : [lookup(index)]), + ...(outside ? [OUTSIDE] : []), + ...includes.flatMap((site) => + familyAt(site, keep, seen, [ + ...checks, + { name: block.name, site, def: block }, + ]) ), + ...extended.flatMap((site) => familyAt(site, keep, seen, checks)), ]; }; + const monoAt = (index, keep = () => true) => { - const found = familyAt(index, keep).filter((entry) => entry !== NONE); + const found = familyAt(index, keep).filter( + (entry) => entry !== NONE && entry !== OUTSIDE + ); return ( found.find((entry) => entry.mono) ?? found.find((entry) => entry.refs.length > 0) ?? @@ -1562,6 +1739,19 @@ export function familiesOf( NONE ); }; + // The families an `@include` at `site` meets, wherever it lands: + // whether one renders JetBrains Mono (`mono`, also when the mixin it + // sits in lands in another module, unseen here), and those named + // through variables (`families`), resolved later. + monoAt.callAt = (site) => { + const found = familyAt(site, () => true); + return { + mono: found.some((entry) => entry.mono || entry === OUTSIDE), + families: found.filter( + (entry) => !entry.mono && entry.refs.length > 0 + ), + }; + }; // Where a declaration at `index` lands (`{ at, scope, rules }`, see // `landingsOf`), for the weights in effect. // Whether a rule runs a keyframe that sets a font only for a while, @@ -1576,5 +1766,10 @@ export function familiesOf( const scope = fontNamespaceRule(blocks, place) ?? place.scope; return scope === null ? [] : landingsOf(scope, index); }; + monoAt.landingsAt = landingsAt; + monoAt.memberOf = memberOf; + monoAt.definitionsAt = definitionsAt; + // Each module mixin's top-level families, by name (`{ key, entry }`). + monoAt.exported = exported; return monoAt; } diff --git a/tools/nx/font-weight-scope.mjs b/tools/nx/font-weight-scope.mjs index add412ed8..b024ee7bd 100644 --- a/tools/nx/font-weight-scope.mjs +++ b/tools/nx/font-weight-scope.mjs @@ -317,7 +317,30 @@ export function sassScopes(scans) { .filter((edge) => edge.rule === 'import') .map(({ loaded, index }) => ({ loaded, index })); - return { qualified, unqualified, imports, loadsOf }; + /** + * Whether a call written in `caller` reaches `callable`, defined in + * `file`: `ns.name` must load `file` (or a module forwarding it) as + * `ns`, under the name it exposes `callable` by; a bare `name` is + * defined in the caller itself or in what it brings in. + */ + const reaches = ({ callee, file: caller }, file, callable) => { + if (!callee || !callable) return false; + if (!callee.namespace && caller === file) { + return callee.name === callable; + } + const scope = callee.namespace + ? qualified(caller, callee.namespace) + : unqualified(caller); + const access = scope.get(file); + return ( + Boolean(access?.declarations) && + access.exposures.some( + (exposure) => exposedName(exposure, callable) === callee.name + ) + ); + }; + + return { qualified, unqualified, imports, loadsOf, reaches }; } /** From bc5a7fcbf9d2402056f13b49168442fe0e4b3d0c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:54:19 +0200 Subject: [PATCH 02/32] fix(playback): keep the saved Embedded MPV player when the mpv check is inconclusive (#1803) * fix(playback): keep the saved Embedded MPV player when the mpv check is inconclusive On Linux native-view the support check runs `mpv --version` by bare name and waits for the login shell PATH first. Since #1784 that lookup is asynchronous with a 10 s budget; when it ran out, the check ran on the inherited PATH and answered a plain `supported: false`. The settings store took that as a verdict and persisted the default player over a saved Embedded MPV selection. The main process probed again once the shell answered, but nothing restored the setting. `EmbeddedMpvSupport` now carries `inconclusive`. The native service sets it on a missing mpv while its probe has only seen the inherited PATH; the IPC handler declares that state before probing and registers the re-probe before the check, so a throwing check cannot leave it stuck. Every other answer stays final. Consumers no longer settle on an inconclusive answer: the settings store keeps the saved player, and the command palette and the settings search probe again on their next use instead of caching it for the session. Co-Authored-By: Claude Fable 5.1 * fix(playback): keep asking for Embedded MPV support while the answer is inconclusive Keeping the saved player on an inconclusive answer left a mounted player stuck on it: the session controller asked for support once, in its constructor, and the session effect never starts while unsupported, so the player did not recover after the login shell answered. The settings page held its one answer the same way. `watchEmbeddedMpvSupport()` asks again every 3 s until the answer is final or the surface is destroyed. The player controller and the settings page facade load support through it, so playback starts by itself and the Embedded MPV option appears without reopening the page. Co-Authored-By: Claude Fable 5.1 * fix(settings): follow an inconclusive Embedded MPV answer to a final decision The settings store checked a saved Embedded MPV selection once. After an inconclusive answer it kept the selection and never looked again, so when mpv turned out to be really missing the player stayed on Embedded MPV instead of falling back to the default one. The store now follows the answer with `watchEmbeddedMpvSupport()` until it is final and only then decides. It acts on an answer only while Embedded MPV is still the saved player, so a player picked meanwhile, also while the first answer was pending, is never overwritten. The watch backs off from 3 s to 30 s between rechecks, so a login shell that never answers does not keep the app polling at the first rate, and it no longer schedules a recheck after its answer handler stopped it. Co-Authored-By: Claude Fable 5.1 * fix(settings): keep the settings search following an inconclusive Embedded MPV answer The settings page asks the search service for Embedded MPV support once, when its search facade is created. After an inconclusive answer the service only probed again on its next call, so with the page left open the Embedded MPV rows stayed unsearchable after the login shell answered, while the player option on the same page already updated. The service now follows the answer with `watchEmbeddedMpvSupport()` until it is final, which updates the open page and the command palette alike. A call made while the answer is still inconclusive restarts the watch, so it asks at once as before. Co-Authored-By: Claude Fable 5.1 * fix(settings): follow Embedded MPV support for search only while the settings page is open The settings search service is provided in the root injector, so the watch it started on its first use had no owner: with a login shell that never answers it kept asking every 30 s until the app quit, long after the settings page or the command palette that needed the answer was closed. A failed recheck also ended the watch as if it were a final answer, hiding the Embedded MPV rows for the rest of the session. The service now separates the two uses. `ensureEmbeddedMpvSupportLoaded()` is a single request again, for the command palette. The settings page calls `followEmbeddedMpvSupport()` and ends it when the page is destroyed. Only a final answer is kept: after an inconclusive one or a failed request the next use asks again, for the palette's player commands too. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- ...yback-embedded-mpv-slow-shell-selection.md | 8 + .../embedded-mpv.events.login-shell.spec.ts | 198 ++++++++++++++++++ .../app/events/embedded-mpv.events.spec.ts | 57 ++++- .../src/app/events/embedded-mpv.events.ts | 9 +- .../embedded-mpv-native.service.spec.ts | 50 +++++ .../services/embedded-mpv-native.service.ts | 19 ++ .../settings-embedded-mpv.facade.spec.ts | 89 ++++++++ .../settings/settings-embedded-mpv.facade.ts | 52 ++++- .../app/settings/settings-search.facade.ts | 4 +- .../settings.component.search.spec.ts | 15 ++ docs/architecture/embedded-mpv-native.md | 35 +++- .../src/lib/settings-store.defaults.ts | 50 +++++ .../lib/settings-store.embedded-mpv.spec.ts | 190 +++++++++++++++++ .../src/lib/settings-store.service.ts | 38 +--- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/embedded-mpv-session.interface.ts | 7 + .../embedded-mpv-support-watch.util.spec.ts | 169 +++++++++++++++ .../lib/embedded-mpv-support-watch.util.ts | 64 ++++++ ...d-mpv-session-controller.lifecycle.spec.ts | 53 +++++ .../embedded-mpv-session-controller.ts | 46 ++-- ...kspace-player-commands.contributor.spec.ts | 78 ++++++- .../workspace-player-commands.contributor.ts | 6 +- .../settings-search.service.spec.ts | 140 +++++++++++++ .../settings-search.service.ts | 68 ++++-- 24 files changed, 1361 insertions(+), 85 deletions(-) create mode 100644 .changes/playback-embedded-mpv-slow-shell-selection.md create mode 100644 apps/electron-backend/src/app/events/embedded-mpv.events.login-shell.spec.ts create mode 100644 apps/web/src/app/settings/settings-embedded-mpv.facade.spec.ts create mode 100644 libs/services/src/lib/settings-store.embedded-mpv.spec.ts create mode 100644 libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.ts diff --git a/.changes/playback-embedded-mpv-slow-shell-selection.md b/.changes/playback-embedded-mpv-slow-shell-selection.md new file mode 100644 index 000000000..61d0b1f1f --- /dev/null +++ b/.changes/playback-embedded-mpv-slow-shell-selection.md @@ -0,0 +1,8 @@ +--- +type: fix +area: playback +--- + +On Linux, the app no longer switches a saved Embedded MPV player back to the +default player when your login shell is slow to start. It now keeps your +choice until it knows for certain whether mpv is installed. diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.login-shell.spec.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.login-shell.spec.ts new file mode 100644 index 000000000..8fab35178 --- /dev/null +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.login-shell.spec.ts @@ -0,0 +1,198 @@ +/** + * The Linux native-view support check end to end in the main process: the + * real IPC handlers, native service and login shell PATH lookup. Only the + * process boundary is faked: the shell (`readPath`), `mpv --version` + * (`spawnSync`) and Electron. `process.platform` is forced here, so the + * Linux branch runs on every host. + */ +import { + EMBEDDED_MPV_PREPARE, + EMBEDDED_MPV_SUPPORT, + type EmbeddedMpvSupport, +} from '@iptvnator/shared/interfaces'; + +const mockSpawnSync = jest.fn(); +const mockIpcHandle = jest.fn(); + +jest.mock('child_process', () => ({ spawnSync: mockSpawnSync })); +jest.mock('electron', () => ({ + app: { + isPackaged: true, + getAppPath: () => '/mock/app.asar', + commandLine: { getSwitchValue: () => '' }, + }, + ipcMain: { handle: mockIpcHandle }, + powerSaveBlocker: { + start: jest.fn(), + stop: jest.fn(), + isStarted: jest.fn(), + }, + screen: { getDisplayMatching: jest.fn() }, +})); +jest.mock('../app', () => ({ + __esModule: true, + default: { mainWindow: null }, +})); +jest.mock('../services/embedded-mpv-session-options', () => ({ + readEmbeddedMpvSessionOptions: () => ({ + extraOptions: [], + autoReconnect: true, + }), +})); +jest.mock('../services/embedded-mpv-frame-copy-platform.util', () => ({ + ...jest.requireActual('../services/embedded-mpv-frame-copy-platform.util'), + getFrameCopyRuntimeAvailability: () => ({ + usable: false, + reason: 'helper-probe-failed', + }), + isFrameCopyRuntimeUsable: () => false, +})); + +const INHERITED_PATH = '/usr/bin:/bin'; +const LOGIN_SHELL_ONLY_DIR = '/home/user/.local/bin'; +const LOGIN_SHELL_PATH = `${LOGIN_SHELL_ONLY_DIR}:${INHERITED_PATH}`; +/** Budget of the lookup; the shell in these tests never answers within it. */ +const LOOKUP_BUDGET_MS = 5; + +type SupportHandler = (event: unknown) => Promise; + +async function flushLookup(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +} + +describe('Embedded MPV support and a slow login shell (Linux native-view)', () => { + const originalPlatform = process.platform; + const originalEnv = { + PATH: process.env.PATH, + DISPLAY: process.env.DISPLAY, + WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY, + IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY: + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY, + }; + let answerShell: (path: string) => void; + + function handlerFor(channel: string): SupportHandler { + const registration = mockIpcHandle.mock.calls.find( + ([registered]) => registered === channel + ); + if (!registration) { + throw new Error(`Missing ipcMain handler for ${channel}`); + } + return registration[1] as SupportHandler; + } + + beforeEach(async () => { + jest.resetModules(); + mockIpcHandle.mockReset(); + mockSpawnSync.mockReset(); + // mpv is installed where only the login shell PATH reaches it. + mockSpawnSync.mockImplementation(() => ({ + status: (process.env.PATH ?? '') + .split(':') + .includes(LOGIN_SHELL_ONLY_DIR) + ? 0 + : 1, + })); + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.PATH = INHERITED_PATH; + process.env.DISPLAY = ':0'; + delete process.env.WAYLAND_DISPLAY; + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY; + + const { scheduleDeferredFixPath } = + await import('../startup/login-shell-path'); + const { embeddedMpvNativeService } = + await import('../services/embedded-mpv-native.service'); + await import('./embedded-mpv.events'); + // The addon is normally a vendored .node file; with it in place, + // mpv on PATH is the only thing support depends on. + ( + embeddedMpvNativeService as unknown as { + addon: { isSupported(): boolean }; + } + ).addon = { isSupported: () => true }; + + scheduleDeferredFixPath( + () => + new Promise((resolve) => { + answerShell = resolve; + }), + LOOKUP_BUDGET_MS + ); + // The lookup starts on the next tick; only then can it be answered. + await flushLookup(); + }); + + afterEach(async () => { + // Let the lookup finish, so no test leaves a pending shell behind. + answerShell(INHERITED_PATH); + await flushLookup(); + }); + + afterAll(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it.each([EMBEDDED_MPV_SUPPORT, EMBEDDED_MPV_PREPARE])( + '%s reports a missing mpv as inconclusive until the shell answers', + async (channel) => { + const check = handlerFor(channel); + + // The lookup runs out of budget: this probe sees the inherited + // PATH, where mpv is missing. + await expect(check({})).resolves.toMatchObject({ + supported: false, + inconclusive: true, + }); + // Asked again meanwhile: the cached answer is still not final. + await expect(check({})).resolves.toMatchObject({ + supported: false, + inconclusive: true, + }); + expect(mockSpawnSync).toHaveBeenCalledTimes(1); + + answerShell(LOGIN_SHELL_PATH); + await flushLookup(); + + const settled = await check({}); + expect(settled.supported).toBe(true); + expect(settled.inconclusive).toBeUndefined(); + expect(mockSpawnSync).toHaveBeenCalledTimes(2); + } + ); + + it('reports a missing mpv as final once the shell answered without it', async () => { + const support = handlerFor(EMBEDDED_MPV_SUPPORT); + await expect(support({})).resolves.toMatchObject({ + supported: false, + inconclusive: true, + }); + + answerShell(INHERITED_PATH); + await flushLookup(); + + const settled = await support({}); + expect(settled.supported).toBe(false); + expect(settled.reason).toContain('mpv executable'); + expect(settled.inconclusive).toBeUndefined(); + }); + + it('reports a missing mpv as final when the shell answered in time', async () => { + answerShell(INHERITED_PATH); + await flushLookup(); + + const answer = await handlerFor(EMBEDDED_MPV_SUPPORT)({}); + expect(answer.supported).toBe(false); + expect(answer.inconclusive).toBeUndefined(); + }); +}); diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts index b496fc0c2..1c34a63c1 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts @@ -10,6 +10,7 @@ const mockEmbeddedMpvService = { getSupport: jest.fn(), willProbeLinuxMpvExecutable: jest.fn(() => false), forgetLinuxMpvExecutableProbe: jest.fn(), + markLinuxMpvExecutableProbeProvisional: jest.fn(), setPaused: jest.fn(), }; const mockSessionOptions = { @@ -26,9 +27,7 @@ jest.mock('../services/embedded-mpv-session-options', () => ({ })); const mockWaitForLoginShellPath = jest.fn(() => Promise.resolve(true)); let settleLookup: () => void = () => undefined; -const mockLookupSettled = new Promise((resolve) => { - settleLookup = resolve; -}); +let mockLookupSettled = Promise.resolve(); jest.mock('../startup/login-shell-path', () => ({ waitForLoginShellPath: () => mockWaitForLoginShellPath(), whenLoginShellPathSettled: () => mockLookupSettled, @@ -69,6 +68,16 @@ describe('EmbeddedMpvEvents IPC handlers', () => { }); describe('support checks and the login shell PATH', () => { + beforeEach(() => { + // A lookup of its own per test: the pending re-probe of one test + // must not answer for the next. + mockLookupSettled = new Promise((resolve) => { + settleLookup = resolve; + }); + mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe.mockClear(); + mockEmbeddedMpvService.markLinuxMpvExecutableProbeProvisional.mockClear(); + }); + afterEach(() => { mockWaitForLoginShellPath.mockClear(); mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReset(); @@ -110,6 +119,10 @@ describe('EmbeddedMpvEvents IPC handlers', () => { expect( mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe ).not.toHaveBeenCalled(); + // The probe saw the login shell PATH: its answer is final. + expect( + mockEmbeddedMpvService.markLinuxMpvExecutableProbeProvisional + ).not.toHaveBeenCalled(); } ); @@ -125,6 +138,17 @@ describe('EmbeddedMpvEvents IPC handlers', () => { await expect( getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({}) ).resolves.toEqual({ supported: false }); + // The service is told before it probes, so the answer of this + // very check is already marked as not final. + const { markLinuxMpvExecutableProbeProvisional, getSupport } = + mockEmbeddedMpvService; + expect( + markLinuxMpvExecutableProbeProvisional + ).toHaveBeenCalledTimes(1); + expect( + markLinuxMpvExecutableProbeProvisional.mock + .invocationCallOrder[0] + ).toBeLessThan(getSupport.mock.invocationCallOrder[0]); expect( mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe ).not.toHaveBeenCalled(); @@ -136,6 +160,33 @@ describe('EmbeddedMpvEvents IPC handlers', () => { ).toHaveBeenCalledTimes(1); }); + it('still re-probes when the check on the inherited PATH throws', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(); + mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue( + true + ); + mockWaitForLoginShellPath.mockResolvedValueOnce(false); + mockEmbeddedMpvService.getSupport.mockImplementation(() => { + throw new Error('probe failed'); + }); + + try { + await expect( + getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({}) + ).rejects.toThrow('probe failed'); + // Otherwise the provisional state would outlive the lookup. + settleLookup(); + await new Promise((resolve) => setImmediate(resolve)); + expect( + mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe + ).toHaveBeenCalledTimes(1); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + it('does not wait when no probe runs, nor for session calls', async () => { mockEmbeddedMpvService.getSupport.mockReturnValue({ supported: true, diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.ts index 904bd9bb2..cd49c6c58 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.ts @@ -80,13 +80,14 @@ async function afterLoginShellPathIfProbing(check: () => T): Promise { ) { return check(); } - // The lookup ran out of budget, so this probe sees the inherited PATH. - // Once the shell does answer, the result is probed again. - const result = check(); + // The lookup ran out of budget, so this probe sees the inherited PATH: a + // missing mpv is answered as inconclusive, never as a verdict the + // renderer may persist. Once the shell does answer, it is probed again. + getService().markLinuxMpvExecutableProbeProvisional(); void whenLoginShellPathSettled().then(() => getService().forgetLinuxMpvExecutableProbe() ); - return result; + return check(); } handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () => diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 728af5580..9991436e8 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -414,6 +414,56 @@ describe('EmbeddedMpvNativeService power blocker', () => { expect(service.willProbeLinuxMpvExecutable()).toBe(true); }); + it('reports a missing mpv as inconclusive only while its probe is provisional', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.DISPLAY = ':0'; + delete process.env.WAYLAND_DISPLAY; + mockSpawnSync.mockReturnValue({ status: 1 }); + mockRuntimeUsable(); + + // The login shell has not answered: mpv is looked up on the + // inherited PATH. + service.markLinuxMpvExecutableProbeProvisional(); + expect(service.getSupport()).toEqual( + expect.objectContaining({ + supported: false, + inconclusive: true, + }) + ); + expect(service.prepareAddon()).toEqual( + expect.objectContaining({ + supported: false, + inconclusive: true, + }) + ); + + // It answered: the next probe is a verdict again. + service.forgetLinuxMpvExecutableProbe(); + const settled = service.getSupport(); + expect(settled.supported).toBe(false); + expect(settled.reason).toContain('mpv executable'); + expect(settled.inconclusive).toBeUndefined(); + }); + + it('keeps every other answer final while the mpv probe is provisional', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.DISPLAY = ':0'; + delete process.env.WAYLAND_DISPLAY; + mockSpawnSync.mockReturnValue({ status: 0 }); + mockRuntimeUsable(); + service.markLinuxMpvExecutableProbeProvisional(); + + const found = service.getSupport(); + expect(found.supported).toBe(true); + expect(found.inconclusive).toBeUndefined(); + + // mpv is there, the addon is not: the PATH cannot change that. + addon.isSupported.mockReturnValue(false); + const unsupported = service.getSupport(); + expect(unsupported.supported).toBe(false); + expect(unsupported.inconclusive).toBeUndefined(); + }); + it('predicts no probe for the frame-copy engine or native Wayland', () => { Object.defineProperty(process, 'platform', { value: 'linux' }); process.env.DISPLAY = ':0'; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 10f30a806..e929d240f 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -174,6 +174,12 @@ export class EmbeddedMpvNativeService { private powerBlockerId: number | null = null; private readonly loadAddonModule = createRequire(__filename); private cachedLinuxMpvExecutableReason: string | null | undefined; + /** + * True while `mpv --version` runs, or was cached, on the inherited PATH + * because the login shell had not answered: a missing mpv is then no + * verdict yet. + */ + private linuxMpvExecutableProbeIsProvisional = false; private frameCopyAdapter: EmbeddedMpvFrameCopyAdapter | null = null; private sessionOptionsDirectory: string | null = null; /** @@ -401,6 +407,16 @@ export class EmbeddedMpvNativeService { */ forgetLinuxMpvExecutableProbe(): void { this.cachedLinuxMpvExecutableReason = undefined; + this.linuxMpvExecutableProbeIsProvisional = false; + } + + /** + * Declares that the probe sees the inherited PATH, the login shell one + * not having arrived. Until `forgetLinuxMpvExecutableProbe()`, a missing + * mpv is reported as `inconclusive`, so no caller settles on it. + */ + markLinuxMpvExecutableProbeProvisional(): void { + this.linuxMpvExecutableProbeIsProvisional = true; } getSupport(): EmbeddedMpvSupport { @@ -458,6 +474,9 @@ export class EmbeddedMpvNativeService { supported: false, platform: process.platform, reason: missingLinuxMpvExecutableReason, + ...(this.linuxMpvExecutableProbeIsProvisional + ? { inconclusive: true } + : {}), ...this.getFrameCopySupportDetails(), }; } diff --git a/apps/web/src/app/settings/settings-embedded-mpv.facade.spec.ts b/apps/web/src/app/settings/settings-embedded-mpv.facade.spec.ts new file mode 100644 index 000000000..069b409c6 --- /dev/null +++ b/apps/web/src/app/settings/settings-embedded-mpv.facade.spec.ts @@ -0,0 +1,89 @@ +import { TestBed } from '@angular/core/testing'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + EMBEDDED_MPV_SUPPORT_RECHECK_MS, + EmbeddedMpvSupport, +} from '@iptvnator/shared/interfaces'; +import { SettingsEmbeddedMpvFacade } from './settings-embedded-mpv.facade'; + +const SUPPORTED: EmbeddedMpvSupport = { supported: true, platform: 'linux' }; +/** A slow login shell: mpv was looked up before its PATH arrived. */ +const INCONCLUSIVE: EmbeddedMpvSupport = { + supported: false, + platform: 'linux', + reason: 'mpv executable missing', + inconclusive: true, +}; + +describe('SettingsEmbeddedMpvFacade', () => { + const originalElectron = window.electron; + let getEmbeddedMpvSupport: jest.Mock; + let facade: SettingsEmbeddedMpvFacade; + + beforeEach(() => { + jest.useFakeTimers(); + getEmbeddedMpvSupport = jest.fn(); + window.electron = { + platform: 'linux', + getEmbeddedMpvSupport, + } as unknown as typeof window.electron; + TestBed.configureTestingModule({ + providers: [ + SettingsEmbeddedMpvFacade, + { + provide: RuntimeCapabilitiesService, + useValue: { isElectron: true }, + }, + ], + }); + facade = TestBed.inject(SettingsEmbeddedMpvFacade); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + window.electron = originalElectron; + jest.useRealTimers(); + }); + + it('offers Embedded MPV once an inconclusive answer turns into supported', async () => { + getEmbeddedMpvSupport + .mockResolvedValueOnce(INCONCLUSIVE) + .mockResolvedValue(SUPPORTED); + + await facade.load(); + expect(facade.supported()).toBe(false); + + // The page stays open; nobody calls load() again. + await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS); + expect(facade.supported()).toBe(true); + + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3 + ); + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2); + }); + + it('stops asking once the settings page is closed', async () => { + getEmbeddedMpvSupport.mockResolvedValue(INCONCLUSIVE); + await facade.load(); + + TestBed.resetTestingModule(); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3 + ); + + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1); + }); + + it('reports a failed probe as unsupported', async () => { + getEmbeddedMpvSupport.mockRejectedValue(new Error('addon load failed')); + + await facade.load(); + + expect(facade.support()).toEqual({ + supported: false, + platform: 'linux', + reason: 'addon load failed', + }); + }); +}); diff --git a/apps/web/src/app/settings/settings-embedded-mpv.facade.ts b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts index cfe738c72..59c2cb632 100644 --- a/apps/web/src/app/settings/settings-embedded-mpv.facade.ts +++ b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts @@ -1,6 +1,15 @@ -import { computed, inject, Injectable, signal } from '@angular/core'; +import { + computed, + DestroyRef, + inject, + Injectable, + signal, +} from '@angular/core'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; -import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces'; +import { + EmbeddedMpvSupport, + watchEmbeddedMpvSupport, +} from '@iptvnator/shared/interfaces'; /** * Probes the desktop backend for embedded MPV support so the playback @@ -9,6 +18,7 @@ import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces'; @Injectable() export class SettingsEmbeddedMpvFacade { private readonly runtime = inject(RuntimeCapabilitiesService); + private stopSupportWatch: (() => void) | undefined; readonly support = signal(null); @@ -24,6 +34,14 @@ export class SettingsEmbeddedMpvFacade { () => this.support()?.engine === 'frame-copy' ); + constructor() { + inject(DestroyRef).onDestroy(() => this.stopSupportWatch?.()); + } + + /** + * Resolves with the first answer. An inconclusive one keeps being asked + * for while the page is open, so the option appears without reopening it. + */ async load(): Promise { if (!this.runtime.isElectron) { this.support.set({ @@ -43,15 +61,27 @@ export class SettingsEmbeddedMpvFacade { return; } - try { - this.support.set(await window.electron.getEmbeddedMpvSupport()); - } catch (error) { - this.support.set({ - supported: false, - platform: window.electron.platform, - reason: error instanceof Error ? error.message : String(error), - }); - } + this.stopSupportWatch?.(); + await new Promise((answered) => { + this.stopSupportWatch = watchEmbeddedMpvSupport( + () => window.electron.getEmbeddedMpvSupport(), + (support) => { + this.support.set(support); + answered(); + }, + (error) => { + this.support.set({ + supported: false, + platform: window.electron.platform, + reason: + error instanceof Error + ? error.message + : String(error), + }); + answered(); + } + ); + }); } /** diff --git a/apps/web/src/app/settings/settings-search.facade.ts b/apps/web/src/app/settings/settings-search.facade.ts index 036c80b73..5893c85e0 100644 --- a/apps/web/src/app/settings/settings-search.facade.ts +++ b/apps/web/src/app/settings/settings-search.facade.ts @@ -66,7 +66,9 @@ export class SettingsSearchFacade { }); constructor() { - void this.settingsSearch.ensureEmbeddedMpvSupportLoaded(); + inject(DestroyRef).onDestroy( + this.settingsSearch.followEmbeddedMpvSupport() + ); effect(() => { if (!this.isSearching()) { diff --git a/apps/web/src/app/settings/settings.component.search.spec.ts b/apps/web/src/app/settings/settings.component.search.spec.ts index 075357c25..540761a1d 100644 --- a/apps/web/src/app/settings/settings.component.search.spec.ts +++ b/apps/web/src/app/settings/settings.component.search.spec.ts @@ -95,6 +95,21 @@ describe('SettingsComponent search', () => { expect(query('app-settings-general-section')).not.toBeNull(); }); + it('follows Embedded MPV support only while the page is open', () => { + const stopFollowing = jest.fn(); + const follow = jest + .spyOn(settingsSearch, 'followEmbeddedMpvSupport') + .mockReturnValue(stopFollowing); + + const page = TestBed.createComponent(SettingsComponent); + expect(follow).toHaveBeenCalledTimes(1); + expect(stopFollowing).not.toHaveBeenCalled(); + + // Closing the page ends it: nothing shows these rows any more. + page.destroy(); + expect(stopFollowing).toHaveBeenCalledTimes(1); + }); + it('shows an empty state when nothing matches', () => { setSettingsSearchQuery('zzzz-no-such-setting'); fixture.detectChanges(); diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 69f46a5d3..d3ac24548 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -185,6 +185,39 @@ support API from global workspace startup paths; use an explicit user action or idle preparation path when a renderer surface only needs to reveal optional Embedded MPV UI. +An unsupported answer can be `inconclusive`. The Linux native-view `mpv` +executable check runs `mpv --version` by bare name, so the support and prepare +handlers wait for the login shell PATH lookup (`startup/login-shell-path.ts`) +first. When that lookup runs out of its budget, the check runs on the +inherited PATH: `EmbeddedMpvNativeService` then reports a missing `mpv` as +`supported: false` with `inconclusive: true`, keeps doing so while the cached +result stands, and probes again once the shell answers. Every other answer, +including a missing `mpv` after the shell answered, is final. An inconclusive +answer is not a verdict on the machine: never persist a decision made from it. +Whatever holds on to one answer follows it through `watchEmbeddedMpvSupport()` +(`@iptvnator/shared/interfaces`), which asks again after +`EMBEDDED_MPV_SUPPORT_RECHECK_MS`, backing off to +`EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS`, until the answer is final: + +- The settings store keeps a saved Embedded MPV selection while the answer is + inconclusive, falls back to the default player only on a final unsupported + answer, and never overwrites a player the user picked meanwhile. +- The player (`EmbeddedMpvSessionController`) and the settings page stay + mounted on one answer: a player mounted in that window starts playback by + itself once `mpv` is found, and the option appears without reopening the + page. +- The settings page also follows the answer for its search + (`SettingsSearchService.followEmbeddedMpvSupport()`) and ends that when it + closes: the Embedded MPV rows become searchable while the page stays open, + and nothing keeps asking once no surface shows them. + +The command palette asks on demand, on every open, for its player commands and +its settings rows (`ensureEmbeddedMpvSupportLoaded()`). Only a final answer is +kept for the session; after an inconclusive one, or a failed request, the next +open asks again. An open palette is a snapshot of that moment: it does not wait +for a final answer, because a login shell that never answers would then keep +it from opening. + When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds. For the native-view engine, the MPV video surface is a platform view/window, @@ -967,7 +1000,7 @@ Defensive practice for this component: Concrete bugs from the audit, recorded so they don't get reintroduced: -- **Infinite session-create loop.** `EmbeddedMpvSessionController.startSession` once wrote `this.support.set(prepared)` after the `prepareEmbeddedMpv` round-trip. The component's session-creation effect tracks `this.support()`, so the write fired the effect → cleanup disposed the session → new session was created → prepare ran again → support was set again. Symptom: endless "Loading stream…" spinner. Fix: do not write `support` inside `startSession`; the constructor's `loadSupport()` already populates it including capabilities. +- **Infinite session-create loop.** `EmbeddedMpvSessionController.startSession` once wrote `this.support.set(prepared)` after the `prepareEmbeddedMpv` round-trip. The component's session-creation effect tracks `this.support()`, so the write fired the effect → cleanup disposed the session → new session was created → prepare ran again → support was set again. Symptom: endless "Loading stream…" spinner. Fix: do not write `support` inside `startSession`; the constructor's `watchSupport()` already populates it including capabilities. - **Stream restart on volume change.** The session-creation effect once read `this.volume()` directly to pass to `startSession`'s `initialVolume`. Each volume tick re-ran the effect, disposing and recreating the session — for VOD/series this restarted playback from the beginning. Fix: read it via `untracked(() => this.volume())`. Subsequent volume changes flow through `controller.applyVolume()`, never through the effect graph. - **Spurious `timeUpdate` re-emits and `volume.set` calls.** The session-fan-out effect calls `scheduleControlsHide()`, which reads `isPlaying`, `menus.anyOpen`, `statusLabel`, and `controlsVisible`. Those reads became tracked deps, so opening any popover, pausing, or hovering re-ran the body. No loop in isolation, but a parent that wires `timeUpdate` back into `playback.startTime` would have hit the volume-restart bug class. Fix: wrap the side-effect block in `untracked()` so the effect listens only to session changes. - **2 Hz no-op stalled-tracker re-runs.** Position polling updates `session` around 2 Hz. Tracking the full session would re-run stalled logic for snapshots with unchanged status, so the controller tracks only `sessionStatus` and invokes `EmbeddedMpvStalledTracker.track` inside `untracked()`, avoiding full-session reruns. diff --git a/libs/services/src/lib/settings-store.defaults.ts b/libs/services/src/lib/settings-store.defaults.ts index 75d4da6c2..aec852a0b 100644 --- a/libs/services/src/lib/settings-store.defaults.ts +++ b/libs/services/src/lib/settings-store.defaults.ts @@ -8,6 +8,7 @@ import { StreamFormat, Theme, VideoPlayer, + watchEmbeddedMpvSupport, } from '@iptvnator/shared/interfaces'; /** Defaults and boot-time helpers of `SettingsStore`, split out for size. */ @@ -115,3 +116,52 @@ export function scheduleEmbeddedMpvPrepare(): void { window.setTimeout(prepare, 2000); } } + +/** + * Checks a saved Embedded MPV selection against this machine: schedules the + * idle prepare when it is supported, and calls `fallBack` when it is not or + * when the check itself fails. An inconclusive answer is no verdict: the + * selection stays and the answer is followed until it is final. Nothing is + * done once `isSaved` turns false, because the user picked another player + * meanwhile. Resolves when the first answer has been handled. + */ +export function verifySavedEmbeddedMpvPlayer( + isSaved: () => boolean, + fallBack: () => Promise +): Promise { + const electron = + typeof window === 'undefined' ? undefined : window.electron; + if (!electron?.getEmbeddedMpvSupport) { + return fallBack(); + } + + return new Promise((handled, failed) => { + const stop = watchEmbeddedMpvSupport( + () => electron.getEmbeddedMpvSupport(), + (support) => { + if (!isSaved()) { + stop(); + handled(); + } else if (support.supported) { + scheduleEmbeddedMpvPrepare(); + handled(); + } else if (support.inconclusive) { + handled(); + } else { + fallBack().then(handled, failed); + } + }, + (error) => { + console.warn( + 'Failed to verify embedded MPV support; reverting to the default inline player.', + error + ); + if (isSaved()) { + fallBack().then(handled, failed); + } else { + handled(); + } + } + ); + }); +} diff --git a/libs/services/src/lib/settings-store.embedded-mpv.spec.ts b/libs/services/src/lib/settings-store.embedded-mpv.spec.ts new file mode 100644 index 000000000..243477609 --- /dev/null +++ b/libs/services/src/lib/settings-store.embedded-mpv.spec.ts @@ -0,0 +1,190 @@ +import { Injector } from '@angular/core'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { of } from 'rxjs'; +import { + EMBEDDED_MPV_SUPPORT_RECHECK_MS, + EmbeddedMpvSupport, + Settings, + STORE_KEY, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import { EpgSourceSettingsService } from './epg-source-settings.service'; +import { SettingsStore } from './settings-store.service'; + +/** What the main process answers when the Linux `mpv` probe finds nothing. */ +const MPV_MISSING: EmbeddedMpvSupport = { + supported: false, + platform: 'linux', + reason: 'Embedded MPV on Linux requires the mpv executable on PATH.', + frameCopyAvailable: false, + frameCopyUnavailableReason: 'helper-probe-failed', +}; +/** A slow login shell: mpv was looked up before its PATH arrived. */ +const INCONCLUSIVE: EmbeddedMpvSupport = { ...MPV_MISSING, inconclusive: true }; +const SUPPORTED: EmbeddedMpvSupport = { + supported: true, + platform: 'linux', + engine: 'native', +}; + +describe('SettingsStore saved Embedded MPV selection', () => { + const testWindow = window as unknown as { + electron?: { getEmbeddedMpvSupport: jest.Mock }; + }; + const originalElectron = testWindow.electron; + let getEmbeddedMpvSupport: jest.Mock; + let storage: { get: jest.Mock; set: jest.Mock }; + let injector: Injector; + + /** Loads settings as on startup and lets the first answer be handled. */ + async function start(): Promise> { + const store = injector.get(SettingsStore); + await store.loadSettings(); + await jest.advanceTimersByTimeAsync(0); + expect(getEmbeddedMpvSupport).toHaveBeenCalled(); + return store; + } + + const persistedPlayers = () => + storage.set.mock.calls.map(([, settings]) => settings.player); + + beforeEach(() => { + jest.useFakeTimers(); + const saved: Partial = { player: VideoPlayer.EmbeddedMpv }; + storage = { + get: jest.fn(() => of(saved)), + set: jest.fn(() => of(undefined)), + }; + getEmbeddedMpvSupport = jest.fn(); + testWindow.electron = { getEmbeddedMpvSupport }; + injector = Injector.create({ + providers: [ + SettingsStore, + EpgSourceSettingsService, + { provide: StorageMap, useValue: storage }, + ], + }); + jest.spyOn( + injector.get(EpgSourceSettingsService), + 'synchronize' + ).mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + testWindow.electron = originalElectron; + }); + + it('keeps the saved player while the support check is inconclusive', async () => { + getEmbeddedMpvSupport.mockResolvedValue(INCONCLUSIVE); + + const store = await start(); + await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS); + + expect(store.player()).toBe(VideoPlayer.EmbeddedMpv); + expect(storage.set).not.toHaveBeenCalled(); + }); + + it('falls back to the default player on a final unsupported answer', async () => { + getEmbeddedMpvSupport.mockResolvedValue(MPV_MISSING); + + const store = await start(); + + expect(store.player()).toBe(VideoPlayer.VideoJs); + expect(storage.set).toHaveBeenCalledWith( + STORE_KEY.Settings, + expect.objectContaining({ player: VideoPlayer.VideoJs }) + ); + }); + + it('keeps the saved player when Embedded MPV is supported', async () => { + getEmbeddedMpvSupport.mockResolvedValue(SUPPORTED); + + const store = await start(); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3 + ); + + expect(store.player()).toBe(VideoPlayer.EmbeddedMpv); + expect(storage.set).not.toHaveBeenCalled(); + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1); + }); + + it('follows an inconclusive answer and falls back once mpv is finally missing', async () => { + getEmbeddedMpvSupport + .mockResolvedValueOnce(INCONCLUSIVE) + .mockResolvedValue(MPV_MISSING); + + const store = await start(); + expect(store.player()).toBe(VideoPlayer.EmbeddedMpv); + + // The login shell answered: mpv really is not installed. + await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS); + + expect(store.player()).toBe(VideoPlayer.VideoJs); + expect(persistedPlayers()).toEqual([VideoPlayer.VideoJs]); + }); + + it('follows an inconclusive answer and keeps the player once mpv is found', async () => { + getEmbeddedMpvSupport + .mockResolvedValueOnce(INCONCLUSIVE) + .mockResolvedValue(SUPPORTED); + + const store = await start(); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20 + ); + + expect(store.player()).toBe(VideoPlayer.EmbeddedMpv); + expect(storage.set).not.toHaveBeenCalled(); + // The final answer ended the checks. + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2); + }); + + it('leaves a player the user picked meanwhile alone and stops checking', async () => { + getEmbeddedMpvSupport + .mockResolvedValueOnce(INCONCLUSIVE) + .mockResolvedValue(MPV_MISSING); + const store = await start(); + + await store.updateSettings({ player: VideoPlayer.MPV }); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20 + ); + + expect(store.player()).toBe(VideoPlayer.MPV); + expect(persistedPlayers()).toEqual([VideoPlayer.MPV]); + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2); + }); + + it('leaves a player picked while the first answer was pending alone', async () => { + let answer: (support: EmbeddedMpvSupport) => void = () => undefined; + getEmbeddedMpvSupport.mockReturnValue( + new Promise((resolve) => { + answer = resolve; + }) + ); + const store = await start(); + + await store.updateSettings({ player: VideoPlayer.VLC }); + answer(MPV_MISSING); + await jest.advanceTimersByTimeAsync(0); + + expect(store.player()).toBe(VideoPlayer.VLC); + expect(persistedPlayers()).toEqual([VideoPlayer.VLC]); + }); + + it('falls back to the default player when the support check fails', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); + getEmbeddedMpvSupport.mockRejectedValue(new Error('bridge failed')); + + try { + const store = await start(); + + expect(store.player()).toBe(VideoPlayer.VideoJs); + expect(persistedPlayers()).toEqual([VideoPlayer.VideoJs]); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index a517f1fcb..e6ae6aa9f 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -41,6 +41,7 @@ import { DEFAULT_SETTINGS, scheduleEmbeddedMpvPrepare, SettingsStorageState, + verifySavedEmbeddedMpvPlayer, } from './settings-store.defaults'; export type { SettingsStorageFailure } from './settings-store.defaults'; @@ -324,39 +325,14 @@ export const SettingsStore = signalStore( }, async sanitizeEmbeddedMpvSelection() { - if (store.player() !== VideoPlayer.EmbeddedMpv) { - return; - } - - if ( - typeof window === 'undefined' || - !window.electron?.getEmbeddedMpvSupport - ) { - await this.updateSettings({ - player: DEFAULT_SETTINGS.player, - }); - return; - } - - try { - const support = - await window.electron.getEmbeddedMpvSupport(); - if (!support.supported) { - await this.updateSettings({ + const isSaved = () => + store.player() === VideoPlayer.EmbeddedMpv; + if (isSaved()) { + await verifySavedEmbeddedMpvPlayer(isSaved, () => + this.updateSettings({ player: DEFAULT_SETTINGS.player, - }); - return; - } - - scheduleEmbeddedMpvPrepare(); - } catch (error) { - console.warn( - 'Failed to verify embedded MPV support; reverting to the default inline player.', - error + }) ); - await this.updateSettings({ - player: DEFAULT_SETTINGS.player, - }); } }, }; diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 6e8ee1abf..95a3a08f2 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -6,6 +6,7 @@ export * from './lib/content-metadata.interface'; export * from './lib/dev-logger.util'; export * from './lib/download-metadata.interface'; export * from './lib/embedded-mpv-session.interface'; +export * from './lib/embedded-mpv-support-watch.util'; export * from './lib/subtitle-style.util'; export * from './lib/zoom-level.util'; export * from './lib/electron-api.interface'; diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index f452966c5..0f90c67c0 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -39,6 +39,13 @@ export interface EmbeddedMpvSupport { supported: boolean; platform: string; reason?: string; + /** + * True when `supported: false` is not a verdict on this machine yet: the + * Linux native-view `mpv` executable was looked up before the login shell + * PATH arrived, and is looked up again once the shell answers. Never + * persist a decision made from such an answer; ask again later. + */ + inconclusive?: boolean; capabilities?: EmbeddedMpvCapabilities; /** * Rendering engine the main process will use for new sessions. diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.spec.ts b/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.spec.ts new file mode 100644 index 000000000..8cccf13d7 --- /dev/null +++ b/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.spec.ts @@ -0,0 +1,169 @@ +import type { EmbeddedMpvSupport } from './embedded-mpv-session.interface'; +import { + EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS, + EMBEDDED_MPV_SUPPORT_RECHECK_MS, + watchEmbeddedMpvSupport, +} from './embedded-mpv-support-watch.util'; + +const SUPPORTED: EmbeddedMpvSupport = { supported: true, platform: 'linux' }; +const UNSUPPORTED: EmbeddedMpvSupport = { + supported: false, + platform: 'linux', + reason: 'no mpv', +}; +const INCONCLUSIVE: EmbeddedMpvSupport = { ...UNSUPPORTED, inconclusive: true }; + +describe('watchEmbeddedMpvSupport', () => { + let onAnswer: jest.Mock; + let onError: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + onAnswer = jest.fn(); + onError = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it.each([SUPPORTED, UNSUPPORTED])( + 'delivers a final answer once and asks no more: %j', + async (answer) => { + const getSupport = jest.fn().mockResolvedValue(answer); + + watchEmbeddedMpvSupport(getSupport, onAnswer, onError); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3 + ); + + expect(getSupport).toHaveBeenCalledTimes(1); + expect(onAnswer.mock.calls).toEqual([[answer]]); + expect(onError).not.toHaveBeenCalled(); + } + ); + + it('asks again while the answer is inconclusive, until a final one', async () => { + const getSupport = jest + .fn() + .mockResolvedValueOnce(INCONCLUSIVE) + .mockResolvedValueOnce(INCONCLUSIVE) + .mockResolvedValue(SUPPORTED); + + watchEmbeddedMpvSupport(getSupport, onAnswer, onError); + await jest.advanceTimersByTimeAsync(0); + expect(getSupport).toHaveBeenCalledTimes(1); + expect(onAnswer).toHaveBeenLastCalledWith(INCONCLUSIVE); + + await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS); + expect(getSupport).toHaveBeenCalledTimes(2); + expect(onAnswer).toHaveBeenLastCalledWith(INCONCLUSIVE); + + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 2 + ); + expect(getSupport).toHaveBeenCalledTimes(3); + expect(onAnswer).toHaveBeenLastCalledWith(SUPPORTED); + + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3 + ); + expect(getSupport).toHaveBeenCalledTimes(3); + }); + + it('backs off to the slowest rate while the answer stays inconclusive', async () => { + const getSupport = jest.fn().mockResolvedValue(INCONCLUSIVE); + + watchEmbeddedMpvSupport(getSupport, onAnswer, onError); + // Rechecks after 3, 6, 12 and 24 s, then every 30 s. + await jest.advanceTimersByTimeAsync(3000 + 6000 + 12_000 + 24_000); + expect(getSupport).toHaveBeenCalledTimes(5); + + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS - 1 + ); + expect(getSupport).toHaveBeenCalledTimes(5); + await jest.advanceTimersByTimeAsync(1); + expect(getSupport).toHaveBeenCalledTimes(6); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS + ); + expect(getSupport).toHaveBeenCalledTimes(7); + }); + + it('asks no more when the answer handler stops the watch', async () => { + const getSupport = jest.fn().mockResolvedValue(INCONCLUSIVE); + const stop: () => void = watchEmbeddedMpvSupport( + getSupport, + () => stop(), + onError + ); + + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3 + ); + + expect(getSupport).toHaveBeenCalledTimes(1); + }); + + it('asks no more once stopped', async () => { + const getSupport = jest.fn().mockResolvedValue(INCONCLUSIVE); + + const stop = watchEmbeddedMpvSupport(getSupport, onAnswer, onError); + await jest.advanceTimersByTimeAsync(0); + stop(); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3 + ); + + expect(getSupport).toHaveBeenCalledTimes(1); + expect(onAnswer).toHaveBeenCalledTimes(1); + }); + + it('drops an answer that arrives after it was stopped', async () => { + let answer: (support: EmbeddedMpvSupport) => void = () => undefined; + const getSupport = jest.fn( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + + const stop = watchEmbeddedMpvSupport(getSupport, onAnswer, onError); + stop(); + answer(INCONCLUSIVE); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3 + ); + + expect(onAnswer).not.toHaveBeenCalled(); + expect(getSupport).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['rejects', () => Promise.reject(new Error('bridge failed'))], + [ + 'throws', + () => { + throw new Error('bridge failed'); + }, + ], + ])( + 'ends the watch with the error when the request %s', + async (_how, request) => { + const getSupport = jest.fn(request); + + watchEmbeddedMpvSupport(getSupport, onAnswer, onError); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3 + ); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toEqual( + new Error('bridge failed') + ); + expect(onAnswer).not.toHaveBeenCalled(); + expect(getSupport).toHaveBeenCalledTimes(1); + } + ); +}); diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.ts b/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.ts new file mode 100644 index 000000000..67317411f --- /dev/null +++ b/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.ts @@ -0,0 +1,64 @@ +import type { EmbeddedMpvSupport } from './embedded-mpv-session.interface'; + +/** + * How soon an inconclusive support answer is asked for again. The main + * process answers from its cached probe meanwhile, so a recheck costs one + * IPC round trip and spawns nothing. + */ +export const EMBEDDED_MPV_SUPPORT_RECHECK_MS = 3000; + +/** + * Each further recheck waits twice as long, up to this: a login shell that + * never answers must not keep the app polling at the first rate for good. + */ +export const EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS = 30_000; + +/** + * Asks for embedded MPV support and hands every answer to `onAnswer`. An + * `inconclusive` answer is not final, so it is asked for again until a final + * one arrives or the returned function is called. A failed request ends the + * watch through `onError`, as a final answer would. + * + * For whatever holds on to one answer: the surfaces that stay mounted on it + * (the player, the settings page) and the settings store, which has to reach + * a decision. A surface that asks on demand simply asks again the next time. + */ +export function watchEmbeddedMpvSupport( + getSupport: () => Promise, + onAnswer: (support: EmbeddedMpvSupport) => void, + onError: (error: unknown) => void +): () => void { + let stopped = false; + let recheck: ReturnType | undefined; + let recheckMs = EMBEDDED_MPV_SUPPORT_RECHECK_MS; + + const ask = async (): Promise => { + let support: EmbeddedMpvSupport; + try { + support = await getSupport(); + } catch (error) { + if (!stopped) { + onError(error); + } + return; + } + if (stopped) { + return; + } + onAnswer(support); + // `onAnswer` may have stopped the watch. + if (support.inconclusive && !stopped) { + recheck = setTimeout(() => void ask(), recheckMs); + recheckMs = Math.min( + recheckMs * 2, + EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS + ); + } + }; + void ask(); + + return () => { + stopped = true; + clearTimeout(recheck); + }; +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts index 2a3ee9e6a..c9cb22bf6 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { + EMBEDDED_MPV_SUPPORT_RECHECK_MS, EmbeddedMpvEngine, EmbeddedMpvSession, ResolvedPortalPlayback, @@ -106,6 +107,58 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => { }); }); + describe('an inconclusive support answer', () => { + // A slow login shell: mpv was looked up before its PATH arrived. + const inconclusive = { + supported: false, + platform: 'linux', + reason: 'mpv executable missing', + inconclusive: true, + }; + + it('is asked for again, so a mounted player recovers by itself', async () => { + const settled = createSupport('native'); + electron.getEmbeddedMpvSupport + .mockResolvedValueOnce(inconclusive) + .mockResolvedValue(settled); + const controller = TestBed.inject(EmbeddedMpvSessionController); + + await waitFor( + () => controller.support() !== null, + 'the first support answer' + ); + expect(controller.support()).toBe(inconclusive); + + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS + ); + expect(controller.support()).toBe(settled); + expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2); + + // A final answer is kept. + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3 + ); + expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2); + }); + + it('is no longer asked for once the player is gone', async () => { + electron.getEmbeddedMpvSupport.mockResolvedValue(inconclusive); + const controller = TestBed.inject(EmbeddedMpvSessionController); + await waitFor( + () => controller.support() !== null, + 'the first support answer' + ); + + TestBed.resetTestingModule(); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3 + ); + + expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(1); + }); + }); + it('preserves the constructor support probe after preparing a session', async () => { const probedSupport = createSupport('native'); const preparedSupport = createSupport('frame-copy'); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts index 61064b980..f7ea0c166 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts @@ -15,6 +15,7 @@ import { EmbeddedMpvSupport, RecordingStartMetadata, ResolvedPortalPlayback, + watchEmbeddedMpvSupport, } from '@iptvnator/shared/interfaces'; import { EmbeddedMpvCommandRunner } from './embedded-mpv-command-runner'; import { measureBounds } from './embedded-mpv-format.utils'; @@ -95,8 +96,9 @@ export class EmbeddedMpvSessionController { this.session.set(session); }); + let stopSupportWatch: (() => void) | undefined; if (typeof window.electron?.getEmbeddedMpvSupport === 'function') { - void this.loadSupport(); + stopSupportWatch = this.watchSupport(); } else { this.support.set({ supported: false, @@ -114,6 +116,7 @@ export class EmbeddedMpvSessionController { this.destroyRef.onDestroy(() => { this.unsubscribeSessionUpdate?.(); + stopSupportWatch?.(); this.stalledTracker.cancel(); if (this.boundsAnimationFrame !== null) { cancelAnimationFrame(this.boundsAnimationFrame); @@ -381,22 +384,31 @@ export class EmbeddedMpvSessionController { EmbeddedMpvSession['recording'] | null > => this.commands.stopRecording(); - private async loadSupport(): Promise { - try { - const electron = this.getElectronBridge(); - if (!electron?.getEmbeddedMpvSupport) { - throw new Error( - 'Embedded MPV requires the Electron desktop build.' - ); - } - this.support.set(await electron.getEmbeddedMpvSupport()); - } catch (error) { - this.support.set({ - supported: false, - platform: window.electron?.platform ?? 'unknown', - reason: error instanceof Error ? error.message : String(error), - }); - } + /** + * Loads support and keeps asking while the answer is inconclusive: the + * session effect starts playback as soon as `support` turns supported, + * so a player mounted during that window recovers by itself. + */ + private watchSupport(): () => void { + return watchEmbeddedMpvSupport( + () => { + const electron = this.getElectronBridge(); + if (!electron?.getEmbeddedMpvSupport) { + throw new Error( + 'Embedded MPV requires the Electron desktop build.' + ); + } + return electron.getEmbeddedMpvSupport(); + }, + (support) => this.support.set(support), + (error) => + this.support.set({ + supported: false, + platform: window.electron?.platform ?? 'unknown', + reason: + error instanceof Error ? error.message : String(error), + }) + ); } private getElectronBridge(): ElectronBridge | undefined { diff --git a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts index 6fd6a4a32..f3c93b8a0 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts @@ -53,7 +53,7 @@ describe('WorkspacePlayerCommandsContributor', () => { let electronStub: | { getEmbeddedMpvSupport: jest.Mock< - Promise<{ supported: boolean }>, + Promise<{ supported: boolean; inconclusive?: boolean }>, [] >; } @@ -63,7 +63,10 @@ describe('WorkspacePlayerCommandsContributor', () => { function bootstrap(options: { supportsManagedExternalPlayers: boolean; supportsEmbeddedMpv?: boolean; - embeddedMpvSupportResult?: { supported: boolean } | null; + embeddedMpvSupportResult?: { + supported: boolean; + inconclusive?: boolean; + } | null; }) { viewCommands = { registerCommand: jest.fn().mockReturnValue(() => undefined), @@ -200,6 +203,77 @@ describe('WorkspacePlayerCommandsContributor', () => { expect(resolveBoolean(embedded?.visible)).toBe(false); }); + it('keeps a final unsupported answer, but asks again after an inconclusive one', async () => { + const contributor = bootstrap({ + supportsManagedExternalPlayers: true, + supportsEmbeddedMpv: true, + // A slow login shell: mpv was looked up before its PATH arrived. + embeddedMpvSupportResult: { supported: false, inconclusive: true }, + }); + const embedded = getRegistered(viewCommands).find( + (c) => c.id === 'switch-player-embedded-mpv' + ); + + await contributor.ensureEmbeddedMpvSupportLoaded(); + expect(resolveBoolean(embedded?.visible)).toBe(false); + + // The shell answered without mpv: that answer is final. + electronStub?.getEmbeddedMpvSupport.mockResolvedValue({ + supported: false, + }); + await contributor.ensureEmbeddedMpvSupportLoaded(); + expect(resolveBoolean(embedded?.visible)).toBe(false); + expect(contributor.ensureEmbeddedMpvSupportLoaded()).toBeUndefined(); + expect(electronStub?.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2); + }); + + it('shows embedded MPV once an inconclusive answer turns into supported', async () => { + const contributor = bootstrap({ + supportsManagedExternalPlayers: true, + supportsEmbeddedMpv: true, + embeddedMpvSupportResult: { supported: false, inconclusive: true }, + }); + const embedded = getRegistered(viewCommands).find( + (c) => c.id === 'switch-player-embedded-mpv' + ); + await contributor.ensureEmbeddedMpvSupportLoaded(); + + electronStub?.getEmbeddedMpvSupport.mockResolvedValue({ + supported: true, + }); + await contributor.ensureEmbeddedMpvSupportLoaded(); + + expect(resolveBoolean(embedded?.visible)).toBe(true); + expect(contributor.ensureEmbeddedMpvSupportLoaded()).toBeUndefined(); + }); + + it('asks again after a failed request instead of giving up for the session', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); + const contributor = bootstrap({ + supportsManagedExternalPlayers: true, + supportsEmbeddedMpv: true, + }); + const embedded = getRegistered(viewCommands).find( + (c) => c.id === 'switch-player-embedded-mpv' + ); + electronStub?.getEmbeddedMpvSupport.mockRejectedValueOnce( + new Error('bridge failed') + ); + + try { + await contributor.ensureEmbeddedMpvSupportLoaded(); + expect(resolveBoolean(embedded?.visible)).toBe(false); + + await contributor.ensureEmbeddedMpvSupportLoaded(); + expect(resolveBoolean(embedded?.visible)).toBe(true); + expect(electronStub?.getEmbeddedMpvSupport).toHaveBeenCalledTimes( + 2 + ); + } finally { + warn.mockRestore(); + } + }); + it('switches to embedded MPV on run', () => { bootstrap({ supportsManagedExternalPlayers: true, diff --git a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts index 4d0893e39..fc9f58dd3 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts @@ -120,16 +120,20 @@ export class WorkspacePlayerCommandsContributor { } private async loadEmbeddedMpvSupport(): Promise { + // Only a final answer is kept. An inconclusive one, or a failed + // request, hides the command for now: the next palette open asks again. + let final = false; try { const support = await window.electron?.getEmbeddedMpvSupport?.(); this.embeddedMpvSupported.set(!!support?.supported); + final = !support?.inconclusive; } catch (error) { console.warn( 'Failed to verify embedded MPV support for the command palette.', error ); } finally { - this.embeddedMpvSupportChecked = true; + this.embeddedMpvSupportChecked = final; this.embeddedMpvSupportLoad = null; } } diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts index 355d5c911..260e83686 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts @@ -3,6 +3,7 @@ import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { EMBEDDED_MPV_SUPPORT_RECHECK_MS } from '@iptvnator/shared/interfaces'; import { SETTINGS_SEARCH_ENTRIES } from './settings-search-entries'; import { SETTINGS_SECTION_DEFINITIONS } from './settings-search-sections'; import { SettingsSearchService } from './settings-search.service'; @@ -142,6 +143,7 @@ describe('SettingsSearchService', () => { function probeWith(support: { supported: boolean; + inconclusive?: boolean; frameCopyAvailable?: boolean; }) { const getEmbeddedMpvSupport = jest @@ -186,6 +188,144 @@ describe('SettingsSearchService', () => { ); }); + it('asks again after an inconclusive answer and keeps the final one', async () => { + // A slow login shell: mpv was looked up before its PATH arrived. + const getEmbeddedMpvSupport = probeWith({ + supported: false, + inconclusive: true, + }); + const { service } = setup({ + ...DESKTOP, + supportsEmbeddedMpv: true, + }); + const entries = () => service.visibleEntries().map(({ id }) => id); + + await service.ensureEmbeddedMpvSupportLoaded(); + expect(entries()).not.toContain('embedded-mpv-extra-options'); + + // The shell answered meanwhile, and mpv is there. + getEmbeddedMpvSupport.mockResolvedValue({ + platform: 'linux', + supported: true, + }); + await service.ensureEmbeddedMpvSupportLoaded(); + expect(entries()).toContain('embedded-mpv-extra-options'); + expect(service.ensureEmbeddedMpvSupportLoaded()).toBeUndefined(); + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2); + }); + + it('asks again after a failed request instead of giving up for the session', async () => { + const getEmbeddedMpvSupport = probeWith({ supported: true }); + getEmbeddedMpvSupport.mockRejectedValueOnce( + new Error('bridge failed') + ); + const { service } = setup({ + ...DESKTOP, + supportsEmbeddedMpv: true, + }); + const entries = () => service.visibleEntries().map(({ id }) => id); + + await service.ensureEmbeddedMpvSupportLoaded(); + expect(entries()).not.toContain('embedded-mpv-extra-options'); + + await service.ensureEmbeddedMpvSupportLoaded(); + expect(entries()).toContain('embedded-mpv-extra-options'); + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2); + }); + + describe('while the settings page stays open', () => { + const inconclusive = { supported: false, inconclusive: true }; + const mountedSetup = () => + setup({ ...DESKTOP, supportsEmbeddedMpv: true }); + + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('follows an inconclusive answer without being asked again', async () => { + const getEmbeddedMpvSupport = probeWith(inconclusive); + const { service } = mountedSetup(); + const entries = () => + service.visibleEntries().map(({ id }) => id); + + // The page starts following once, when it is created. + service.followEmbeddedMpvSupport(); + await jest.advanceTimersByTimeAsync(0); + expect(entries()).not.toContain('embedded-mpv-extra-options'); + + getEmbeddedMpvSupport.mockResolvedValue({ + platform: 'linux', + supported: true, + }); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS + ); + + expect(entries()).toContain('embedded-mpv-extra-options'); + expect( + service.ensureEmbeddedMpvSupportLoaded() + ).toBeUndefined(); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20 + ); + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2); + }); + + it('stops asking once the page is closed, and asks again on the next use', async () => { + const getEmbeddedMpvSupport = probeWith(inconclusive); + const { service } = mountedSetup(); + const stopFollowing = service.followEmbeddedMpvSupport(); + await jest.advanceTimersByTimeAsync(0); + + // Nothing shows these rows any more: no polling is left. + stopFollowing(); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20 + ); + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1); + + await service.ensureEmbeddedMpvSupportLoaded(); + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2); + }); + + it('ends on a failed recheck, and the next use asks again', async () => { + const getEmbeddedMpvSupport = probeWith(inconclusive); + const { service } = mountedSetup(); + service.followEmbeddedMpvSupport(); + await jest.advanceTimersByTimeAsync(0); + + getEmbeddedMpvSupport.mockRejectedValueOnce( + new Error('bridge failed') + ); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20 + ); + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2); + + getEmbeddedMpvSupport.mockResolvedValue({ + platform: 'linux', + supported: true, + }); + await service.ensureEmbeddedMpvSupportLoaded(); + expect(service.visibleEntries().map(({ id }) => id)).toContain( + 'embedded-mpv-extra-options' + ); + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(3); + }); + + it('has nothing to follow once a final answer is in hand', async () => { + const getEmbeddedMpvSupport = probeWith({ supported: true }); + const { service } = mountedSetup(); + await service.ensureEmbeddedMpvSupportLoaded(); + + service.followEmbeddedMpvSupport(); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20 + ); + + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1); + }); + }); + it('does not probe where the runtime has no embedded MPV bridge', () => { const getEmbeddedMpvSupport = probeWith({ supported: true }); const { service } = setup(DESKTOP); diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts index 0dddc9e8d..1a17827c1 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts @@ -3,7 +3,10 @@ import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; -import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces'; +import { + EmbeddedMpvSupport, + watchEmbeddedMpvSupport, +} from '@iptvnator/shared/interfaces'; import { SETTINGS_SEARCH_ENTRIES } from './settings-search-entries'; import { rankSearchMatch, tokenizeSearchQuery } from './settings-search-rank'; import { @@ -62,13 +65,51 @@ export class SettingsSearchService { } /** - * Probes embedded MPV support once so rows that need it become - * searchable. Returns the pending probe, or `undefined` when there is - * nothing to wait for. Call it lazily (palette open, settings page), - * never from shell bootstrap: supported desktop builds may load the - * native addon while answering. + * Probes embedded MPV support so rows that need it become searchable. + * Returns the pending probe, or `undefined` when there is nothing to + * wait for. Call it lazily (palette open), never from shell bootstrap: + * supported desktop builds may load the native addon while answering. + * Only a final answer is kept; after an inconclusive one, or a failed + * request, the next call asks again. */ ensureEmbeddedMpvSupportLoaded(): Promise | undefined { + const getSupport = this.embeddedMpvSupportProbe(); + if (!getSupport) { + return undefined; + } + + this.embeddedMpvSupportLoad ??= getSupport() + .then((support) => this.takeEmbeddedMpvSupport(support)) + .catch(() => this.takeEmbeddedMpvSupport(null)) + .finally(() => { + this.embeddedMpvSupportLoad = undefined; + }); + return this.embeddedMpvSupportLoad; + } + + /** + * For a surface that keeps showing these rows (the settings page): probes + * like `ensureEmbeddedMpvSupportLoaded()` and follows an inconclusive + * answer until it is final, so the rows appear by themselves. Returns + * the function that ends it; call it when the surface goes away, so + * nothing keeps asking for an answer no one shows. + */ + followEmbeddedMpvSupport(): () => void { + const getSupport = this.embeddedMpvSupportProbe(); + if (!getSupport) { + return () => undefined; + } + + return watchEmbeddedMpvSupport( + getSupport, + (support) => this.takeEmbeddedMpvSupport(support), + () => this.takeEmbeddedMpvSupport(null) + ); + } + + /** The support request, or `undefined` when there is nothing to ask. */ + private embeddedMpvSupportProbe(): + (() => Promise) | undefined { if (this.embeddedMpvSupportChecked) { return undefined; } @@ -83,14 +124,13 @@ export class SettingsSearchService { return undefined; } - this.embeddedMpvSupportLoad ??= electron - .getEmbeddedMpvSupport() - .then((support) => this.embeddedMpvSupport.set(support)) - .catch(() => this.embeddedMpvSupport.set(null)) - .finally(() => { - this.embeddedMpvSupportChecked = true; - }); - return this.embeddedMpvSupportLoad; + return () => electron.getEmbeddedMpvSupport(); + } + + private takeEmbeddedMpvSupport(support: EmbeddedMpvSupport | null): void { + this.embeddedMpvSupport.set(support); + this.embeddedMpvSupportChecked = + support !== null && !support.inconclusive; } /** From ef795e56bc90b1881b5383845106d12a78647dda Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:53:01 +0200 Subject: [PATCH 03/32] docs(website): match the Stalker and M3U guides to the reworked add dialog (#1808) --- .../guide-m3u-add-playlist-dark.png | Bin 441523 -> 455532 bytes .../guide-m3u-add-playlist-light.png | Bin 453038 -> 451364 bytes .../guide-stalker-add-playlist-dark.png | Bin 482269 -> 488842 bytes .../guide-stalker-add-playlist-light.png | Bin 487430 -> 416427 bytes .../blog/m3u-playlist-epg-setup-guide.mdx | 2 +- .../blog/stalker-portal-setup-guide.mdx | 31 ++++++++++-------- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/website/public/blog/guides/screenshots/guide-m3u-add-playlist-dark.png b/apps/website/public/blog/guides/screenshots/guide-m3u-add-playlist-dark.png index f8d61d7ed998b2bf80869710200c7a6c27f1bf65..713e5c6405b333cf7112c4dab42d63a310bd4410 100644 GIT binary patch literal 455532 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