Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3578408
Replace 24h absolute auto-lock with configurable idle timer
Copilot May 21, 2026
54aa0b8
Address PR review feedback from Copilot reviewer
Copilot May 21, 2026
e710fa1
Address PR review feedback: treat shortened-timeout save as activity;…
Copilot May 22, 2026
0c1d6ca
Strengthen shortened-timeout regression test
Copilot May 22, 2026
b00599b
Flip popup to unlock screen when idle auto-lock fires
Copilot May 26, 2026
ecf489b
Preserve interrupted route context across auto-lock
Copilot May 26, 2026
6b22e3a
Address PR review feedback: idle auto-lock cross-surface consistency
Copilot May 27, 2026
f3e4598
Address internal review: require hasPrivateKey in useGetAppData cache…
Copilot May 27, 2026
2013aad
Make SessionLockListener handler synchronous to avoid claiming runtim…
Copilot May 27, 2026
ece5971
Move post-unlock navigation from SessionLockListener to UnlockAccount
Copilot May 27, 2026
a5ed79d
Reset idle alarm when a new Freighter surface mounts unlocked
Copilot May 27, 2026
b3868f9
Fire surface-mount activity ping unconditionally; gate on lock state …
Copilot May 27, 2026
69ab958
TEMP: instrument auto-lock path for diagnosis (will be reverted)
Copilot May 27, 2026
c0f2e16
Accept extension-origin tab senders in popupMessageListener gate
Copilot May 27, 2026
7b3e4dd
Drop surface-mount USER_ACTIVITY ping; only user input resets the idl…
Copilot May 27, 2026
7793c28
Address review feedback: scope-drift cleanup and small hardening
Copilot May 27, 2026
5038039
Address PR review feedback from @piyalbasu
Copilot May 27, 2026
c9487e6
Drop loadSettings cast using generic sendMessageToBackground
Copilot May 27, 2026
8a1376f
Add dedicated Auto-Lock Timer settings page under Security
Copilot May 29, 2026
191f6cf
Change 4h option to 6h, add 12h option, default to 12h
Copilot May 29, 2026
bc00233
Restore saveSettings error envelope check
Copilot Jun 2, 2026
4c3ca0b
Merge branch 'master' into plan-do-review/issue-2082
piyalbasu Jun 17, 2026
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
8 changes: 5 additions & 3 deletions @shared/api/helpers/extensionMessaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,19 @@ export const sendMessageToContentScript = (msg: Msg): Promise<Response> => {
});
};

