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
1 change: 1 addition & 0 deletions infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// evault.ts imports these at runtime; mock them so the module loads in a plain
// Node test env without pulling Tauri / wallet-sdk internals.
vi.mock("wallet-sdk", () => ({ syncPublicKeyToEvault: vi.fn() }));
vi.mock("../../services/NotificationService", () => ({
default: {
getInstance: () => ({ registerDevice: vi.fn(async () => true) }),
},
}));

import { VaultController } from "./evault";

const VAULT = { ename: "user@w3id.example", uri: "https://evault.example" };

/** Build a VaultController whose only exercised dependency is `store.get`. */
function makeController(get: (key: string) => Promise<unknown>) {
const store = { get } as unknown as ConstructorParameters<
typeof VaultController
>[0];
const userController = {
user: Promise.resolve(undefined),
} as unknown as ConstructorParameters<typeof VaultController>[1];
return new VaultController(store, userController);
}

afterEach(() => {
vi.useRealTimers();
});

describe("VaultController.vault — #readVaultResilient retry behavior", () => {
it("returns immediately for a resolved undefined (genuine logout), no retry", async () => {
const get = vi.fn(async () => undefined);
const vc = makeController(get);

await expect(vc.vault).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(1); // no retry when the key is simply absent
});

it("retries thrown store errors up to maxAttempts, then returns undefined without throwing", async () => {
vi.useFakeTimers();
const get = vi.fn(async () => {
throw new Error(
"Fetch API cannot load ipc://localhost/plugin:store|get",
);
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync(); // skip the 200ms backoffs

await expect(result).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(10); // maxAttempts
});

it("recovers the vault when the store IPC comes back mid-retry", async () => {
vi.useFakeTimers();
let calls = 0;
const get = vi.fn(async () => {
if (calls++ < 6) throw new Error("IPC custom protocol failed");
return VAULT;
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync();

await expect(result).resolves.toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(7);
});

it("coalesces concurrent reads into a single in-flight store read", async () => {
const get = vi.fn(async () => VAULT);
const vc = makeController(get);

const [a, b] = await Promise.all([vc.vault, vc.vault]);

expect(a).toEqual(VAULT);
expect(b).toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(1); // both callers shared one read

// ...and a later, independent access performs a fresh read.
await vc.vault;
expect(get).toHaveBeenCalledTimes(2);
});
});
59 changes: 49 additions & 10 deletions infrastructure/eid-wallet/src/lib/global/controllers/evault.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -580,19 +580,58 @@ export class VaultController {
}
}

#vaultReadInFlight: Promise<Record<string, string> | undefined> | null =
null;

get vault() {
return this.#store
.get<Record<string, string>>("vault")
.then((vault) => {
if (!vault) {
// Coalesce concurrent reads so the resume window doesn't spin one
// retry loop per caller; cleared on settle so later reads are fresh.
if (this.#vaultReadInFlight) return this.#vaultReadInFlight;
const read = this.#readVaultResilient().finally(() => {
this.#vaultReadInFlight = null;
});
this.#vaultReadInFlight = read;
return read;
}

/**
* Read the persisted vault, distinguishing "no vault" (resolved `undefined`
* → genuine logout) from a transient store IPC failure (a throw).
*
* On iOS the `ipc://localhost` protocol is briefly torn down after the app
* resumes from background, so `plugin:store|get` throws. The old getter
* swallowed that into `undefined` — indistinguishable from "never logged
* in" — so route guards redirected to /login: the intermittent sign-out.
*
* We retry on THROW so the resume window passes before reporting "no vault".
* A genuine logout resolves `undefined` (no throw) and returns immediately.
* Still never throws, still returns `Record | undefined` — callers unchanged.
*/
async #readVaultResilient(): Promise<Record<string, string> | undefined> {
const maxAttempts = 10;
const retryDelayMs = 200; // ~2s total budget covers the IPC-dead window
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const vault =
await this.#store.get<Record<string, string>>("vault");
return vault ?? undefined;
} catch (error) {
if (attempt === maxAttempts) {
console.error(
"Failed to get vault after retries (store IPC unavailable):",
error,
);
return undefined;
}
return vault;
})
.catch((error) => {
console.error("Failed to get vault:", error);
return undefined;
});
console.warn(
`[VaultController] vault read failed (attempt ${attempt}/${maxAttempts}), store IPC likely re-initializing after resume — retrying...`,
);
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs),
);
}
}
return undefined;
}

// Getters for internal properties
Expand Down
6 changes: 6 additions & 0 deletions infrastructure/eid-wallet/src/test/env-static-public.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
// Test stub for SvelteKit's `$env/static/public` (aliased in vitest.config.ts).
// Real values are injected by SvelteKit at build time; tests only need the
// symbols to exist so modules that import them can load.
export const PUBLIC_EID_WALLET_TOKEN = "";
export const PUBLIC_PROVISIONER_URL = "";
export const PUBLIC_REGISTRY_URL = "";
20 changes: 20 additions & 0 deletions infrastructure/eid-wallet/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

