Skip to content

Commit 09ab88a

Browse files
lace-releases[bot]lace-publish-bot
andauthored
release: lace-extension@2.0.4 (#2219)
Snapshot of input-output-hk/lace-platform @ 994a962152aa6027bd735eb61424fb8334bdbcf2 Produced by open-source-release-publish.yml. See PROVENANCE for the allowlist hash that controlled this snapshot. Co-authored-by: lace-publish-bot <lace-publish-bot@users.noreply.github.com>
1 parent 440f948 commit 09ab88a

81 files changed

Lines changed: 2344 additions & 385 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

PROVENANCE

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
Lace public source snapshot
22
===========================
33

4-
release-tag: lace-extension@2.0.3
4+
release-tag: lace-extension@2.0.4
55
source-repo: input-output-hk/lace-platform (private)
6-
source-commit: adb23f654599f926f0f3a57abbe6cd8306364b6b
7-
snapshot-utc: 2026-05-06T20:56:53.464Z
6+
source-commit: 994a962152aa6027bd735eb61424fb8334bdbcf2
7+
snapshot-utc: 2026-05-08T19:54:36.790Z
88

99
allowlist-sha256: a75a43e9e238d609b21b9d7f3409fa46bb8a0a92a4738dbeba71076ec4e74929
1010
excludelist-sha256: 8e7916a357aae4f8a8a963e32cb48392e039572670b390f043322a9d17e98a50

apps/lace-extension/app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"slug": "lace-extension",
55
"owner": "lace_io",
66
"newArchEnabled": true,
7-
"version": "2.0.3",
7+
"version": "2.0.4",
88
"orientation": "portrait",
99
"icon": "./assets/icon.png",
1010
"platforms": ["web"],

apps/lace-extension/assets/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "$EXTENSION_NAME",
33
"description": "One fast, accessible, and secure platform for digital assets, DApps, NFTs, and DeFi.",
4-
"version": "2.0.3",
4+
"version": "2.0.4",
55
"manifest_version": 3,
66
"key": "$EXTENSION_KEY",
77
"icons": {

apps/lace-extension/src/ExpoApp.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
NoOpProvider,
1414
createSentryProvider,
1515
} from '@lace-lib/observability';
16-
import { Loader, configureImageFormat } from '@lace-lib/ui-toolkit';
16+
import { Splash, configureImageFormat } from '@lace-lib/ui-toolkit';
1717
import * as Sentry from '@sentry/react';
1818
import React, { useEffect, useState } from 'react';
1919
import { Provider } from 'react-redux';
@@ -133,7 +133,7 @@ const ExpoApp = () => {
133133
void loadPromise.then(setInit);
134134
}, []);
135135

136-
if (!init) return <Loader />;
136+
if (!init) return <Splash />;
137137

