Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "voksa",
"productName": "Voksa",
"version": "0.7.0",
"version": "0.7.1",
"description": "Voksa",
"author": "Voksa",
"license": "GPL-3.0-only",
Expand Down
75 changes: 71 additions & 4 deletions scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)})`,
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 16 additions & 8 deletions src/injected/fallbackMask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/injected/hoverMask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 28 additions & 7 deletions src/injected/streamMask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ export function installStreamMasker(host: MaskerHost): void {
const originalAttrs = new WeakMap<Element, Map<string, string>>();
const modifiedAttrEls = new Set<Element>();
const redactedEls = new Set<Element>();
// 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<Text, string>();
const writtenAttrs = new WeakMap<Element, Map<string, string>>();
const shadowObserved = new WeakSet<ShadowRoot>();

// Iframe gate bookkeeping (see the header). `knownFrames` also covers frames
Expand All @@ -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 -----------------------------------------------------
Expand All @@ -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;
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/main/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/main/stream-mode/frameGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
63 changes: 63 additions & 0 deletions src/shared/__tests__/maskPatterns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions src/shared/__tests__/streamConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions src/shared/i18n/en/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,24 @@ export const enStream: Record<string, string> = {
'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.':
Expand Down
Loading
Loading