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..137779c 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)})`, @@ -2952,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( 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); }