138138
const appContent = (
139139
<Provider store={init.store}>
@@ -143,7 +143,7 @@ const ExpoApp = () => {
143143

144144
// Only wrap with Sentry ErrorBoundary if Sentry is initialized
145145
const wrappedContent = isSentryEnabled ? (
146-
<Sentry.ErrorBoundary fallback={<Loader />}>
146+
<Sentry.ErrorBoundary fallback={<Splash />}>
147147
{appContent}
148148
</Sentry.ErrorBoundary>
149149
) : (
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Pure logic for the SW's chrome.action.onClicked handler. Extracted so the
2+
// branches can be unit-tested without booting the SW.
3+
//
4+
// The `sidePanel` permission is declared as required in manifest.json, so
5+
// either:
6+
// - the host browser recognises it and auto-grants it at install time
7+
// (Chrome / Edge / Brave / Arc, …) — `chrome.sidePanel` is callable; or
8+
// - the host browser silently ignores the unknown permission name
9+
// (older Chromium forks, e.g. Yandex) — `chrome.sidePanel` is undefined.
10+
// We branch on the runtime presence of the API, not on a permission probe.
11+
12+
import type { DefaultOpenMode } from '@lace-contract/views';
13+
14+
export type ActionClickHandlerDeps = {
15+
// Runtime check for `chrome.sidePanel`. False on hosts that don't ship the
16+
// API (older Chromium forks).
17+
isSidePanelApiAvailable: () => boolean;
18+
// The user's persisted mode preference. The slice forces 'tab' when the
19+
// API is unavailable; we still gate on `isSidePanelApiAvailable` here as
20+
// defence in depth.
21+
getStoredDefaultOpenMode: () => DefaultOpenMode;
22+
openLaceTab: () => Promise<void>;
23+
openSidePanel: (windowId: number) => Promise<void>;
24+
logger: { error: (...args: unknown[]) => void };
25+
};
26+
27+
/**
28+
* Routes a toolbar-action click:
29+
* - host doesn't ship sidePanel, or user chose tab → open a tab.
30+
* - otherwise → open the side panel; on failure, fall back to a tab.
31+
*/
32+
export const handleActionClick = async (
33+
deps: ActionClickHandlerDeps,
34+
tab: { windowId?: number },
35+
): Promise<void> => {
36+
const shouldOpenSidePanel =
37+
deps.isSidePanelApiAvailable() &&
38+
deps.getStoredDefaultOpenMode() === 'sidePanel';
39+
40+
if (!shouldOpenSidePanel || tab.windowId === undefined) {
41+
await deps.openLaceTab();
42+
return;
43+
}
44+
45+
try {
46+
await deps.openSidePanel(tab.windowId);
47+
} catch (error) {
48+
deps.logger.error('Failed to open side panel imperatively', error);
49+
await deps.openLaceTab();
50+
}
51+
};

apps/lace-extension/src/sw-script/load.ts

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,102 @@ import {
33
createStore,
44
findStorageModule,
55
} from '@lace-contract/module';
6+
import {
7+
applySidePanelBehavior,
8+
DEFAULT_OPEN_MODE,
9+
isSidePanelApiAvailable,
10+
} from '@lace-contract/views';
611
import { devToolsEnhancer } from '@redux-devtools/remote';
712

8-
import { createExtensionModuleLoader, ENV, logger } from '../util';
13+
import {
14+
createExtensionModuleLoader,
15+
ENV,
16+
logger,
17+
readDefaultOpenMode,
18+
} from '../util';
919

20+
import { handleActionClick } from './action-click-handler';
1021
import { createRemoteStore } from './create-remote-store';
1122
import { exposeAPIs } from './expose-apis';
1223

1324
import type { FeatureFlagApi } from '../util';
25+
import type { DefaultOpenMode } from '@lace-contract/views';
26+
27+
// MV3 only dispatches events to listeners registered before the SW script
28+
// hits its first await. If `chrome.action.onClicked.addListener` is registered
29+
// after an await, the click that woke the SW is dropped — the user has to
30+
// click again once boot finishes. Everything in this top section therefore
31+
// runs synchronously, before the awaits below.
32+
33+
// In-memory mirror of the persisted preference, refreshed from
34+
// chrome.storage.local on every click and at SW boot. Initialised to the
35+
// contract's default so the first click after boot has *some* value to use
36+
// if the storage read somehow fails. See ADR 26.
37+
//
38+
// We deliberately do NOT subscribe to chrome.storage.onChanged here: that
39+
// listener would fire on every chrome.storage.local write (including
40+
// redux-persist's), resetting the SW idle timer and preventing it from going
41+
// dormant. The popup updates Chrome's setPanelBehavior itself after writing,
42+
// so cross-context propagation does not require an SW listener.
43+
const cachedDefaultOpenMode: { current: DefaultOpenMode } = {
44+
current: DEFAULT_OPEN_MODE,
45+
};
46+
47+
const openLaceTab = async () => {
48+
try {
49+
const indexUrl = chrome.runtime.getURL('expo/index.html');
50+
const existing = await chrome.tabs.query({ url: `${indexUrl}*` });
51+
if (existing[0]?.id !== undefined) {
52+
await chrome.tabs.update(existing[0].id, { active: true });
53+
if (existing[0].windowId !== undefined) {
54+
await chrome.windows.update(existing[0].windowId, { focused: true });
55+
}
56+
return;
57+
}
58+
await chrome.tabs.create({ url: 'expo/index.html', active: true });
59+
} catch (error) {
60+
logger.error('Failed to open Lace tab', error);
61+
}
62+
};
63+
64+
const refreshFromStorage = async (): Promise<DefaultOpenMode> => {
65+
const mode = await readDefaultOpenMode();
66+
cachedDefaultOpenMode.current = mode;
67+
// Fire-and-forget: handleActionClick decides what to open by reading
68+
// cachedDefaultOpenMode (just assigned above), not Chrome's setPanelBehavior
69+
// state, so the click can be handled before this Promise settles.
70+
applySidePanelBehavior(mode)?.catch((error: unknown) => {
71+
logger.error('Failed to update side panel behavior', error);
72+
});
73+
return mode;
74+
};
75+
76+
// Kick off the initial read synchronously so Chrome's setPanelBehavior is
77+
// reconciled at boot (in case the popup was unable to update it the last
78+
// time the user changed mode).
79+
void refreshFromStorage().catch((error: unknown) => {
80+
logger.error('Failed to read defaultOpenMode from storage', error);
81+
});
82+
83+
chrome.action.onClicked.addListener(tab => {
84+
void (async () => {
85+
// Re-read on every click so popup-side changes propagate without a
86+
// chrome.storage.onChanged subscription (which would keep the SW alive).
87+
// The await preserves the user-gesture window — chrome.storage reads
88+
// typically resolve in well under a millisecond.
89+
await refreshFromStorage().catch(() => cachedDefaultOpenMode.current);
90+
await handleActionClick(
91+
{
92+
isSidePanelApiAvailable,
93+
getStoredDefaultOpenMode: () => cachedDefaultOpenMode.current,
94+
openLaceTab,
95+
openSidePanel: async windowId => chrome.sidePanel.open({ windowId }),
96+
logger,
97+
},
98+
tab,
99+
);
100+
})();
101+
});
14102

15103
const {
16104
loadModules,
@@ -45,8 +133,6 @@ const { store } = await createStore(
45133
);
46134
const remoteStore = createRemoteStore(store);
47135

48-
void chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
49-
50136
const featureFlags: FeatureFlagApi = {
51137
getFeatureFlags: async () => loadedFeatureFlags,
52138
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SW-side reader for the toolbar action's open-mode preference. The MV3
2+
// service worker cannot read redux-persist before its first await, so this
3+
// preference lives in chrome.storage.local — see ADR 26. Both the SW and
4+
// the views-extension UI hook talk to chrome.storage.local through the
5+
// same key.
6+
//
7+
// The SW deliberately does NOT register a chrome.storage.onChanged listener
8+
// here. That listener fires on every chrome.storage.local write, which in
9+
// Lace includes redux-persist writes — each event resets the SW idle timer
10+
// and prevents the service worker from going dormant. The popup applies the
11+
// new chrome.sidePanel behaviour itself after writing, and the SW reconciles
12+
// the cached value on the next click.
13+
14+
import {
15+
DEFAULT_OPEN_MODE,
16+
DEFAULT_OPEN_MODE_STORAGE_KEY,
17+
isValidDefaultOpenMode,
18+
} from '@lace-contract/views';
19+
20+
import type { DefaultOpenMode } from '@lace-contract/views';
21+
22+
type ChromeStorageArea = {
23+
get: (key: string) => Promise<Record<string, unknown>>;
24+
};
25+
type ChromeLike = { storage?: { local?: ChromeStorageArea } };
26+
27+
const getLocal = (): ChromeStorageArea | undefined =>
28+
(globalThis as { chrome?: ChromeLike }).chrome?.storage?.local;
29+
30+
export const readDefaultOpenMode = async (): Promise<DefaultOpenMode> => {
31+
const local = getLocal();
32+
if (!local) return DEFAULT_OPEN_MODE;
33+
const result = await local.get(DEFAULT_OPEN_MODE_STORAGE_KEY);
34+
const value = result[DEFAULT_OPEN_MODE_STORAGE_KEY];
35+
return isValidDefaultOpenMode(value) ? value : DEFAULT_OPEN_MODE;
36+
};

apps/lace-extension/src/util/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './config';
66
export * from './connect-store';
77
export * from './load-modules';
88
export * from './all-modules';
9+
export * from './default-open-mode-storage';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { handleActionClick } from '../../src/sw-script/action-click-handler';
4+
5+
import type { ActionClickHandlerDeps } from '../../src/sw-script/action-click-handler';
6+
7+
const noopLogger = { error: vi.fn() };
8+
9+
const createDeps = (
10+
overrides: Partial<ActionClickHandlerDeps> = {},
11+
): ActionClickHandlerDeps => ({
12+
isSidePanelApiAvailable: () => true,
13+
getStoredDefaultOpenMode: () => 'sidePanel',
14+
openLaceTab: vi.fn(async () => {}),
15+
openSidePanel: vi.fn(async () => {}),
16+
logger: noopLogger,
17+
...overrides,
18+
});
19+
20+
describe('handleActionClick', () => {
21+
beforeEach(() => {
22+
noopLogger.error.mockClear();
23+
});
24+
25+
it('opens a tab when the host does not ship chrome.sidePanel (e.g. Yandex)', async () => {
26+
const openLaceTab = vi.fn(async () => {});
27+
const openSidePanel = vi.fn(async () => {});
28+
const deps = createDeps({
29+
isSidePanelApiAvailable: () => false,
30+
// Even if stored mode is 'sidePanel' (the default), no API → tab.
31+
getStoredDefaultOpenMode: () => 'sidePanel',
32+
openLaceTab,
33+
openSidePanel,
34+
});
35+
36+
await handleActionClick(deps, { windowId: 1 });
37+
38+
expect(openLaceTab).toHaveBeenCalledTimes(1);
39+
expect(openSidePanel).not.toHaveBeenCalled();
40+
});
41+
42+
it("opens a tab when the user has chosen 'tab' mode", async () => {
43+
const openLaceTab = vi.fn(async () => {});
44+
const openSidePanel = vi.fn(async () => {});
45+
const deps = createDeps({
46+
isSidePanelApiAvailable: () => true,
47+
getStoredDefaultOpenMode: () => 'tab',
48+
openLaceTab,
49+
openSidePanel,
50+
});
51+
52+
await handleActionClick(deps, { windowId: 2 });
53+
54+
expect(openLaceTab).toHaveBeenCalledTimes(1);
55+
expect(openSidePanel).not.toHaveBeenCalled();
56+
});
57+
58+
it('opens the side panel imperatively when API is available, mode is sidePanel, and a windowId is provided', async () => {
59+
const openLaceTab = vi.fn(async () => {});
60+
const openSidePanel = vi.fn(async () => {});
61+
const deps = createDeps({
62+
isSidePanelApiAvailable: () => true,
63+
getStoredDefaultOpenMode: () => 'sidePanel',
64+
openLaceTab,
65+
openSidePanel,
66+
});
67+
68+
await handleActionClick(deps, { windowId: 7 });
69+
70+
expect(openSidePanel).toHaveBeenCalledWith(7);
71+
expect(openLaceTab).not.toHaveBeenCalled();
72+
});
73+
74+
it('falls back to a tab when openSidePanel throws', async () => {
75+
const openLaceTab = vi.fn(async () => {});
76+
const openSidePanel = vi.fn(async () => {
77+
throw new Error('boom');
78+
});
79+
const deps = createDeps({
80+
isSidePanelApiAvailable: () => true,
81+
getStoredDefaultOpenMode: () => 'sidePanel',
82+
openLaceTab,
83+
openSidePanel,
84+
});
85+
86+
await handleActionClick(deps, { windowId: 7 });
87+
88+
expect(openSidePanel).toHaveBeenCalledWith(7);
89+
expect(openLaceTab).toHaveBeenCalledTimes(1);
90+
expect(noopLogger.error).toHaveBeenCalled();
91+
});
92+
93+
it('falls back to a tab when no windowId is available', async () => {
94+
const openLaceTab = vi.fn(async () => {});
95+
const openSidePanel = vi.fn(async () => {});
96+
const deps = createDeps({
97+
isSidePanelApiAvailable: () => true,
98+
getStoredDefaultOpenMode: () => 'sidePanel',
99+
openLaceTab,
100+
openSidePanel,
101+
});
102+
103+
await handleActionClick(deps, { windowId: undefined });
104+
105+
expect(openSidePanel).not.toHaveBeenCalled();
106+
expect(openLaceTab).toHaveBeenCalledTimes(1);
107+
});
108+
});

0 commit comments

Comments
 (0)