From e8ccd423307ffedebaf6aa0e891b326bfab908c3 Mon Sep 17 00:00:00 2001 From: Howmation Date: Fri, 30 Oct 2026 09:19:18 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(stream):=20custom=20replacements,=20fi?= =?UTF-8?q?nd=20=E2=86=92=20replace=20text=20under=20Stream=20Mode=20(0.7.?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Like custom keywords, but the user picks what is displayed instead of bullets: each pair replaces every case-insensitive match of `from` with `to` (possibly empty), in pages, subframes, link previews, the browser UI and the Go-Live preflight. - maskText/hasAnyMask take the pairs as a new optional argument. They run first, on the raw text, in one pass (a replacement is never re-scanned), longest match first; the built-in masks still apply to the result. - The page and fallback maskers now re-mask their own write from the original: a replacement holding its `from` would otherwise grow on every mutation, and an edited list would never reach rewritten nodes. - sameMaskingConfig compares arrays by value (pairs are objects). - New "Remplacements personnalisés" card on voksa://stream (FR/EN), the `from` side concealed while Stream Mode is live. - Unit tests and smoke scenario 6b (STREAM CUSTOM REPLACEMENTS). Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 4 +- package.json | 2 +- scripts/smoke.mjs | 53 +++++++ src/injected/fallbackMask.ts | 24 ++- src/injected/hoverMask.ts | 2 +- src/injected/streamMask.ts | 35 ++++- src/main/ipc/handlers.ts | 2 +- src/main/stream-mode/frameGuard.ts | 2 +- src/shared/__tests__/maskPatterns.test.ts | 63 ++++++++ src/shared/__tests__/streamConfig.test.ts | 12 ++ src/shared/i18n/en/stream.ts | 18 +++ src/shared/maskPatterns.ts | 63 +++++++- src/shared/preflight.ts | 12 +- src/shared/streamConfig.ts | 12 +- src/ui/components/pages/StreamPage.tsx | 172 +++++++++++++++++++++- src/ui/lib/masking.ts | 2 +- 16 files changed, 445 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index dcc01f6..5d7a5c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "voksa", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "voksa", - "version": "0.7.0", + "version": "0.7.1", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index bb28151..d01d3fe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "voksa", "productName": "Voksa", - "version": "0.7.0", + "version": "0.7.1", "description": "Voksa", "author": "Voksa", "license": "GPL-3.0-only", diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 9a36c2c..0079695 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -1308,6 +1308,59 @@ async function main() { } pass('STREAM MASKS LIVE PAGES'); + // --- 6b. CUSTOM REPLACEMENTS ---------------------------------------------- + // The chosen text shows instead of the match. The replacement here holds its + // own `from`: the masker observes its own write as a mutation, and must + // re-mask from the original or the text grows on every pass (my-my-server…). + // Removing the pair must then bring the original text back, live. + const countOf = (text, needle) => text.split(needle).length - 1; + await ui.evaluate( + `window.voksa.stream.update({ customReplacements: [{ from: 'server', to: 'my-server' }] })`, + ); + await waitFor( + 'custom replacement shown on the live page', + async () => { + state = await page.evaluate(PAGE_STATE_EXPR); + return state.body.includes('my-server:') && !state.body.includes(PROBE_IP); + }, + STREAM_TIMEOUT_MS, + ); + await page.evaluate( + `document.body.appendChild(document.createElement('p')).textContent = 'second server'`, + ); + await waitFor( + 'custom replacement applied to new content', + async () => { + state = await page.evaluate(PAGE_STATE_EXPR); + return state.body.includes('second my-server'); + }, + STREAM_TIMEOUT_MS, + ); + // Let any self-feeding mutation loop run before counting. + await sleep(300); + state = await page.evaluate(PAGE_STATE_EXPR); + if (countOf(state.body, 'my-server') !== 2 || state.body.includes('my-my-server')) { + throw new Error( + `replacement re-applied to its own output: ${JSON.stringify(state.body)} (streamMask.ts maskTextNode must re-mask its own write from the original)`, + ); + } + await ui.evaluate(`window.voksa.stream.update({ customReplacements: [] })`); + await waitFor( + 'removing the replacement restores the original text, still masked', + async () => { + state = await page.evaluate(PAGE_STATE_EXPR); + return ( + !state.body.includes('my-server') && + state.body.includes('server:') && + state.body.includes('second server') && + !state.body.includes(PROBE_IP) + ); + }, + STREAM_TIMEOUT_MS, + ); + await page.evaluate(`document.body.lastElementChild.remove()`); + pass('STREAM CUSTOM REPLACEMENTS'); + // --- 7. STREAM NAVIGATION NOT STUCK ------------------------------------------ await ui.evaluate( `window.voksa.tabs.navigate(${JSON.stringify(tabId)}, ${JSON.stringify(page2Url)})`, diff --git a/src/injected/fallbackMask.ts b/src/injected/fallbackMask.ts index 78212a0..f7362af 100644 --- a/src/injected/fallbackMask.ts +++ b/src/injected/fallbackMask.ts @@ -105,12 +105,15 @@ function createHandle(): FallbackHandle { } function active(): boolean { - return !!config?.enabled && hasAnyMask(flags(), config?.customMasks ?? []); + return ( + !!config?.enabled && + hasAnyMask(flags(), config?.customMasks ?? [], config?.customReplacements ?? []) + ); } function maskString(input: string): string { if (!config) return input; - return maskText(input, flags(), hostname, config.customMasks); + return maskText(input, flags(), hostname, config.customMasks, config.customReplacements); } function ensureStyle(): void { @@ -133,9 +136,13 @@ function createHandle(): FallbackHandle { if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEXTAREA') return; if (parent.isContentEditable) return; const current = node.data; - const next = maskString(current); + // Our own write is re-masked from the original, never from its masked + // form (same rule as streamMask.ts: a replacement holding a keyword would + // grow on every pass, and an edited list must reach rewritten nodes). + const ours = writtenText.get(node) === current && originalText.has(node); + const next = maskString(ours ? (originalText.get(node) as string) : current); if (next === current) return; - if (!originalText.has(node)) originalText.set(node, current); + if (!ours) originalText.set(node, current); writtenText.set(node, next); modifiedTextNodes.add(node); node.data = next; @@ -145,15 +152,16 @@ function createHandle(): FallbackHandle { for (const name of MASKABLE_ATTRS) { const value = el.getAttribute(name); if (value == null) continue; - const next = maskString(value); - if (next === value) continue; let originals = originalAttrs.get(el); + let written = writtenAttrs.get(el); + const ours = written?.get(name) === value && !!originals?.has(name); + const next = maskString(ours ? (originals?.get(name) as string) : value); + if (next === value) continue; if (!originals) { originals = new Map(); originalAttrs.set(el, originals); } - if (!originals.has(name)) originals.set(name, value); - let written = writtenAttrs.get(el); + if (!ours) originals.set(name, value); if (!written) { written = new Map(); writtenAttrs.set(el, written); diff --git a/src/injected/hoverMask.ts b/src/injected/hoverMask.ts index e9582c9..f1c62e6 100644 --- a/src/injected/hoverMask.ts +++ b/src/injected/hoverMask.ts @@ -39,7 +39,7 @@ export function installHoverMasker(host: MaskerHost): void { function maskUrl(url: string): string { if (!config) return url; - return maskText(url, flags(), host.getHostname(), config.customMasks); + return maskText(url, flags(), host.getHostname(), config.customMasks, config.customReplacements); } function shouldMask(): boolean { diff --git a/src/injected/streamMask.ts b/src/injected/streamMask.ts index 838dac2..b9fd831 100644 --- a/src/injected/streamMask.ts +++ b/src/injected/streamMask.ts @@ -81,6 +81,13 @@ export function installStreamMasker(host: MaskerHost): void { const originalAttrs = new WeakMap>(); const modifiedAttrEls = new Set(); const redactedEls = new Set(); + // What we last wrote, to tell our own write from the page's. Our write is + // re-masked from the ORIGINAL, never from its masked form: a replacement + // whose text holds a keyword would otherwise grow on every pass (the write + // is itself a mutation), and an edited list would never reach nodes it + // already rewrote. + const writtenText = new WeakMap(); + const writtenAttrs = new WeakMap>(); const shadowObserved = new WeakSet(); // Iframe gate bookkeeping (see the header). `knownFrames` also covers frames @@ -106,11 +113,14 @@ export function installStreamMasker(host: MaskerHost): void { function maskString(input: string): string { if (!config) return input; - return maskText(input, flags(), host.getHostname(), config.customMasks); + return maskText(input, flags(), host.getHostname(), config.customMasks, config.customReplacements); } function active(): boolean { - return !!config?.enabled && hasAnyMask(flags(), config?.customMasks ?? []); + return ( + !!config?.enabled && + hasAnyMask(flags(), config?.customMasks ?? [], config?.customReplacements ?? []) + ); } // --- text / attributes ----------------------------------------------------- @@ -121,9 +131,11 @@ export function installStreamMasker(host: MaskerHost): void { if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEXTAREA') return; if (parent.isContentEditable) return; const current = node.data; - const next = maskString(current); + const ours = writtenText.get(node) === current && originalText.has(node); + const next = maskString(ours ? (originalText.get(node) as string) : current); if (next !== current) { - if (!originalText.has(node)) originalText.set(node, current); + if (!ours) originalText.set(node, current); + writtenText.set(node, next); modifiedTextNodes.add(node); node.data = next; } @@ -133,14 +145,21 @@ export function installStreamMasker(host: MaskerHost): void { for (const name of MASKABLE_ATTRS) { const v = el.getAttribute(name); if (v == null) continue; - const next = maskString(v); + let store = originalAttrs.get(el); + let written = writtenAttrs.get(el); + const ours = written?.get(name) === v && !!store?.has(name); + const next = maskString(ours ? (store?.get(name) as string) : v); if (next !== v) { - let store = originalAttrs.get(el); if (!store) { store = new Map(); originalAttrs.set(el, store); } - if (!store.has(name)) store.set(name, v); + if (!ours) store.set(name, v); + if (!written) { + written = new Map(); + writtenAttrs.set(el, written); + } + written.set(name, next); modifiedAttrEls.add(el); el.setAttribute(name, next); } @@ -400,11 +419,13 @@ export function installStreamMasker(host: MaskerHost): void { for (const node of modifiedTextNodes) { const orig = originalText.get(node); if (orig != null && node.isConnected) node.data = orig; + writtenText.delete(node); } modifiedTextNodes.clear(); for (const el of modifiedAttrEls) { const store = originalAttrs.get(el); if (store) for (const [name, val] of store) el.setAttribute(name, val); + writtenAttrs.delete(el); } modifiedAttrEls.clear(); for (const el of redactedEls) el.removeAttribute(REDACT_ATTR); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d5d2fba..03a8ccc 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -604,7 +604,7 @@ export function registerIpcHandlers(): void { isActive: win.tabs.isActiveTab(t.id), isInternal: t.isInternal, })); - return runPreflight(tabs, flags, os.hostname(), cfg.customMasks); + return runPreflight(tabs, flags, os.hostname(), cfg.customMasks, cfg.customReplacements); }); // Doc-nonce protocol: a new document started masking / finished its initial diff --git a/src/main/stream-mode/frameGuard.ts b/src/main/stream-mode/frameGuard.ts index fe286f5..51f52a5 100644 --- a/src/main/stream-mode/frameGuard.ts +++ b/src/main/stream-mode/frameGuard.ts @@ -125,7 +125,7 @@ export function isMaskingActive(config: StreamModeConfig): boolean { maskMacAddresses: config.maskMacAddresses, maskInternalHostnames: config.maskInternalHostnames, }; - return config.enabled && hasAnyMask(flags, config.customMasks); + return config.enabled && hasAnyMask(flags, config.customMasks, config.customReplacements); } /** A frame with no real document yet: nothing to mask, and no leak to fear. */ diff --git a/src/shared/__tests__/maskPatterns.test.ts b/src/shared/__tests__/maskPatterns.test.ts index 23e5917..3f28ea9 100644 --- a/src/shared/__tests__/maskPatterns.test.ts +++ b/src/shared/__tests__/maskPatterns.test.ts @@ -117,6 +117,69 @@ describe('custom masks', () => { }); }); +describe('custom replacements', () => { + const NONE: MaskFlags = { + maskIPv4: false, + maskIPv6: false, + maskEmails: false, + maskPhones: false, + maskMacAddresses: false, + maskInternalHostnames: false, + }; + const rep = (s: string, pairs: { from: string; to: string }[], flags: MaskFlags = NONE) => + maskText(s, flags, null, [], pairs); + + it('shows the chosen text instead of each match, case-insensitively', () => { + expect(rep('Valentin et VALENTIN', [{ from: 'valentin', to: 'Val' }])).toBe('Val et Val'); + }); + it('accepts an empty replacement (erases the string)', () => { + expect(rep('Projet [Falcon]', [{ from: 'Falcon', to: '' }])).toBe('Projet []'); + }); + it('never re-scans its own output (`to` may contain `from`)', () => { + expect(rep('Val', [{ from: 'Val', to: 'Valentin' }])).toBe('Valentin'); + expect(rep('ab ab', [{ from: 'ab', to: 'abab' }])).toBe('abab abab'); + }); + it('prefers the longest match at the same position', () => { + const pairs = [ + { from: 'Jean', to: 'J.' }, + { from: 'Jean Dupont', to: 'Streamer' }, + ]; + expect(rep('Jean Dupont et Jean', pairs)).toBe('Streamer et J.'); + }); + it('first pair wins on a duplicate `from`', () => { + const pairs = [ + { from: 'acme', to: 'one' }, + { from: 'ACME', to: 'two' }, + ]; + expect(rep('Acme', pairs)).toBe('one'); + }); + it('escapes regex characters and trims `from`', () => { + expect(rep('a.b.c and axbxc', [{ from: ' a.b.c ', to: 'X' }])).toBe('X and axbxc'); + }); + it('ignores pairs whose `from` is shorter than 2 chars, and malformed entries', () => { + const junk = [{ from: 'a', to: 'z' }, null, { from: 42 }, { to: 'x' }] as unknown as { + from: string; + to: string; + }[]; + expect(rep('a b c', junk)).toBe('a b c'); + expect(rep('nas01', [{ from: 'nas01', to: undefined as unknown as string }])).toBe(''); + }); + it('runs before the built-in masks: a replaced IP shows the chosen text', () => { + expect(rep('ping 8.8.8.8', [{ from: '8.8.8.8', to: 'mon-serveur' }], ALL)).toBe( + 'ping mon-serveur', + ); + }); + it('still masks a replacement that is itself sensitive', () => { + const out = rep('contact: Bob', [{ from: 'Bob', to: 'bob@example.com' }], ALL); + expect(out).toBe(`contact: ${MASK.EMAIL}`); + }); + it('counts for hasAnyMask', () => { + expect(hasAnyMask(NONE, [], [{ from: 'acme', to: 'x' }])).toBe(true); + expect(hasAnyMask(NONE, [], [{ from: ' a ', to: 'x' }])).toBe(false); + expect(hasAnyMask(NONE, [], [])).toBe(false); + }); +}); + describe('internal hostname masking', () => { it('masks *.local and the machine hostname when enabled', () => { const out = maskText( diff --git a/src/shared/__tests__/streamConfig.test.ts b/src/shared/__tests__/streamConfig.test.ts index a18e65b..45a8f26 100644 --- a/src/shared/__tests__/streamConfig.test.ts +++ b/src/shared/__tests__/streamConfig.test.ts @@ -28,6 +28,18 @@ describe('sameMaskingConfig', () => { expect(sameMaskingConfig(base(), { ...base(), customMasks: ['ACME'] })).toBe(false); }); + it('compares customReplacements by value', () => { + const pairs = () => [{ from: 'acme', to: 'corp' }]; + const a = { ...base(), customReplacements: pairs() }; + // A fresh array of fresh objects (every settings load) is the same config. + expect(sameMaskingConfig(a, { ...base(), customReplacements: pairs() })).toBe(true); + expect(sameMaskingConfig(a, { ...a, color: '#00ff00' })).toBe(true); + expect( + sameMaskingConfig(a, { ...base(), customReplacements: [{ from: 'acme', to: 'other' }] }), + ).toBe(false); + expect(sameMaskingConfig(a, base())).toBe(false); + }); + it('fails closed on unknown future fields', () => { // A field added to StreamModeConfig but not to the cosmetic deny-list // must count as mask-relevant: frames get re-pushed, coverage re-checked. diff --git a/src/shared/i18n/en/stream.ts b/src/shared/i18n/en/stream.ts index 1ead252..32bae78 100644 --- a/src/shared/i18n/en/stream.ts +++ b/src/shared/i18n/en/stream.ts @@ -95,6 +95,24 @@ export const enStream: Record = { 'Retirer le mot-clé masqué': 'Remove the masked keyword', 'Retirer {text}': 'Remove {text}', + // StreamPage: custom replacements card + 'Remplacements personnalisés': 'Custom replacements', + 'Remplacez une chaîne par le texte de votre choix : un vrai nom par un pseudo, une adresse par « mon-serveur ».': + 'Replace a string with the text of your choice: a real name with a nickname, an address with "my-server".', + 'Texte à remplacer…': 'Text to replace…', + 'Texte à remplacer': 'Text to replace', + 'Texte affiché à la place…': 'Text shown instead…', + 'Texte affiché à la place': 'Text shown instead', + 'La recherche est insensible à la casse. Chaque correspondance est remplacée exactement par le texte choisi (laissez-le vide pour effacer la chaîne). Les remplacements passent avant les autres masques : si le texte affiché ressemble lui-même à un email ou une IP, il est masqué.': + 'Matching is case-insensitive. Every match is replaced with exactly the text you chose (leave it empty to erase the string). Replacements run before the other masks: if the text shown itself looks like an email or an IP, it is masked.', + 'Aucun remplacement pour le moment.': 'No replacements yet.', + '(vide)': '(empty)', + 'Retirer le remplacement masqué': 'Remove the masked replacement', + 'Supprimer {n} remplacement ?': 'Delete {n} replacement?', + 'Supprimer {n} remplacements ?': 'Delete {n} replacements?', + '{n} remplacement actif': '{n} active replacement', + '{n} remplacements actifs': '{n} active replacements', + // StreamPage: appearance (accent color) card 'Apparence': 'Appearance', 'La couleur qui signale le Mode Stream dans l’interface.': diff --git a/src/shared/maskPatterns.ts b/src/shared/maskPatterns.ts index a07e79f..2ef4457 100644 --- a/src/shared/maskPatterns.ts +++ b/src/shared/maskPatterns.ts @@ -144,17 +144,44 @@ function escapeRegExp(s: string): string { /** * Mask a single string. Stateless and identical across all three worlds. The * caller supplies which categories are active, the local machine hostname - * (for internal-hostname masking) and the user's custom substrings. + * (for internal-hostname masking), the user's custom substrings and their + * find → replace pairs. */ export function maskText( input: string, flags: MaskFlags, hostname: string | null, customMasks: readonly string[] = [], + customReplacements: readonly CustomReplacement[] = [], ): string { if (!input) return input; let out = input; + // First, on the raw text: the user's own wording replaces the secret before + // any built-in pattern runs, so `192.168.1.10` → `mon-serveur` shows the + // chosen text, not a placeholder. A replacement that itself looks like an + // email or an IP is still masked below: the safe outcome wins. + const replacements = activeReplacements(customReplacements); + if (replacements.length > 0) { + const byKey = new Map(); + for (const r of replacements) { + const key = r.from.toLowerCase(); + if (!byKey.has(key)) byKey.set(key, r.to); + } + // Longest first: `Jean Dupont` must win over `Jean` at the same position. + const alt = [...byKey.keys()] + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join('|'); + // One pass: a replacement is never re-scanned, so `to` may contain `from`. + out = out.replace( + new RegExp(alt, 'gi'), + // A match whose lower case differs from its key (rare Unicode folding) + // falls back to bullets: never show the original. + (m) => byKey.get(m.toLowerCase()) ?? '•'.repeat(Math.max(3, m.length)), + ); + } + if (flags.maskIPv4) { out = out.replace(IPV4_RE, (m) => (isPrivateIPv4(m) ? m : MASK.IPV4)); } @@ -190,8 +217,35 @@ export function maskText( return out; } -/** True if any masking category (or custom masks) would change output. */ -export function hasAnyMask(flags: MaskFlags, customMasks: readonly string[] = []): boolean { +/** + * A user-defined find → replace pair: every case-insensitive match of `from` + * is displayed as `to` (which may be empty). Like custom masks, `from` needs + * at least 2 characters once trimmed. + */ +export type CustomReplacement = { from: string; to: string }; + +/** + * The pairs that apply, `from` trimmed. Defensive about shape: the config + * comes from settings.json and an IPC patch, neither of which is validated. + */ +function activeReplacements(list: readonly CustomReplacement[]): CustomReplacement[] { + const out: CustomReplacement[] = []; + if (!Array.isArray(list)) return out; + for (const r of list) { + if (!r || typeof r.from !== 'string') continue; + const from = r.from.trim(); + if (from.length < 2) continue; + out.push({ from, to: typeof r.to === 'string' ? r.to : '' }); + } + return out; +} + +/** True if any masking category (or custom masks / replacements) would change output. */ +export function hasAnyMask( + flags: MaskFlags, + customMasks: readonly string[] = [], + customReplacements: readonly CustomReplacement[] = [], +): boolean { return ( flags.maskIPv4 || flags.maskIPv6 || @@ -199,6 +253,7 @@ export function hasAnyMask(flags: MaskFlags, customMasks: readonly string[] = [] flags.maskPhones || flags.maskMacAddresses || flags.maskInternalHostnames || - customMasks.some((s) => s.trim().length >= 2) + customMasks.some((s) => s.trim().length >= 2) || + activeReplacements(customReplacements).length > 0 ); } diff --git a/src/shared/preflight.ts b/src/shared/preflight.ts index 138faf2..ee71800 100644 --- a/src/shared/preflight.ts +++ b/src/shared/preflight.ts @@ -12,7 +12,7 @@ * Honest scope, stated in the UI: this audits VOKSA. It cannot see a Discord * window, a desktop notification, or the rest of the screen. */ -import { maskText, type MaskFlags } from './maskPatterns'; +import { maskText, type CustomReplacement, type MaskFlags } from './maskPatterns'; export type PreflightTab = { id: string; @@ -45,8 +45,9 @@ export type PreflightReport = { }; /** - * Scan the given tabs against the mask config. `flags`/`hostname`/`customMasks` - * are the SAME inputs the masker uses, so a finding here is precisely what + * Scan the given tabs against the mask config. `flags`, `hostname`, + * `customMasks` and `customReplacements` are the SAME inputs the masker uses, + * so a finding here is precisely what * Stream Mode would mask on the page. */ export function runPreflight( @@ -54,6 +55,7 @@ export function runPreflight( flags: MaskFlags, hostname: string | null, customMasks: readonly string[], + customReplacements: readonly CustomReplacement[] = [], ): PreflightReport { const findings: PreflightFinding[] = []; let scanned = 0; @@ -64,8 +66,8 @@ export function runPreflight( if (tab.isInternal) continue; scanned += 1; - const maskedTitle = maskText(tab.title, flags, hostname, customMasks); - const maskedUrl = maskText(tab.url, flags, hostname, customMasks); + const maskedTitle = maskText(tab.title, flags, hostname, customMasks, customReplacements); + const maskedUrl = maskText(tab.url, flags, hostname, customMasks, customReplacements); const titleHit = maskedTitle !== tab.title; const urlHit = maskedUrl !== tab.url; if (titleHit || urlHit) { diff --git a/src/shared/streamConfig.ts b/src/shared/streamConfig.ts index c6621af..2f7c7de 100644 --- a/src/shared/streamConfig.ts +++ b/src/shared/streamConfig.ts @@ -1,4 +1,5 @@ import { DEFAULT_STREAM_COLOR } from './streamColor'; +import type { CustomReplacement } from './maskPatterns'; export type StreamModeConfig = { enabled: boolean; @@ -44,6 +45,12 @@ export type StreamModeConfig = { * project name, a client name, an internal service URL, etc. */ customMasks: string[]; + /** + * User-defined find → replace pairs (case-insensitive): unlike customMasks, + * the user picks what is displayed instead (a pseudonym, a generic name). + * Applied before every other mask, see maskText. + */ + customReplacements: CustomReplacement[]; /** * Accent color of every Stream Mode surface in the chrome UI (window ring, * toolbar shield, badges): hex `#rrggbb`, the light and dark palettes are @@ -76,7 +83,9 @@ export function sameMaskingConfig(a: StreamModeConfig, b: StreamModeConfig): boo const va = (a as Record)[key]; const vb = (b as Record)[key]; if (Array.isArray(va) && Array.isArray(vb)) { - if (va.length !== vb.length || va.some((v, i) => v !== vb[i])) return false; + // Items may be objects (customReplacements), rebuilt on every settings + // load: compare by value, not reference. + if (va.length !== vb.length || JSON.stringify(va) !== JSON.stringify(vb)) return false; } else if (va !== vb) { return false; } @@ -102,6 +111,7 @@ export const DEFAULT_STREAM_CONFIG: StreamModeConfig = { hideHistory: true, autoStreamOnRecorder: true, customMasks: [], + customReplacements: [], color: DEFAULT_STREAM_COLOR, }; diff --git a/src/ui/components/pages/StreamPage.tsx b/src/ui/components/pages/StreamPage.tsx index e40e8b2..29a65b5 100644 --- a/src/ui/components/pages/StreamPage.tsx +++ b/src/ui/components/pages/StreamPage.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { + ArrowRight, Camera, Check, CheckCircle2, @@ -20,6 +21,7 @@ import { Pipette, Plus, Radio, + Replace, RotateCcw, Server, Shield, @@ -40,6 +42,7 @@ import { Toggle } from '../ui/Toggle'; import { askConfirm } from '../ui/ConfirmDialog'; import { useT } from '../../lib/i18n'; import type { StreamModeConfig } from '../../../shared/streamConfig'; +import type { CustomReplacement } from '../../../shared/maskPatterns'; import { DEFAULT_STREAM_COLOR, STREAM_COLOR_PRESETS, @@ -202,7 +205,7 @@ export function StreamPage(): React.ReactElement { void toggle()} /> @@ -251,6 +254,17 @@ export function StreamPage(): React.ReactElement { onChange={(list) => void update({ customMasks: list })} /> + + void update({ customReplacements: list })} + /> + void; +}): React.ReactElement { + const t = useT(); + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const fromRef = useRef(null); + + // Same 2-character floor as maskText: a shorter `from` would never apply. + const canAdd = from.trim().length >= 2; + + const add = () => { + const trimmed = from.trim(); + if (trimmed.length < 2) return; + const key = trimmed.toLowerCase(); + // Re-adding a `from` updates its replacement instead of stacking a + // duplicate that maskText would ignore anyway (first one wins). + const next = replacements.filter((r) => r.from.trim().toLowerCase() !== key); + onChange([...next, { from: trimmed, to }]); + setFrom(''); + setTo(''); + fromRef.current?.focus(); + }; + + const remove = (index: number) => { + const next = replacements.slice(); + next.splice(index, 1); + onChange(next); + }; + + const clear = async () => { + if (replacements.length === 0) return; + const ok = await askConfirm({ + title: + replacements.length > 1 + ? t('Supprimer {n} remplacements ?', { n: replacements.length }) + : t('Supprimer {n} remplacement ?', { n: replacements.length }), + confirmLabel: t('Supprimer'), + danger: true, + }); + if (ok) onChange([]); + }; + + const onEnter = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + add(); + } + }; + + const inputClass = + 'w-full h-10 px-3 rounded-lg bg-bg border border-border focus:border-accent focus:outline-none focus:shadow-[0_0_0_3px_rgba(59,125,255,0.12)] text-[13px] transition-all'; + + return ( +
+
+
+ setFrom(e.target.value)} + onKeyDown={onEnter} + placeholder={t('Texte à remplacer…')} + aria-label={t('Texte à remplacer')} + className={`${inputClass} sm:flex-1 ${concealed ? '[-webkit-text-security:disc]' : ''}`} + /> + + setTo(e.target.value)} + onKeyDown={onEnter} + placeholder={t('Texte affiché à la place…')} + aria-label={t('Texte affiché à la place')} + className={`${inputClass} sm:flex-1`} + /> + +
+

