Skip to content
Open
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
10 changes: 5 additions & 5 deletions bun.lock

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
Expand Up @@ -40,7 +40,7 @@
"smoldot": "3.4.1"
},
"overrides": {
"@parity/truapi": "0.10.0",
"@parity/truapi": "0.12.0",
"fast-uri": "3.1.5",
"smoldot": "3.4.1",
"esbuild": "^0.28.1",
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
"@dotli/storage": "workspace:*",
"@dotli/truapi-debug": "workspace:*",
"@noble/hashes": "^2.2.0",
"@parity/truapi": "0.10.0",
"@parity/truapi-host": "0.7.0",
"@parity/truapi": "0.12.0",
"@parity/truapi-host": "0.9.0",
"@polkadot-api/json-rpc-provider": "^0.2.0",
"@scure/base": "^2.2.0",
"neverthrow": "^8.2.0",
Expand Down
23 changes: 23 additions & 0 deletions packages/ui/src/host-callbacks/Locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { LocaleHost } from "@parity/truapi-host";
import type { HostLocaleSubscribeItem } from "@parity/truapi";
import { createResultStream } from "./result-stream";

// dotli presents English chrome and has no language setting of its own, so the
// visitor's browser preference is the only real signal a product can localize
// against. A product that does not ship the tag picks its own fallback.
function currentLocale(): HostLocaleSubscribeItem {
return { languageTag: navigator.language };
}

export function createLocaleSubscribe(): Required<LocaleHost>["subscribeLocale"] {
return () =>
createResultStream<HostLocaleSubscribeItem>([currentLocale()], (push) => {
const onLanguageChanged = (): void => {
push(currentLocale());
};
window.addEventListener("languagechange", onLanguageChanged);
return () => {
window.removeEventListener("languagechange", onLanguageChanged);
};
});
}
6 changes: 6 additions & 0 deletions packages/ui/src/host-callbacks/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,12 @@ function coreLocalStorageKey(key: CoreStorageKey): string {
return `${CORE_LOCAL_STORAGE_PREFIX}product-subtree:${hexNoPrefix(
encodeCoreStorageKey(key),
)}`;
// The ledger bounds replays for one wallet and peer pair, so the whole
// triple has to discriminate the slot.
case "SsoResponderRequestLedger":
return `${CORE_LOCAL_STORAGE_PREFIX}sso-responder-ledger:${hexNoPrefix(
encodeCoreStorageKey(key),
)}`;
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/host-callbacks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createChainConnect } from "./Chain";
import { createFeatureSupported } from "./FeatureSupported";
import { createSupportedChains } from "./SupportedChains";
import { createThemeSubscribe } from "./Theme";
import { createLocaleSubscribe } from "./Locale";
import { createAuthStateChanged } from "./AuthState";
import { createChatPlatform } from "./Chat";
import { createSessionStoreAdapters } from "./SessionStore";
Expand Down Expand Up @@ -82,6 +83,7 @@ export function createHostCallbacks(
},
userConfirmation: createUserConfirmationAdapters(label, blockingModalScope),
theme: { subscribeTheme: createThemeSubscribe() },
locale: { subscribeLocale: createLocaleSubscribe() },
preimage: createPreimageAdapters(label),
chain: { connect: createChainConnect() },
// Always served; the core itself denies chat calls on non-Chat
Expand Down
10 changes: 1 addition & 9 deletions packages/ui/src/runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,13 @@ function getPlatformType(userAgent: string = navigator.userAgent): string {
return "Unknown";
}

// `assetHub` drives the host's dotNS username resolution. Typed as an
// intersection so this compiles against @parity/truapi-host releases from
// before the field existed; once the dependency floor includes it, fold the
// field into the plain ProductRuntimeConfig literal.
type RuntimeConfigWithAssetHub = ProductRuntimeConfig & {
assetHub?: { genesisHash: string | Uint8Array };
};

// The window origin deliberately plays no part here: `productId` comes
// solely from the label (or the explicit override).
export function createTruapiRuntimeConfig(
label: string,
productId: string = labelToProductId(label),
): ProductRuntimeConfig {
const config: RuntimeConfigWithAssetHub = {
const config: ProductRuntimeConfig = {
productId,
host: {
name: "Polkadot Web",
Expand Down
60 changes: 60 additions & 0 deletions packages/ui/tests/locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from "vitest";
import { createLocaleSubscribe } from "@dotli/ui/host-callbacks/Locale";

const realLanguage = navigator.language;

function setBrowserLanguage(tag: string): void {
Object.defineProperty(navigator, "language", {
value: tag,
configurable: true,
});
}

afterEach(() => {
setBrowserLanguage(realLanguage);
});

describe("locale host callbacks", () => {
it("As a dotli integrator, the host emits the visitor's language immediately", async () => {
// Given
// dotli presents English chrome, but a product localizes itself, so the
// signal has to be what the visitor asked their browser for.
setBrowserLanguage("pt-BR");
const subscribeLocale = createLocaleSubscribe();

// When
const iterator = subscribeLocale()[Symbol.asyncIterator]();
const first = await iterator.next();
await iterator.return?.();

// Then
expect(first.done).toBe(false);
expect(first.value.isOk()).toBe(true);
expect(first.value._unsafeUnwrap()).toEqual({ languageTag: "pt-BR" });
});

it("As a dotli integrator, the host emits language changes until unsubscribed", async () => {
// Given
setBrowserLanguage("en");
const subscribeLocale = createLocaleSubscribe();

const iterator = subscribeLocale()[Symbol.asyncIterator]();
const first = await iterator.next();
const next = iterator.next();

// When
setBrowserLanguage("zh-Hans");
window.dispatchEvent(new Event("languagechange"));
const changed = await next;

await iterator.return?.();
const afterReturn = await iterator.next();

// Then
expect(first.value._unsafeUnwrap()).toEqual({ languageTag: "en" });
expect(changed.done).toBe(false);
expect(changed.value.isOk()).toBe(true);
expect(changed.value._unsafeUnwrap()).toEqual({ languageTag: "zh-Hans" });
expect(afterReturn.done).toBe(true);
});
});
47 changes: 47 additions & 0 deletions packages/ui/tests/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,53 @@ describe("session-store host callbacks", () => {
]);
});

it("As a dotli integrator, the host stores the SSO responder replay ledger per wallet and peer", async () => {
// Given
const { readCoreStorage, writeCoreStorage, clearCoreStorage } =
createSessionStoreAdapters();
const key = {
tag: "SsoResponderRequestLedger",
value: {
rootPublicKey: new Uint8Array(32).fill(1),
peerStatementAccountId: new Uint8Array(32).fill(2),
peerEncryptionPublicKey: new Uint8Array(32).fill(3),
},
} satisfies CoreStorageKey;
const otherPeer = {
tag: "SsoResponderRequestLedger",
value: {
rootPublicKey: new Uint8Array(32).fill(1),
peerStatementAccountId: new Uint8Array(32).fill(2),
peerEncryptionPublicKey: new Uint8Array(32).fill(4),
},
} satisfies CoreStorageKey;

// When
await writeCoreStorage(key, new Uint8Array([21]));
await writeCoreStorage(otherPeer, new Uint8Array([22]));

// Then
// Core-owned replay state, not key material, so it is stored like the
// ring registry snapshot rather than under at-rest encryption.
expect(localStorage.length).toBe(2);
for (const index of [0, 1]) {
const storageKey = localStorage.key(index);
expect(storageKey).toMatch(/^dotli:core:sso-responder-ledger:[0-9a-f]+$/);
expect(localStorage.getItem(storageKey ?? "")).toMatch(/^0x/);
}
// The ledger bounds replays for one peer, so two peers of the same wallet
// must never share a slot.
expect(Array.from((await readCoreStorage(key)) ?? [])).toEqual([21]);
expect(Array.from((await readCoreStorage(otherPeer)) ?? [])).toEqual([22]);

// When
await clearCoreStorage(key);

// Then
expect(await readCoreStorage(key)).toBeUndefined();
expect(Array.from((await readCoreStorage(otherPeer)) ?? [])).toEqual([22]);
});

it("As a dotli integrator, the host never reuses a nonce across allowance key writes", async () => {
// Given
const { readCoreStorage, writeCoreStorage } = createSessionStoreAdapters();
Expand Down
Loading