// Dedicated, isolated test config — intentionally does NOT load the app's
// Svelte/Tauri/Tailwind plugins. Unit tests here run in a plain Node
// environment and stub the few runtime-only imports (see `$env` alias below;
// heavier deps are mocked per-spec with `vi.mock`).
export default defineConfig({
resolve: {
alias: {
"$env/static/public": fileURLToPath(
new URL("./src/test/env-static-public.ts", import.meta.url),
),
},
},
test: {
environment: "node",
include: ["src/**/*.spec.ts"],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
1 change: 1 addition & 0 deletions infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// evault.ts imports these at runtime; mock them so the module loads in a plain
// Node test env without pulling Tauri / wallet-sdk internals.
vi.mock("wallet-sdk", () => ({ syncPublicKeyToEvault: vi.fn() }));
vi.mock("../../services/NotificationService", () => ({
default: {
getInstance: () => ({ registerDevice: vi.fn(async () => true) }),
},
}));

import { VaultController } from "./evault";

const VAULT = { ename: "user@w3id.example", uri: "https://evault.example" };

/** Build a VaultController whose only exercised dependency is `store.get`. */
function makeController(get: (key: string) => Promise<unknown>) {
const store = { get } as unknown as ConstructorParameters<
typeof VaultController
>[0];
const userController = {
user: Promise.resolve(undefined),
} as unknown as ConstructorParameters<typeof VaultController>[1];
return new VaultController(store, userController);
}

afterEach(() => {
vi.useRealTimers();
});

describe("VaultController.vault — #readVaultResilient retry behavior", () => {
it("returns immediately for a resolved undefined (genuine logout), no retry", async () => {
const get = vi.fn(async () => undefined);
const vc = makeController(get);

await expect(vc.vault).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(1); // no retry when the key is simply absent
});

it("retries thrown store errors up to maxAttempts, then returns undefined without throwing", async () => {
vi.useFakeTimers();
const get = vi.fn(async () => {
throw new Error(
"Fetch API cannot load ipc://localhost/plugin:store|get",
);
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync(); // skip the 200ms backoffs

await expect(result).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(10); // maxAttempts
});

it("recovers the vault when the store IPC comes back mid-retry", async () => {
vi.useFakeTimers();
let calls = 0;
const get = vi.fn(async () => {
if (calls++ < 6) throw new Error("IPC custom protocol failed");
return VAULT;
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync();

await expect(result).resolves.toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(7);
});

it("coalesces concurrent reads into a single in-flight store read", async () => {
const get = vi.fn(async () => VAULT);
const vc = makeController(get);

const [a, b] = await Promise.all([vc.vault, vc.vault]);

expect(a).toEqual(VAULT);
expect(b).toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(1); // both callers shared one read

// ...and a later, independent access performs a fresh read.
await vc.vault;
expect(get).toHaveBeenCalledTimes(2);
});
});
59 changes: 49 additions & 10 deletions infrastructure/eid-wallet/src/lib/global/controllers/evault.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -580,19 +580,58 @@ export class VaultController {
}
}

#vaultReadInFlight: Promise<Record<string, string> | undefined> | null =
null;

get vault() {
return this.#store
.get<Record<string, string>>("vault")
.then((vault) => {
if (!vault) {
// Coalesce concurrent reads so the resume window doesn't spin one
// retry loop per caller; cleared on settle so later reads are fresh.
if (this.#vaultReadInFlight) return this.#vaultReadInFlight;
const read = this.#readVaultResilient().finally(() => {
this.#vaultReadInFlight = null;
});
this.#vaultReadInFlight = read;
return read;
}

/**
* Read the persisted vault, distinguishing "no vault" (resolved `undefined`
* → genuine logout) from a transient store IPC failure (a throw).
*
* On iOS the `ipc://localhost` protocol is briefly torn down after the app
* resumes from background, so `plugin:store|get` throws. The old getter
* swallowed that into `undefined` — indistinguishable from "never logged
* in" — so route guards redirected to /login: the intermittent sign-out.
*
* We retry on THROW so the resume window passes before reporting "no vault".
* A genuine logout resolves `undefined` (no throw) and returns immediately.
* Still never throws, still returns `Record | undefined` — callers unchanged.
*/
async #readVaultResilient(): Promise<Record<string, string> | undefined> {
const maxAttempts = 10;
const retryDelayMs = 200; // ~2s total budget covers the IPC-dead window
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const vault =
await this.#store.get<Record<string, string>>("vault");
return vault ?? undefined;
} catch (error) {
if (attempt === maxAttempts) {
console.error(
"Failed to get vault after retries (store IPC unavailable):",
error,
);
return undefined;
}
return vault;
})
.catch((error) => {
console.error("Failed to get vault:", error);
return undefined;
});
console.warn(
`[VaultController] vault read failed (attempt ${attempt}/${maxAttempts}), store IPC likely re-initializing after resume — retrying...`,
);
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs),
);
}
}
return undefined;
}

// Getters for internal properties
Expand Down
6 changes: 6 additions & 0 deletions infrastructure/eid-wallet/src/test/env-static-public.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
// Test stub for SvelteKit's `$env/static/public` (aliased in vitest.config.ts).
// Real values are injected by SvelteKit at build time; tests only need the
// symbols to exist so modules that import them can load.
export const PUBLIC_EID_WALLET_TOKEN = "";
export const PUBLIC_PROVISIONER_URL = "";
export const PUBLIC_REGISTRY_URL = "";
20 changes: 20 additions & 0 deletions infrastructure/eid-wallet/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

// Dedicated, isolated test config — intentionally does NOT load the app's
// Svelte/Tauri/Tailwind plugins. Unit tests here run in a plain Node
// environment and stub the few runtime-only imports (see `$env` alias below;
// heavier deps are mocked per-spec with `vi.mock`).
export default defineConfig({
resolve: {
alias: {
"$env/static/public": fileURLToPath(
new URL("./src/test/env-static-public.ts", import.meta.url),
),
},
},
test: {
environment: "node",
include: ["src/**/*.spec.ts"],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// evault.ts imports these at runtime; mock them so the module loads in a plain
// Node test env without pulling Tauri / wallet-sdk internals.
vi.mock("wallet-sdk", () => ({ syncPublicKeyToEvault: vi.fn() }));
vi.mock("../../services/NotificationService", () => ({
default: {
getInstance: () => ({ registerDevice: vi.fn(async () => true) }),
},
}));

import { VaultController } from "./evault";

const VAULT = { ename: "user@w3id.example", uri: "https://evault.example" };

/** Build a VaultController whose only exercised dependency is `store.get`. */
function makeController(get: (key: string) => Promise<unknown>) {
const store = { get } as unknown as ConstructorParameters<
typeof VaultController
>[0];
const userController = {
user: Promise.resolve(undefined),
} as unknown as ConstructorParameters<typeof VaultController>[1];
return new VaultController(store, userController);
}

afterEach(() => {
vi.useRealTimers();
});

describe("VaultController.vault — #readVaultResilient retry behavior", () => {
it("returns immediately for a resolved undefined (genuine logout), no retry", async () => {
const get = vi.fn(async () => undefined);
const vc = makeController(get);

await expect(vc.vault).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(1); // no retry when the key is simply absent
});

it("retries thrown store errors up to maxAttempts, then returns undefined without throwing", async () => {
vi.useFakeTimers();
const get = vi.fn(async () => {
throw new Error(
"Fetch API cannot load ipc://localhost/plugin:store|get",
);
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync(); // skip the 200ms backoffs

await expect(result).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(10); // maxAttempts
});

it("recovers the vault when the store IPC comes back mid-retry", async () => {
vi.useFakeTimers();
let calls = 0;
const get = vi.fn(async () => {
if (calls++ < 6) throw new Error("IPC custom protocol failed");
return VAULT;
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync();

await expect(result).resolves.toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(7);
});

it("coalesces concurrent reads into a single in-flight store read", async () => {
const get = vi.fn(async () => VAULT);
const vc = makeController(get);

const [a, b] = await Promise.all([vc.vault, vc.vault]);

expect(a).toEqual(VAULT);
expect(b).toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(1); // both callers shared one read

// ...and a later, independent access performs a fresh read.
await vc.vault;
expect(get).toHaveBeenCalledTimes(2);
});
});
59 changes: 49 additions & 10 deletions infrastructure/eid-wallet/src/lib/global/controllers/evault.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -580,19 +580,58 @@ export class VaultController {
}
}

#vaultReadInFlight: Promise<Record<string, string> | undefined> | null =
null;

get vault() {
return this.#store
.get<Record<string, string>>("vault")
.then((vault) => {
if (!vault) {
// Coalesce concurrent reads so the resume window doesn't spin one
// retry loop per caller; cleared on settle so later reads are fresh.
if (this.#vaultReadInFlight) return this.#vaultReadInFlight;
const read = this.#readVaultResilient().finally(() => {
this.#vaultReadInFlight = null;
});
this.#vaultReadInFlight = read;
return read;
}

/**
* Read the persisted vault, distinguishing "no vault" (resolved `undefined`
* → genuine logout) from a transient store IPC failure (a throw).
*
* On iOS the `ipc://localhost` protocol is briefly torn down after the app
* resumes from background, so `plugin:store|get` throws. The old getter
* swallowed that into `undefined` — indistinguishable from "never logged
* in" — so route guards redirected to /login: the intermittent sign-out.
*
* We retry on THROW so the resume window passes before reporting "no vault".
* A genuine logout resolves `undefined` (no throw) and returns immediately.
* Still never throws, still returns `Record | undefined` — callers unchanged.
*/
async #readVaultResilient(): Promise<Record<string, string> | undefined> {
const maxAttempts = 10;
const retryDelayMs = 200; // ~2s total budget covers the IPC-dead window
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const vault =
await this.#store.get<Record<string, string>>("vault");
return vault ?? undefined;
} catch (error) {
if (attempt === maxAttempts) {
console.error(
"Failed to get vault after retries (store IPC unavailable):",
error,
);
return undefined;
}
return vault;
})
.catch((error) => {
console.error("Failed to get vault:", error);
return undefined;
});
console.warn(
`[VaultController] vault read failed (attempt ${attempt}/${maxAttempts}), store IPC likely re-initializing after resume — retrying...`,
);
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs),
);
}
}
return undefined;
}

// Getters for internal properties
Expand Down
6 changes: 6 additions & 0 deletions infrastructure/eid-wallet/src/test/env-static-public.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
// Test stub for SvelteKit's `$env/static/public` (aliased in vitest.config.ts).
// Real values are injected by SvelteKit at build time; tests only need the
// symbols to exist so modules that import them can load.
export const PUBLIC_EID_WALLET_TOKEN = "";
export const PUBLIC_PROVISIONER_URL = "";
export const PUBLIC_REGISTRY_URL = "";
20 changes: 20 additions & 0 deletions infrastructure/eid-wallet/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

// Dedicated, isolated test config — intentionally does NOT load the app's
// Svelte/Tauri/Tailwind plugins. Unit tests here run in a plain Node
// environment and stub the few runtime-only imports (see `$env` alias below;
// heavier deps are mocked per-spec with `vi.mock`).
export default defineConfig({
resolve: {
alias: {
"$env/static/public": fileURLToPath(
new URL("./src/test/env-static-public.ts", import.meta.url),
),
},
},
test: {
environment: "node",
include: ["src/**/*.spec.ts"],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// evault.ts imports these at runtime; mock them so the module loads in a plain
// Node test env without pulling Tauri / wallet-sdk internals.
vi.mock("wallet-sdk", () => ({ syncPublicKeyToEvault: vi.fn() }));
vi.mock("../../services/NotificationService", () => ({
default: {
getInstance: () => ({ registerDevice: vi.fn(async () => true) }),
},
}));

import { VaultController } from "./evault";

const VAULT = { ename: "user@w3id.example", uri: "https://evault.example" };

/** Build a VaultController whose only exercised dependency is `store.get`. */
function makeController(get: (key: string) => Promise<unknown>) {
const store = { get } as unknown as ConstructorParameters<
typeof VaultController
>[0];
const userController = {
user: Promise.resolve(undefined),
} as unknown as ConstructorParameters<typeof VaultController>[1];
return new VaultController(store, userController);
}

afterEach(() => {
vi.useRealTimers();
});

describe("VaultController.vault — #readVaultResilient retry behavior", () => {
it("returns immediately for a resolved undefined (genuine logout), no retry", async () => {
const get = vi.fn(async () => undefined);
const vc = makeController(get);

await expect(vc.vault).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(1); // no retry when the key is simply absent
});

it("retries thrown store errors up to maxAttempts, then returns undefined without throwing", async () => {
vi.useFakeTimers();
const get = vi.fn(async () => {
throw new Error(
"Fetch API cannot load ipc://localhost/plugin:store|get",
);
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync(); // skip the 200ms backoffs

await expect(result).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(10); // maxAttempts
});

it("recovers the vault when the store IPC comes back mid-retry", async () => {
vi.useFakeTimers();
let calls = 0;
const get = vi.fn(async () => {
if (calls++ < 6) throw new Error("IPC custom protocol failed");
return VAULT;
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync();

await expect(result).resolves.toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(7);
});

it("coalesces concurrent reads into a single in-flight store read", async () => {
const get = vi.fn(async () => VAULT);
const vc = makeController(get);

const [a, b] = await Promise.all([vc.vault, vc.vault]);

expect(a).toEqual(VAULT);
expect(b).toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(1); // both callers shared one read

// ...and a later, independent access performs a fresh read.
await vc.vault;
expect(get).toHaveBeenCalledTimes(2);
});
});
59 changes: 49 additions & 10 deletions infrastructure/eid-wallet/src/lib/global/controllers/evault.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -580,19 +580,58 @@ export class VaultController {
}
}

#vaultReadInFlight: Promise<Record<string, string> | undefined> | null =
null;

get vault() {
return this.#store
.get<Record<string, string>>("vault")
.then((vault) => {
if (!vault) {
// Coalesce concurrent reads so the resume window doesn't spin one
// retry loop per caller; cleared on settle so later reads are fresh.
if (this.#vaultReadInFlight) return this.#vaultReadInFlight;
const read = this.#readVaultResilient().finally(() => {
this.#vaultReadInFlight = null;
});
this.#vaultReadInFlight = read;
return read;
}

/**
* Read the persisted vault, distinguishing "no vault" (resolved `undefined`
* → genuine logout) from a transient store IPC failure (a throw).
*
* On iOS the `ipc://localhost` protocol is briefly torn down after the app
* resumes from background, so `plugin:store|get` throws. The old getter
* swallowed that into `undefined` — indistinguishable from "never logged
* in" — so route guards redirected to /login: the intermittent sign-out.
*
* We retry on THROW so the resume window passes before reporting "no vault".
* A genuine logout resolves `undefined` (no throw) and returns immediately.
* Still never throws, still returns `Record | undefined` — callers unchanged.
*/
async #readVaultResilient(): Promise<Record<string, string> | undefined> {
const maxAttempts = 10;
const retryDelayMs = 200; // ~2s total budget covers the IPC-dead window
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const vault =
await this.#store.get<Record<string, string>>("vault");
return vault ?? undefined;
} catch (error) {
if (attempt === maxAttempts) {
console.error(
"Failed to get vault after retries (store IPC unavailable):",
error,
);
return undefined;
}
return vault;
})
.catch((error) => {
console.error("Failed to get vault:", error);
return undefined;
});
console.warn(
`[VaultController] vault read failed (attempt ${attempt}/${maxAttempts}), store IPC likely re-initializing after resume — retrying...`,
);
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs),
);
}
}
return undefined;
}

// Getters for internal properties
Expand Down
6 changes: 6 additions & 0 deletions infrastructure/eid-wallet/src/test/env-static-public.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
// Test stub for SvelteKit's `$env/static/public` (aliased in vitest.config.ts).
// Real values are injected by SvelteKit at build time; tests only need the
// symbols to exist so modules that import them can load.
export const PUBLIC_EID_WALLET_TOKEN = "";
export const PUBLIC_PROVISIONER_URL = "";
export const PUBLIC_REGISTRY_URL = "";
20 changes: 20 additions & 0 deletions infrastructure/eid-wallet/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

// Dedicated, isolated test config — intentionally does NOT load the app's
// Svelte/Tauri/Tailwind plugins. Unit tests here run in a plain Node
// environment and stub the few runtime-only imports (see `$env` alias below;
// heavier deps are mocked per-spec with `vi.mock`).
export default defineConfig({
resolve: {
alias: {
"$env/static/public": fileURLToPath(
new URL("./src/test/env-static-public.ts", import.meta.url),
),
},
},
test: {
environment: "node",
include: ["src/**/*.spec.ts"],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
1 change: 1 addition & 0 deletions infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// evault.ts imports these at runtime; mock them so the module loads in a plain
// Node test env without pulling Tauri / wallet-sdk internals.
vi.mock("wallet-sdk", () => ({ syncPublicKeyToEvault: vi.fn() }));
vi.mock("../../services/NotificationService", () => ({
default: {
getInstance: () => ({ registerDevice: vi.fn(async () => true) }),
},
}));

import { VaultController } from "./evault";

const VAULT = { ename: "user@w3id.example", uri: "https://evault.example" };

/** Build a VaultController whose only exercised dependency is `store.get`. */
function makeController(get: (key: string) => Promise<unknown>) {
const store = { get } as unknown as ConstructorParameters<
typeof VaultController
>[0];
const userController = {
user: Promise.resolve(undefined),
} as unknown as ConstructorParameters<typeof VaultController>[1];
return new VaultController(store, userController);
}

afterEach(() => {
vi.useRealTimers();
});

describe("VaultController.vault — #readVaultResilient retry behavior", () => {
it("returns immediately for a resolved undefined (genuine logout), no retry", async () => {
const get = vi.fn(async () => undefined);
const vc = makeController(get);

await expect(vc.vault).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(1); // no retry when the key is simply absent
});

it("retries thrown store errors up to maxAttempts, then returns undefined without throwing", async () => {
vi.useFakeTimers();
const get = vi.fn(async () => {
throw new Error(
"Fetch API cannot load ipc://localhost/plugin:store|get",
);
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync(); // skip the 200ms backoffs

await expect(result).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(10); // maxAttempts
});

it("recovers the vault when the store IPC comes back mid-retry", async () => {
vi.useFakeTimers();
let calls = 0;
const get = vi.fn(async () => {
if (calls++ < 6) throw new Error("IPC custom protocol failed");
return VAULT;
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync();

await expect(result).resolves.toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(7);
});

it("coalesces concurrent reads into a single in-flight store read", async () => {
const get = vi.fn(async () => VAULT);
const vc = makeController(get);

const [a, b] = await Promise.all([vc.vault, vc.vault]);

expect(a).toEqual(VAULT);
expect(b).toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(1); // both callers shared one read

// ...and a later, independent access performs a fresh read.
await vc.vault;
expect(get).toHaveBeenCalledTimes(2);
});
});
59 changes: 49 additions & 10 deletions infrastructure/eid-wallet/src/lib/global/controllers/evault.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -580,19 +580,58 @@ export class VaultController {
}
}

#vaultReadInFlight: Promise<Record<string, string> | undefined> | null =
null;

get vault() {
return this.#store
.get<Record<string, string>>("vault")
.then((vault) => {
if (!vault) {
// Coalesce concurrent reads so the resume window doesn't spin one
// retry loop per caller; cleared on settle so later reads are fresh.
if (this.#vaultReadInFlight) return this.#vaultReadInFlight;
const read = this.#readVaultResilient().finally(() => {
this.#vaultReadInFlight = null;
});
this.#vaultReadInFlight = read;
return read;
}

/**
* Read the persisted vault, distinguishing "no vault" (resolved `undefined`
* → genuine logout) from a transient store IPC failure (a throw).
*
* On iOS the `ipc://localhost` protocol is briefly torn down after the app
* resumes from background, so `plugin:store|get` throws. The old getter
* swallowed that into `undefined` — indistinguishable from "never logged
* in" — so route guards redirected to /login: the intermittent sign-out.
*
* We retry on THROW so the resume window passes before reporting "no vault".
* A genuine logout resolves `undefined` (no throw) and returns immediately.
* Still never throws, still returns `Record | undefined` — callers unchanged.
*/
async #readVaultResilient(): Promise<Record<string, string> | undefined> {
const maxAttempts = 10;
const retryDelayMs = 200; // ~2s total budget covers the IPC-dead window
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const vault =
await this.#store.get<Record<string, string>>("vault");
return vault ?? undefined;
} catch (error) {
if (attempt === maxAttempts) {
console.error(
"Failed to get vault after retries (store IPC unavailable):",
error,
);
return undefined;
}
return vault;
})
.catch((error) => {
console.error("Failed to get vault:", error);
return undefined;
});
console.warn(
`[VaultController] vault read failed (attempt ${attempt}/${maxAttempts}), store IPC likely re-initializing after resume — retrying...`,
);
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs),
);
}
}
return undefined;
}

// Getters for internal properties
Expand Down
6 changes: 6 additions & 0 deletions infrastructure/eid-wallet/src/test/env-static-public.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
// Test stub for SvelteKit's `$env/static/public` (aliased in vitest.config.ts).
// Real values are injected by SvelteKit at build time; tests only need the
// symbols to exist so modules that import them can load.
export const PUBLIC_EID_WALLET_TOKEN = "";
export const PUBLIC_PROVISIONER_URL = "";
export const PUBLIC_REGISTRY_URL = "";
20 changes: 20 additions & 0 deletions infrastructure/eid-wallet/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

// Dedicated, isolated test config — intentionally does NOT load the app's
// Svelte/Tauri/Tailwind plugins. Unit tests here run in a plain Node
// environment and stub the few runtime-only imports (see `$env` alias below;
// heavier deps are mocked per-spec with `vi.mock`).
export default defineConfig({
resolve: {
alias: {
"$env/static/public": fileURLToPath(
new URL("./src/test/env-static-public.ts", import.meta.url),
),
},
},
test: {
environment: "node",
include: ["src/**/*.spec.ts"],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// evault.ts imports these at runtime; mock them so the module loads in a plain
// Node test env without pulling Tauri / wallet-sdk internals.
vi.mock("wallet-sdk", () => ({ syncPublicKeyToEvault: vi.fn() }));
vi.mock("../../services/NotificationService", () => ({
default: {
getInstance: () => ({ registerDevice: vi.fn(async () => true) }),
},
}));

import { VaultController } from "./evault";

const VAULT = { ename: "user@w3id.example", uri: "https://evault.example" };

/** Build a VaultController whose only exercised dependency is `store.get`. */
function makeController(get: (key: string) => Promise<unknown>) {
const store = { get } as unknown as ConstructorParameters<
typeof VaultController
>[0];
const userController = {
user: Promise.resolve(undefined),
} as unknown as ConstructorParameters<typeof VaultController>[1];
return new VaultController(store, userController);
}

afterEach(() => {
vi.useRealTimers();
});

describe("VaultController.vault — #readVaultResilient retry behavior", () => {
it("returns immediately for a resolved undefined (genuine logout), no retry", async () => {
const get = vi.fn(async () => undefined);
const vc = makeController(get);

await expect(vc.vault).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(1); // no retry when the key is simply absent
});

it("retries thrown store errors up to maxAttempts, then returns undefined without throwing", async () => {
vi.useFakeTimers();
const get = vi.fn(async () => {
throw new Error(
"Fetch API cannot load ipc://localhost/plugin:store|get",
);
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync(); // skip the 200ms backoffs

await expect(result).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(10); // maxAttempts
});

it("recovers the vault when the store IPC comes back mid-retry", async () => {
vi.useFakeTimers();
let calls = 0;
const get = vi.fn(async () => {
if (calls++ < 6) throw new Error("IPC custom protocol failed");
return VAULT;
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync();

await expect(result).resolves.toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(7);
});

it("coalesces concurrent reads into a single in-flight store read", async () => {
const get = vi.fn(async () => VAULT);
const vc = makeController(get);

const [a, b] = await Promise.all([vc.vault, vc.vault]);

expect(a).toEqual(VAULT);
expect(b).toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(1); // both callers shared one read

// ...and a later, independent access performs a fresh read.
await vc.vault;
expect(get).toHaveBeenCalledTimes(2);
});
});
59 changes: 49 additions & 10 deletions infrastructure/eid-wallet/src/lib/global/controllers/evault.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -580,19 +580,58 @@ export class VaultController {
}
}

#vaultReadInFlight: Promise<Record<string, string> | undefined> | null =
null;

get vault() {
return this.#store
.get<Record<string, string>>("vault")
.then((vault) => {
if (!vault) {
// Coalesce concurrent reads so the resume window doesn't spin one
// retry loop per caller; cleared on settle so later reads are fresh.
if (this.#vaultReadInFlight) return this.#vaultReadInFlight;
const read = this.#readVaultResilient().finally(() => {
this.#vaultReadInFlight = null;
});
this.#vaultReadInFlight = read;
return read;
}

/**
* Read the persisted vault, distinguishing "no vault" (resolved `undefined`
* → genuine logout) from a transient store IPC failure (a throw).
*
* On iOS the `ipc://localhost` protocol is briefly torn down after the app
* resumes from background, so `plugin:store|get` throws. The old getter
* swallowed that into `undefined` — indistinguishable from "never logged
* in" — so route guards redirected to /login: the intermittent sign-out.
*
* We retry on THROW so the resume window passes before reporting "no vault".
* A genuine logout resolves `undefined` (no throw) and returns immediately.
* Still never throws, still returns `Record | undefined` — callers unchanged.
*/
async #readVaultResilient(): Promise<Record<string, string> | undefined> {
const maxAttempts = 10;
const retryDelayMs = 200; // ~2s total budget covers the IPC-dead window
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const vault =
await this.#store.get<Record<string, string>>("vault");
return vault ?? undefined;
} catch (error) {
if (attempt === maxAttempts) {
console.error(
"Failed to get vault after retries (store IPC unavailable):",
error,
);
return undefined;
}
return vault;
})
.catch((error) => {
console.error("Failed to get vault:", error);
return undefined;
});
console.warn(
`[VaultController] vault read failed (attempt ${attempt}/${maxAttempts}), store IPC likely re-initializing after resume — retrying...`,
);
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs),
);
}
}
return undefined;
}

// Getters for internal properties
Expand Down
6 changes: 6 additions & 0 deletions infrastructure/eid-wallet/src/test/env-static-public.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
// Test stub for SvelteKit's `$env/static/public` (aliased in vitest.config.ts).
// Real values are injected by SvelteKit at build time; tests only need the
// symbols to exist so modules that import them can load.
export const PUBLIC_EID_WALLET_TOKEN = "";
export const PUBLIC_PROVISIONER_URL = "";
export const PUBLIC_REGISTRY_URL = "";
20 changes: 20 additions & 0 deletions infrastructure/eid-wallet/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

// Dedicated, isolated test config — intentionally does NOT load the app's
// Svelte/Tauri/Tailwind plugins. Unit tests here run in a plain Node
// environment and stub the few runtime-only imports (see `$env` alias below;
// heavier deps are mocked per-spec with `vi.mock`).
export default defineConfig({
resolve: {
alias: {
"$env/static/public": fileURLToPath(
new URL("./src/test/env-static-public.ts", import.meta.url),
),
},
},
test: {
environment: "node",
include: ["src/**/*.spec.ts"],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// evault.ts imports these at runtime; mock them so the module loads in a plain
// Node test env without pulling Tauri / wallet-sdk internals.
vi.mock("wallet-sdk", () => ({ syncPublicKeyToEvault: vi.fn() }));
vi.mock("../../services/NotificationService", () => ({
default: {
getInstance: () => ({ registerDevice: vi.fn(async () => true) }),
},
}));

import { VaultController } from "./evault";

const VAULT = { ename: "user@w3id.example", uri: "https://evault.example" };

/** Build a VaultController whose only exercised dependency is `store.get`. */
function makeController(get: (key: string) => Promise<unknown>) {
const store = { get } as unknown as ConstructorParameters<
typeof VaultController
>[0];
const userController = {
user: Promise.resolve(undefined),
} as unknown as ConstructorParameters<typeof VaultController>[1];
return new VaultController(store, userController);
}

afterEach(() => {
vi.useRealTimers();
});

describe("VaultController.vault — #readVaultResilient retry behavior", () => {
it("returns immediately for a resolved undefined (genuine logout), no retry", async () => {
const get = vi.fn(async () => undefined);
const vc = makeController(get);

await expect(vc.vault).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(1); // no retry when the key is simply absent
});

it("retries thrown store errors up to maxAttempts, then returns undefined without throwing", async () => {
vi.useFakeTimers();
const get = vi.fn(async () => {
throw new Error(
"Fetch API cannot load ipc://localhost/plugin:store|get",
);
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync(); // skip the 200ms backoffs

await expect(result).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(10); // maxAttempts
});

it("recovers the vault when the store IPC comes back mid-retry", async () => {
vi.useFakeTimers();
let calls = 0;
const get = vi.fn(async () => {
if (calls++ < 6) throw new Error("IPC custom protocol failed");
return VAULT;
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync();

await expect(result).resolves.toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(7);
});

it("coalesces concurrent reads into a single in-flight store read", async () => {
const get = vi.fn(async () => VAULT);
const vc = makeController(get);

const [a, b] = await Promise.all([vc.vault, vc.vault]);

expect(a).toEqual(VAULT);
expect(b).toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(1); // both callers shared one read

// ...and a later, independent access performs a fresh read.
await vc.vault;
expect(get).toHaveBeenCalledTimes(2);
});
});
59 changes: 49 additions & 10 deletions infrastructure/eid-wallet/src/lib/global/controllers/evault.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -580,19 +580,58 @@ export class VaultController {
}
}

#vaultReadInFlight: Promise<Record<string, string> | undefined> | null =
null;

get vault() {
return this.#store
.get<Record<string, string>>("vault")
.then((vault) => {
if (!vault) {
// Coalesce concurrent reads so the resume window doesn't spin one
// retry loop per caller; cleared on settle so later reads are fresh.
if (this.#vaultReadInFlight) return this.#vaultReadInFlight;
const read = this.#readVaultResilient().finally(() => {
this.#vaultReadInFlight = null;
});
this.#vaultReadInFlight = read;
return read;
}

/**
* Read the persisted vault, distinguishing "no vault" (resolved `undefined`
* → genuine logout) from a transient store IPC failure (a throw).
*
* On iOS the `ipc://localhost` protocol is briefly torn down after the app
* resumes from background, so `plugin:store|get` throws. The old getter
* swallowed that into `undefined` — indistinguishable from "never logged
* in" — so route guards redirected to /login: the intermittent sign-out.
*
* We retry on THROW so the resume window passes before reporting "no vault".
* A genuine logout resolves `undefined` (no throw) and returns immediately.
* Still never throws, still returns `Record | undefined` — callers unchanged.
*/
async #readVaultResilient(): Promise<Record<string, string> | undefined> {
const maxAttempts = 10;
const retryDelayMs = 200; // ~2s total budget covers the IPC-dead window
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const vault =
await this.#store.get<Record<string, string>>("vault");
return vault ?? undefined;
} catch (error) {
if (attempt === maxAttempts) {
console.error(
"Failed to get vault after retries (store IPC unavailable):",
error,
);
return undefined;
}
return vault;
})
.catch((error) => {
console.error("Failed to get vault:", error);
return undefined;
});
console.warn(
`[VaultController] vault read failed (attempt ${attempt}/${maxAttempts}), store IPC likely re-initializing after resume — retrying...`,
);
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs),
);
}
}
return undefined;
}

// Getters for internal properties
Expand Down
6 changes: 6 additions & 0 deletions infrastructure/eid-wallet/src/test/env-static-public.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
// Test stub for SvelteKit's `$env/static/public` (aliased in vitest.config.ts).
// Real values are injected by SvelteKit at build time; tests only need the
// symbols to exist so modules that import them can load.
export const PUBLIC_EID_WALLET_TOKEN = "";
export const PUBLIC_PROVISIONER_URL = "";
export const PUBLIC_REGISTRY_URL = "";
20 changes: 20 additions & 0 deletions infrastructure/eid-wallet/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

// Dedicated, isolated test config — intentionally does NOT load the app's
// Svelte/Tauri/Tailwind plugins. Unit tests here run in a plain Node
// environment and stub the few runtime-only imports (see `$env` alias below;
// heavier deps are mocked per-spec with `vi.mock`).
export default defineConfig({
resolve: {
alias: {
"$env/static/public": fileURLToPath(
new URL("./src/test/env-static-public.ts", import.meta.url),
),
},
},
test: {
environment: "node",
include: ["src/**/*.spec.ts"],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
1 change: 1 addition & 0 deletions infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// evault.ts imports these at runtime; mock them so the module loads in a plain
// Node test env without pulling Tauri / wallet-sdk internals.
vi.mock("wallet-sdk", () => ({ syncPublicKeyToEvault: vi.fn() }));
vi.mock("../../services/NotificationService", () => ({
default: {
getInstance: () => ({ registerDevice: vi.fn(async () => true) }),
},
}));

import { VaultController } from "./evault";

const VAULT = { ename: "user@w3id.example", uri: "https://evault.example" };

/** Build a VaultController whose only exercised dependency is `store.get`. */
function makeController(get: (key: string) => Promise<unknown>) {
const store = { get } as unknown as ConstructorParameters<
typeof VaultController
>[0];
const userController = {
user: Promise.resolve(undefined),
} as unknown as ConstructorParameters<typeof VaultController>[1];
return new VaultController(store, userController);
}

afterEach(() => {
vi.useRealTimers();
});

describe("VaultController.vault — #readVaultResilient retry behavior", () => {
it("returns immediately for a resolved undefined (genuine logout), no retry", async () => {
const get = vi.fn(async () => undefined);
const vc = makeController(get);

await expect(vc.vault).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(1); // no retry when the key is simply absent
});

it("retries thrown store errors up to maxAttempts, then returns undefined without throwing", async () => {
vi.useFakeTimers();
const get = vi.fn(async () => {
throw new Error(
"Fetch API cannot load ipc://localhost/plugin:store|get",
);
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync(); // skip the 200ms backoffs

await expect(result).resolves.toBeUndefined();
expect(get).toHaveBeenCalledTimes(10); // maxAttempts
});

it("recovers the vault when the store IPC comes back mid-retry", async () => {
vi.useFakeTimers();
let calls = 0;
const get = vi.fn(async () => {
if (calls++ < 6) throw new Error("IPC custom protocol failed");
return VAULT;
});
const vc = makeController(get);

const result = vc.vault;
await vi.runAllTimersAsync();

await expect(result).resolves.toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(7);
});

it("coalesces concurrent reads into a single in-flight store read", async () => {
const get = vi.fn(async () => VAULT);
const vc = makeController(get);

const [a, b] = await Promise.all([vc.vault, vc.vault]);

expect(a).toEqual(VAULT);
expect(b).toEqual(VAULT);
expect(get).toHaveBeenCalledTimes(1); // both callers shared one read

// ...and a later, independent access performs a fresh read.
await vc.vault;
expect(get).toHaveBeenCalledTimes(2);
});
});
59 changes: 49 additions & 10 deletions infrastructure/eid-wallet/src/lib/global/controllers/evault.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -580,19 +580,58 @@ export class VaultController {
}
}

#vaultReadInFlight: Promise<Record<string, string> | undefined> | null =
null;

get vault() {
return this.#store
.get<Record<string, string>>("vault")
.then((vault) => {
if (!vault) {
// Coalesce concurrent reads so the resume window doesn't spin one
// retry loop per caller; cleared on settle so later reads are fresh.
if (this.#vaultReadInFlight) return this.#vaultReadInFlight;
const read = this.#readVaultResilient().finally(() => {
this.#vaultReadInFlight = null;
});
this.#vaultReadInFlight = read;
return read;
}

/**
* Read the persisted vault, distinguishing "no vault" (resolved `undefined`
* → genuine logout) from a transient store IPC failure (a throw).
*
* On iOS the `ipc://localhost` protocol is briefly torn down after the app
* resumes from background, so `plugin:store|get` throws. The old getter
* swallowed that into `undefined` — indistinguishable from "never logged
* in" — so route guards redirected to /login: the intermittent sign-out.
*
* We retry on THROW so the resume window passes before reporting "no vault".
* A genuine logout resolves `undefined` (no throw) and returns immediately.
* Still never throws, still returns `Record | undefined` — callers unchanged.
*/
async #readVaultResilient(): Promise<Record<string, string> | undefined> {
const maxAttempts = 10;
const retryDelayMs = 200; // ~2s total budget covers the IPC-dead window
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const vault =
await this.#store.get<Record<string, string>>("vault");
return vault ?? undefined;
} catch (error) {
if (attempt === maxAttempts) {
console.error(
"Failed to get vault after retries (store IPC unavailable):",
error,
);
return undefined;
}
return vault;
})
.catch((error) => {
console.error("Failed to get vault:", error);
return undefined;
});
console.warn(
`[VaultController] vault read failed (attempt ${attempt}/${maxAttempts}), store IPC likely re-initializing after resume — retrying...`,
);
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs),
);
}
}
return undefined;
}

// Getters for internal properties
Expand Down
6 changes: 6 additions & 0 deletions infrastructure/eid-wallet/src/test/env-static-public.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
// Test stub for SvelteKit's `$env/static/public` (aliased in vitest.config.ts).
// Real values are injected by SvelteKit at build time; tests only need the
// symbols to exist so modules that import them can load.
export const PUBLIC_EID_WALLET_TOKEN = "";
export const PUBLIC_PROVISIONER_URL = "";
export const PUBLIC_REGISTRY_URL = "";
20 changes: 20 additions & 0 deletions infrastructure/eid-wallet/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

// Dedicated, isolated test config — intentionally does NOT load the app's
// Svelte/Tauri/Tailwind plugins. Unit tests here run in a plain Node
// environment and stub the few runtime-only imports (see `$env` alias below;
// heavier deps are mocked per-spec with `vi.mock`).
export default defineConfig({
resolve: {
alias: {
"$env/static/public": fileURLToPath(
new URL("./src/test/env-static-public.ts", import.meta.url),
),
},
},
test: {
environment: "node",
include: ["src/**/*.spec.ts"],
},
});
Loading