export const sendMessageToBackground = async (msg: Msg): Promise<Response> => {
export const sendMessageToBackground = async <T = Response>(
msg: Msg,
): Promise<T> => {
let res;

if (DEV_SERVER) {
// treat this as an external call because we're making the call from the browser, not the popup
res = await sendMessageToContentScript(msg);
} else {
res = (await browser.runtime.sendMessage(msg)) as Response;
res = await browser.runtime.sendMessage(msg);
}

return res as Response;
return res as T;
};

export const FreighterApiNodeError = {
Expand Down
37 changes: 25 additions & 12 deletions @shared/api/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
} from "stellar-sdk";
import BigNumber from "bignumber.js";
import { INDEXER_URL, INDEXER_V2_URL } from "@shared/constants/mercury";
import {
AutoLockTimeoutMinutes,
DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES,
} from "@shared/constants/autoLock";
import {
AssetListResponse,
AssetsListItem,
Expand Down Expand Up @@ -60,6 +64,7 @@ import {
CollectibleContract,
DiscoverData,
RecentProtocolEntry,
SaveSettingsResponse,
} from "./types";
import {
AccountBalancesInterface,
Expand Down Expand Up @@ -1618,47 +1623,51 @@ export const saveSettings = async ({
isMemoValidationEnabled,
isHideDustEnabled,
isOpenSidebarByDefault,
autoLockTimeoutMinutes,
}: {
activePublicKey: string;
isDataSharingAllowed: boolean;
isMemoValidationEnabled: boolean;
isHideDustEnabled: boolean;
isOpenSidebarByDefault: boolean;
}): Promise<Settings & IndexerSettings> => {
let response = {
autoLockTimeoutMinutes: AutoLockTimeoutMinutes;
}): Promise<SaveSettingsResponse> => {
let response: SaveSettingsResponse = {
allowList: DEFAULT_ALLOW_LIST,
isDataSharingAllowed: false,
networkDetails: MAINNET_NETWORK_DETAILS,
networksList: DEFAULT_NETWORKS,
isMemoValidationEnabled: true,
isRpcHealthy: false,
userNotification: { enabled: false, message: "" },
settingsState: SettingsState.IDLE,
isSorobanPublicEnabled: false,
isNonSSLEnabled: false,
isHideDustEnabled: true,
isOpenSidebarByDefault: false,
error: "",
hiddenAssets: {},
autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES,
};

try {
response = await sendMessageToBackground({
const raw = await sendMessageToBackground<
SaveSettingsResponse | { error: string }
>({
activePublicKey,
isDataSharingAllowed,
isMemoValidationEnabled,
isHideDustEnabled,
isOpenSidebarByDefault,
autoLockTimeoutMinutes,
type: SERVICE_TYPES.SAVE_SETTINGS,
});

if ("error" in raw && raw.error) {
throw new Error(raw.error);
}

response = raw as SaveSettingsResponse;
} catch (e) {
console.error(e);
}

if (response.error) {
throw new Error(response.error);
}

return response;
};

Expand Down Expand Up @@ -1863,7 +1872,11 @@ export const loadSettings = (): Promise<
IndexerSettings &
ExperimentalFeatures & { assetsLists: AssetsLists }
> =>
sendMessageToBackground({
sendMessageToBackground<
Settings &
IndexerSettings &
ExperimentalFeatures & { assetsLists: AssetsLists }
>({
activePublicKey: null,
type: SERVICE_TYPES.LOAD_SETTINGS,
});
Expand Down
9 changes: 8 additions & 1 deletion @shared/api/types/message-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CollectibleKey,
} from "./types";
import { AssetsListItem } from "@shared/constants/soroban/asset-list";
import { AutoLockTimeoutMinutes } from "@shared/constants/autoLock";

export interface TokenToAdd {
domain: string;
Expand Down Expand Up @@ -282,6 +283,11 @@ export interface SaveSettingsMessage extends BaseMessage {
isMemoValidationEnabled: boolean;
isDataSharingAllowed: boolean;
isOpenSidebarByDefault: boolean;
autoLockTimeoutMinutes: AutoLockTimeoutMinutes;
}

export interface UserActivityMessage extends BaseMessage {
type: SERVICE_TYPES.USER_ACTIVITY;
}

export interface SaveExperimentalFeaturesMessage extends BaseMessage {
Expand Down Expand Up @@ -547,4 +553,5 @@ export type ServiceMessageRequest =
| GetHiddenCollectiblesMessage
| MarkQueueActiveMessage
| OpenSidebarMessage
| RejectSigningRequestMessage;
| RejectSigningRequestMessage
| UserActivityMessage;
16 changes: 16 additions & 0 deletions @shared/api/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
AssetsLists,
AssetsListItem,
} from "../../constants/soroban/asset-list";
import { AutoLockTimeoutMinutes } from "../../constants/autoLock";

export enum ActionStatus {
IDLE = "IDLE",
Expand Down Expand Up @@ -189,6 +190,7 @@ export interface Preferences {
networksList: NetworkDetails[];
isHideDustEnabled: boolean;
isOpenSidebarByDefault: boolean;
autoLockTimeoutMinutes: AutoLockTimeoutMinutes;
error: string;
}

Expand Down Expand Up @@ -220,6 +222,20 @@ export interface IndexerSettings {
userNotification: UserNotification;
}

export type SaveSettingsResponse = {
allowList: AllowList;
isDataSharingAllowed: boolean;
isMemoValidationEnabled: boolean;
networkDetails: NetworkDetails;
networksList: NetworkDetails[];
isRpcHealthy: boolean;
isSorobanPublicEnabled: boolean;
isNonSSLEnabled: boolean;
isHideDustEnabled: boolean;
isOpenSidebarByDefault: boolean;
autoLockTimeoutMinutes: AutoLockTimeoutMinutes;
};

export type Settings = {
allowList: AllowList;
networkDetails: NetworkDetails;
Expand Down
55 changes: 55 additions & 0 deletions @shared/constants/autoLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Single source of truth for the idle auto-lock timeout feature.
*
* The browser session is locked after this many minutes of user
* inactivity across all extension surfaces (popup, sidebar, standalone
* signing windows, grant-access windows). Any user interaction inside
* an extension page resets the timer.
*/
export const VALID_AUTO_LOCK_TIMEOUT_MINUTES = [
1, 5, 15, 30, 60, 360, 720, 1440,
] as const;

export type AutoLockTimeoutMinutes =
(typeof VALID_AUTO_LOCK_TIMEOUT_MINUTES)[number];

export const DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES: AutoLockTimeoutMinutes = 720;

export const isValidAutoLockTimeoutMinutes = (
value: unknown,
): value is AutoLockTimeoutMinutes =>
typeof value === "number" &&
(VALID_AUTO_LOCK_TIMEOUT_MINUTES as readonly number[]).includes(value);

export const coerceAutoLockTimeoutMinutes = (
value: unknown,
): AutoLockTimeoutMinutes =>
isValidAutoLockTimeoutMinutes(value)
? value
: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES;

/**
* Build a human-readable label for a timeout preset. The English fallback is
* constructed in JS and passed to `t()` as `defaultValue`, so even when an
* i18n key is missing (common in dev or partial locales) the rendered label is
* grammatically correct (e.g. "1 minute" / "5 minutes"). Locales can override
* by providing the matching keys.
*/
export const formatTimeoutLabel = (
minutes: AutoLockTimeoutMinutes,
t: (key: string, opts?: Record<string, unknown>) => string,
): string => {
if (minutes >= 60) {
const hours = minutes / 60;
const fallback = hours === 1 ? "1 hour" : `${hours} hours`;
return t("autoLockTimeout.hours", {
count: hours,
defaultValue: fallback,
});
}
const fallback = minutes === 1 ? "1 minute" : `${minutes} minutes`;
return t("autoLockTimeout.minutes", {
count: minutes,
defaultValue: fallback,
});
};
3 changes: 3 additions & 0 deletions @shared/constants/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export enum SERVICE_TYPES {
CLEAR_RECENT_PROTOCOLS = "CLEAR_RECENT_PROTOCOLS",
GET_DISCOVER_WELCOME_SEEN = "GET_DISCOVER_WELCOME_SEEN",
DISMISS_DISCOVER_WELCOME = "DISMISS_DISCOVER_WELCOME",
USER_ACTIVITY = "USER_ACTIVITY",
SESSION_LOCKED = "SESSION_LOCKED",
SESSION_UNLOCKED = "SESSION_UNLOCKED",
}

// SIDEBAR_NAVIGATE is a plain string constant (not in an enum) because it is
Expand Down
86 changes: 86 additions & 0 deletions extension/src/background/__tests__/initAlarmListener.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import browser from "webextension-polyfill";

import { SERVICE_TYPES } from "@shared/constants/services";

let alarmHandler:
| ((alarm: { name: string }) => void | Promise<void>)
| undefined;

const mockSendMessage = jest.fn().mockResolvedValue(undefined);

jest.mock("webextension-polyfill", () => ({
alarms: {
onAlarm: {
addListener: jest.fn((handler) => {
alarmHandler = handler;
}),
},
},
runtime: {
sendMessage: (...args: any[]) => mockSendMessage(...args),
},
}));

const mockClearSession = jest.fn().mockResolvedValue(undefined);
jest.mock("background/helpers/session", () => {
const actual = jest.requireActual("background/helpers/session");
return {
...actual,
clearSession: (...args: any[]) => mockClearSession(...args),
};
});

jest.mock("background/store", () => ({
buildStore: jest.fn().mockResolvedValue({}),
}));

jest.mock("background/helpers/dataStorageAccess", () => ({
dataStorageAccess: jest.fn().mockReturnValue({}),
browserLocalStorage: {},
}));

// eslint-disable-next-line @typescript-eslint/no-var-requires
const { initAlarmListener } = require("background/index");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { SESSION_ALARM_NAME } = require("background/helpers/session");

describe("initAlarmListener", () => {
beforeEach(() => {
mockSendMessage.mockClear();
mockClearSession.mockClear();
alarmHandler = undefined;
initAlarmListener();
});

it("clears the session and broadcasts SESSION_LOCKED when the auto-lock alarm fires", async () => {
expect(browser.alarms.onAlarm.addListener).toHaveBeenCalled();
expect(alarmHandler).toBeDefined();

await alarmHandler!({ name: SESSION_ALARM_NAME });

expect(mockClearSession).toHaveBeenCalledTimes(1);
expect(mockSendMessage).toHaveBeenCalledTimes(1);
expect(mockSendMessage).toHaveBeenCalledWith({
type: SERVICE_TYPES.SESSION_LOCKED,
});
});

it("swallows sendMessage errors when no UI is open to receive the broadcast", async () => {
mockSendMessage.mockRejectedValueOnce(
new Error("Could not establish connection. Receiving end does not exist."),
);

await expect(
alarmHandler!({ name: SESSION_ALARM_NAME }),
).resolves.toBeUndefined();

expect(mockClearSession).toHaveBeenCalledTimes(1);
});

it("ignores unrelated alarms", async () => {
await alarmHandler!({ name: "some-other-alarm" });

expect(mockClearSession).not.toHaveBeenCalled();
expect(mockSendMessage).not.toHaveBeenCalled();
});
});
Loading
Loading