+ {t('La recherche est insensible à la casse. Chaque correspondance est remplacée exactement par le texte choisi (laissez-le vide pour effacer la chaîne). Les remplacements passent avant les autres masques : si le texte affiché ressemble lui-même à un email ou une IP, il est masqué.')} +

+
+ + {replacements.length === 0 ? ( +
+ {t('Aucun remplacement pour le moment.')} +
+ ) : ( + <> +
    + {replacements.map((r, i) => ( +
  • + + {concealed ? '•'.repeat(Math.max(3, r.from.length)) : r.from} + + + + {r.to ? r.to : {t('(vide)')}} + + +
  • + ))} +
+
+ + {replacements.length > 1 + ? t('{n} remplacements actifs', { n: replacements.length }) + : t('{n} remplacement actif', { n: replacements.length })} + + +
+ + )} +
+ ); +} + /** * Panic Key card: enable + rebind. The capture field records the NEXT chord * pressed while focused (the standard rebind pattern): modifiers + one real diff --git a/src/ui/lib/masking.ts b/src/ui/lib/masking.ts index 86df2a0..347dbe2 100644 --- a/src/ui/lib/masking.ts +++ b/src/ui/lib/masking.ts @@ -30,5 +30,5 @@ export function useMaskedText(text: string | null | undefined): string { maskMacAddresses: config.maskMacAddresses, maskInternalHostnames: config.maskInternalHostnames, }; - return maskText(text, flags, hostname, config.customMasks); + return maskText(text, flags, hostname, config.customMasks, config.customReplacements); } From b40ab45b7392f79ea16d311dbe8dab399882be2c Mon Sep 17 00:00:00 2001 From: Howmation Date: Fri, 30 Oct 2026 09:27:11 +0100 Subject: [PATCH 2/2] test(smoke): retry the window.open from the level 1 popup This was the one call on the popup that was not retried, and it failed once on macOS CI with "CDP socket closed (popup level 1)". Only CDP errors are retried: a popup that is really gone still fails on the deadline, and a call that landed before its socket closed is not repeated. Co-Authored-By: Claude Opus 5.5 --- scripts/smoke.mjs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 0079695..137779c 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -3005,10 +3005,24 @@ async function main() { }); // Level 2: the same, opened FROM the popup. This is the level that used to be - // a bare BrowserWindow (no preload, no guard). - const opened2 = await chain1.evaluate( - `!!window.open(${J(chain2Url)}, 'voksa-chain-2', 'popup,width=560,height=440')`, - { userGesture: true }, + // a bare BrowserWindow (no preload, no guard). Only CDP errors are retried + // (lived: this one-shot failed once on macOS CI with "CDP socket closed (popup + // level 1)"), so a level 1 popup that is really gone still fails on the + // deadline. A call that landed before its socket closed is not repeated: its + // window is listed by then. + const { value: opened2 } = await waitFor( + 'window.open from the popup', + async () => { + const listed = (await listTargets()).some((t) => t.type === 'page' && t.url === chain2Url); + if (listed) return { value: true }; + return { + value: await chain1.evaluate( + `!!window.open(${J(chain2Url)}, 'voksa-chain-2', 'popup,width=560,height=440')`, + { userGesture: true }, + ), + }; + }, + STEP_TIMEOUT_MS, ); if (opened2 !== true) throw new Error('window.open from a popup was refused (returned null)'); const chain2 = await connectPageTarget(