fix(app): smooth the fluxer.com migration and expired re-login (#3019)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-28 18:11:18 +02:00
1 parent a728be4062
commit c9ae5b6ee8
75 files changed
+3284 -2368

No files matched your search

@@ -221,7 +221,13 @@ describe('rewriteImportedAccount', () => {
userId: '1',
token: 'token',
localStorageData: {runtimeConfig: '{}', token: 'token'},
managedStorageData: {runtimeConfig: '{}', token: 'token', 'fluxer.theme': 'dark'},
managedStorageData: {
runtimeConfig: '{}',
token: 'token',
'fluxer.theme': 'dark',
'fluxer.lastPushEndpoint': 'https://push',
[core.DOMAIN_MIGRATION_MARKER_KEY]: '{}',
},
lastActive: NOW,
instance: {apiEndpoint: 'https://web.fluxer.app/api'} as RuntimeConfigSnapshot,
};
@@ -269,7 +275,7 @@ describe('migration gate', () => {
['assignment off', {assignmentEnabled: false}],
['kill switch', {discovery: {...ENABLED_DISCOVERY, enabled: false}}],
['no discovery', {discovery: null}],
['already completed', {marker: {state: 'completed', target: 'https://fluxer.com', at: NOW}}],
['already completed', {marker: {state: 'completed', target: 'https://fluxer.com', at: NOW, attempts: 1}}],
['failed recently', {marker: {state: 'failed', at: NOW - 60_000, attempts: 1}}],
['failed too often', {marker: {state: 'failed', at: NOW - 3 * 24 * 60 * 60 * 1000, attempts: 3}}],
['Android web app', {environment: environment('chromium-android')}],
@@ -315,9 +321,10 @@ describe('marker state', () => {
state: 'completed',
target: 'https://fluxer.com',
at: NOW + 2,
attempts: 2,
});
core.markDomainMigrationFailed(storage, NOW + 3);
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 3, attempts: 1});
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 3, attempts: 2});
});
it('ignores malformed markers', () => {
@@ -352,7 +359,12 @@ describe('forwarding', () => {
});
it('honours the kill switch', () => {
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
const completed: core.DomainMigrationMarker = {
state: 'completed',
target: 'https://fluxer.com',
at: NOW,
attempts: 1,
};
const browser = environment('none');
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, browser)).toBe(true);
expect(core.shouldForwardCompletedSource({...ENABLED_DISCOVERY, enabled: false}, completed, browser)).toBe(false);
@@ -361,7 +373,12 @@ describe('forwarding', () => {
});
it('forwards installed apps only when standalone forwarding is on', () => {
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
const completed: core.DomainMigrationMarker = {
state: 'completed',
target: 'https://fluxer.com',
at: NOW,
attempts: 1,
};
const forwarding = {...ENABLED_DISCOVERY, standalone_forwarding: true};
const desktop = environment('chromium-desktop');
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, desktop)).toBe(false);
@@ -488,7 +505,12 @@ describe('classifyDomainMigrationInstallKind', () => {
describe('shouldShowDomainMovedNotice', () => {
const source = core.resolveDomainMigrationSide('https://web.fluxer.app');
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
const completed: core.DomainMigrationMarker = {
state: 'completed',
target: 'https://fluxer.com',
at: NOW,
attempts: 1,
};
function notice(overrides: Partial<core.DomainMovedNoticeInput>): core.DomainMovedNoticeInput {
return {
@@ -632,10 +654,17 @@ describe('runDomainMigrationPreMount', () => {
});
it('forwards a migrated source tab with its hash', async () => {
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
visit('https://web.fluxer.app/channels/1/2?a=1#b', ENABLED_DISCOVERY);
writeCompletedMarker();
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/reset#token=abc');
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1/2?a=1#b');
});
it('keeps one-shot routes on the source', async () => {
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
writeCompletedMarker();
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
});
it('stays put when the kill switch is off', async () => {
@@ -703,10 +732,10 @@ describe('runDomainMigrationPreMount', () => {
it('resumes through the target when a migrated source still holds a session', async () => {
writeCompletedMarker();
window.localStorage.setItem('token', 'session-token');
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
visit('https://web.fluxer.app/channels/1/2#x', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith(
`https://fluxer.com/migrate/begin?resume=1&next=${encodeURIComponent('/reset#token=abc')}`,
`https://fluxer.com/migrate/begin?resume=1&imported=1&next=${encodeURIComponent('/channels/1/2#x')}`,
);
expect(core.readDomainMigrationIntent(window.sessionStorage, Date.now())).not.toBeNull();
});
@@ -795,11 +824,14 @@ describe('runDomainMigrationPreMount', () => {
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
});
it('opens the target directly when the browser already migrated', async () => {
it('opens the target in place when the browser already migrated', async () => {
window.localStorage.setItem('token', 'session-token');
visit('https://fluxer.com/migrate/begin?start=1&next=%2Fapp', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/app');
const replaceState = vi.fn();
vi.stubGlobal('history', {state: null, replaceState});
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
expect(replaceState).toHaveBeenCalledWith(null, '', '/app');
});
it('does not start a migration inside an installed Android app', async () => {
@@ -819,14 +851,24 @@ describe('runDomainMigrationPreMount', () => {
);
});
it('reports back to the source when the target already holds a session', async () => {
it('still imports into a target that already holds a session', async () => {
window.localStorage.setItem('token', 'session-token');
visit('https://fluxer.com/migrate/begin?next=%2Fchannels%2F1', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/migrate/done?next=%2Fchannels%2F1');
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
expect(window.sessionStorage.getItem(core.DOMAIN_MIGRATION_PENDING_KEY)).not.toBeNull();
window.localStorage.removeItem(core.DOMAIN_MIGRATION_IMPORT_KEY);
visit('https://fluxer.com/migrate/begin?resume=1&next=%2Fchannels%2F1', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1');
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
core.writeDomainMigrationImport(window.localStorage, {state: 'done', at: Date.now()});
visit('https://fluxer.com/migrate/begin?resume=1&next=%2Fchannels%2F1', ENABLED_DISCOVERY);
const replaceState = vi.fn();
vi.stubGlobal('history', {state: null, replaceState});
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
expect(replaceState).toHaveBeenCalledWith(null, '', '/channels/1');
});
});
@@ -4,17 +4,23 @@ import {
classifyDomainMigrationInstallKind,
type DomainMigrationDisplayMode,
type DomainMigrationEnvironment,
type DomainMigrationGateInput,
type DomainMigrationInstallKind,
type DomainMigrationSide,
domainMovedBrowserMigrationUrl,
domainMovedInstallUrl,
domainMovedManifestId,
isDomainMigrationOneShotRoute,
markDomainMigrationFailed,
readDomainMigrationMarker,
writeDomainMigrationIntent,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import {
AuthSessionStorageKey,
parseStoredSessionValue,
} from '@app/features/platform/state/auth_session/AuthSessionStorage';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {hasUnavailableElectronNativeContext, isElectron} from '@app/features/ui/utils/NativeUtils';
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {hasUnavailableElectronNativeContext, isElectron} from '@app/features/ui/utils/ElectronRuntime';
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
interface NavigatorWithStandalone extends Navigator {
@@ -68,6 +74,29 @@ export function readDomainMigrationEnvironment(): DomainMigrationEnvironment {
};
}
export function readDomainMigrationGateInput(
assignmentEnabled: boolean,
voiceActive: boolean,
): DomainMigrationGateInput {
return {
environment: readDomainMigrationEnvironment(),
assignmentEnabled,
discovery: readDomainMigrationDiscovery(),
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
now: Date.now(),
voiceActive,
oneShotRoute: isDomainMigrationOneShotRoute(window.location.pathname),
};
}
export function startDomainMigrationFromSource(side: DomainMigrationSide): true {
markDomainMigrationFailed(getProtectedLocalStorage(), Date.now());
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.replace(`${side.target}/migrate/begin?next=${encodeURIComponent(next)}`);
return true;
}
export async function desktopPasskeysSupported(): Promise<boolean> {
if (!isElectronEnvironment()) {
return true;
@@ -22,6 +22,11 @@ export const DOMAIN_MIGRATION_PENDING_KEY = 'fluxer:domain-migration:pending';
export const DOMAIN_MIGRATION_INTENT_KEY = 'fluxer:domain-migration:intent';
export const DOMAIN_MIGRATION_NOTIFICATIONS_KEY = 'fluxer:domain-migration:notifications';
export const DOMAIN_MIGRATION_MOVED_DISMISSED_KEY = 'fluxer:domain-migration:moved-dismissed-at';
export const DOMAIN_MIGRATION_ENROLLED_KEY = 'fluxer:domain-migration:enrolled';
export const DOMAIN_MIGRATION_PROBED_AT_KEY = 'fluxer:domain-migration:probed-at';
export const DOMAIN_MIGRATION_IMPORT_KEY = 'fluxer:domain-migration:import';
export const DOMAIN_MIGRATION_DEVICES_KEY = 'fluxer:domain-migration:devices';
export const DOMAIN_MIGRATION_STORAGE_KEY_PREFIXES: ReadonlyArray<string> = [DOMAIN_MIGRATION_MARKER_KEY];
export const DOMAIN_MIGRATION_PAYLOAD_VERSION = 1;
export const DOMAIN_MIGRATION_DEFAULT_NEXT_PATH = '/channels/@me';
@@ -31,6 +36,21 @@ export const DOMAIN_MIGRATION_PENDING_MAX_AGE_MS = 10 * 60 * 1000;
export const DOMAIN_MIGRATION_CUSTOM_SOUNDS_MAX_BYTES = 4 * 1024 * 1024;
export const DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES = 2 * 1024 * 1024;
export const DOMAIN_MIGRATION_MOVED_DISMISS_MS = 7 * 24 * 60 * 60 * 1000;
export const DOMAIN_MIGRATION_PROBE_INTERVAL_MS = 6 * 60 * 60 * 1000;
export const DOMAIN_MIGRATION_IMPORT_STALE_MS = 2 * 60 * 1000;
export const DOMAIN_MIGRATION_DEVICES_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const ONE_SHOT_ROUTE_PREFIXES: ReadonlyArray<string> = [
'/reset',
'/verify',
'/authorize-ip',
'/wasntme',
'/oauth2/authorize',
'/auth/sso/callback',
'/premium-callback',
'/age-verification-callback',
'/connection-callback',
];
const NEXT_PATH_BASE = 'https://next.invalid';
@@ -92,12 +112,20 @@ export function sanitizeNextPath(value: unknown): string {
return `${pathname}${url.search}${url.hash}`;
}
export function isDomainMigrationStorageKey(key: string): boolean {
return key.startsWith(DOMAIN_MIGRATION_MARKER_KEY);
}
export function isDomainMigrationOneShotRoute(pathname: string): boolean {
return ONE_SHOT_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
export function buildTargetUrl(target: string, pathname: string, search: string, hash: string): string {
return `${target}${sanitizeNextPath(`${pathname}${search}${hash}`)}`;
}
export type DomainMigrationMarker =
| {state: 'completed'; target: string; at: number}
| {state: 'completed' | 'handed-off'; target: string; at: number; attempts: number}
| {state: 'failed'; at: number; attempts: number};
export function parseDomainMigrationMarker(raw: string | null): DomainMigrationMarker | null {
@@ -109,8 +137,9 @@ export function parseDomainMigrationMarker(raw: string | null): DomainMigrationM
if (typeof value !== 'object' || value === null || typeof value.at !== 'number') {
return null;
}
if (value.state === 'completed' && typeof value.target === 'string') {
return {state: 'completed', target: value.target, at: value.at};
if ((value.state === 'completed' || value.state === 'handed-off') && typeof value.target === 'string') {
const attempts = typeof value.attempts === 'number' && value.attempts > 0 ? value.attempts : 0;
return {state: value.state, target: value.target, at: value.at, attempts};
}
if (value.state === 'failed') {
const attempts = typeof value.attempts === 'number' && value.attempts > 0 ? value.attempts : 1;
@@ -137,12 +166,26 @@ function writeDomainMigrationMarker(storage: StorageLike | null, marker: DomainM
}
export function markDomainMigrationCompleted(storage: StorageLike | null, target: string, now: number): void {
writeDomainMigrationMarker(storage, {state: 'completed', target, at: now});
const attempts = readDomainMigrationMarker(storage)?.attempts ?? 0;
writeDomainMigrationMarker(storage, {state: 'completed', target, at: now, attempts});
}
export function markDomainMigrationHandedOff(
storage: StorageLike | null,
target: string,
now: number,
attempts: number,
): void {
writeDomainMigrationMarker(storage, {state: 'handed-off', target, at: now, attempts});
}
export function isCompletedDomainMigrationMarker(marker: DomainMigrationMarker | null): boolean {
return marker !== null && marker.state !== 'failed';
}
export function markDomainMigrationFailed(storage: StorageLike | null, now: number): void {
const previous = readDomainMigrationMarker(storage);
const attempts = previous?.state === 'failed' ? previous.attempts + 1 : 1;
const attempts = previous?.state === 'failed' ? previous.attempts + 1 : Math.max(previous?.attempts ?? 0, 1);
writeDomainMigrationMarker(storage, {state: 'failed', at: now, attempts});
}
@@ -150,7 +193,7 @@ export function markerAllowsDomainMigration(marker: DomainMigrationMarker | null
if (marker === null) {
return true;
}
if (marker.state === 'completed') {
if (marker.state !== 'failed') {
return false;
}
return (
@@ -158,6 +201,150 @@ export function markerAllowsDomainMigration(marker: DomainMigrationMarker | null
);
}
function readTimestampRecord(storage: StorageLike | null, key: string): number | null {
try {
const value = JSON.parse(storage?.getItem(key) ?? 'null') as unknown;
return isRecord(value) && typeof value.at === 'number' ? value.at : null;
} catch {
return null;
}
}
function writeTimestampRecord(storage: StorageLike | null, key: string, now: number): void {
try {
storage?.setItem(key, JSON.stringify({at: now}));
} catch {}
}
export function readDomainMigrationEnrollment(storage: StorageLike | null): boolean {
return readTimestampRecord(storage, DOMAIN_MIGRATION_ENROLLED_KEY) !== null;
}
export function markDomainMigrationEnrolled(storage: StorageLike | null, now: number): void {
if (!readDomainMigrationEnrollment(storage)) {
writeTimestampRecord(storage, DOMAIN_MIGRATION_ENROLLED_KEY, now);
}
}
export function domainMigrationProbeIsDue(storage: StorageLike | null, now: number): boolean {
const probedAt = readTimestampRecord(storage, DOMAIN_MIGRATION_PROBED_AT_KEY);
return probedAt === null || now - probedAt >= DOMAIN_MIGRATION_PROBE_INTERVAL_MS || probedAt > now;
}
export function markDomainMigrationProbed(storage: StorageLike | null, now: number): void {
writeTimestampRecord(storage, DOMAIN_MIGRATION_PROBED_AT_KEY, now);
}
export interface DomainMigrationImportRecord {
state: 'running' | 'done';
at: number;
}
export function readDomainMigrationImport(storage: StorageLike | null): DomainMigrationImportRecord | null {
try {
const value = JSON.parse(storage?.getItem(DOMAIN_MIGRATION_IMPORT_KEY) ?? 'null') as unknown;
if (!isRecord(value) || typeof value.at !== 'number' || (value.state !== 'running' && value.state !== 'done')) {
return null;
}
return {state: value.state, at: value.at};
} catch {
return null;
}
}
export function writeDomainMigrationImport(
storage: StorageLike | null,
record: DomainMigrationImportRecord | null,
): void {
try {
if (record === null) {
storage?.removeItem(DOMAIN_MIGRATION_IMPORT_KEY);
} else {
storage?.setItem(DOMAIN_MIGRATION_IMPORT_KEY, JSON.stringify(record));
}
} catch {}
}
export function importIsRunning(record: DomainMigrationImportRecord | null, now: number): boolean {
return record?.state === 'running' && now - record.at < DOMAIN_MIGRATION_IMPORT_STALE_MS;
}
export type DomainMigrationMediaDeviceKind = 'audioinput' | 'audiooutput' | 'videoinput';
export interface DomainMigrationMediaDevice {
kind: DomainMigrationMediaDeviceKind;
device_id: string;
label: string;
}
export interface DomainMigrationDeviceMap {
at: number;
devices: Array<DomainMigrationMediaDevice>;
resolved: Array<DomainMigrationMediaDeviceKind>;
}
function isMediaDeviceKind(value: unknown): value is DomainMigrationMediaDeviceKind {
return value === 'audioinput' || value === 'audiooutput' || value === 'videoinput';
}
function isMediaDevice(value: unknown): value is DomainMigrationMediaDevice {
return (
isRecord(value) &&
isMediaDeviceKind(value.kind) &&
typeof value.device_id === 'string' &&
value.device_id.length > 0 &&
typeof value.label === 'string' &&
value.label.length > 0
);
}
export function readDomainMigrationDeviceMap(
storage: StorageLike | null,
now: number,
): DomainMigrationDeviceMap | null {
let raw: string | null;
try {
raw = storage?.getItem(DOMAIN_MIGRATION_DEVICES_KEY) ?? null;
} catch {
return null;
}
if (raw === null) {
return null;
}
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
value = null;
}
if (
!isRecord(value) ||
typeof value.at !== 'number' ||
now - value.at > DOMAIN_MIGRATION_DEVICES_MAX_AGE_MS ||
value.at > now ||
!Array.isArray(value.devices) ||
!Array.isArray(value.resolved)
) {
writeDomainMigrationDeviceMap(storage, null);
return null;
}
return {
at: value.at,
devices: value.devices.filter(isMediaDevice),
resolved: value.resolved.filter(isMediaDeviceKind),
};
}
export function writeDomainMigrationDeviceMap(storage: StorageLike | null, map: DomainMigrationDeviceMap | null): void {
try {
if (map === null) {
storage?.removeItem(DOMAIN_MIGRATION_DEVICES_KEY);
} else {
storage?.setItem(DOMAIN_MIGRATION_DEVICES_KEY, JSON.stringify(map));
}
} catch {}
}
export interface DomainMigrationIntent {
at: number;
handoff_id?: string;
@@ -310,7 +497,11 @@ export function shouldForwardCompletedSource(
marker: DomainMigrationMarker | null,
environment: DomainMigrationEnvironment,
): boolean {
return discovery?.enabled === true && marker?.state === 'completed' && environmentMayForward(environment, discovery);
return (
discovery?.enabled === true &&
isCompletedDomainMigrationMarker(marker) &&
environmentMayForward(environment, discovery)
);
}
export interface DomainMovedNoticeInput {
@@ -330,7 +521,7 @@ export function shouldShowDomainMovedNotice(input: DomainMovedNoticeInput): bool
if (input.dismissedAt !== null && input.now - input.dismissedAt < DOMAIN_MIGRATION_MOVED_DISMISS_MS) {
return false;
}
const completed = input.marker?.state === 'completed';
const completed = isCompletedDomainMigrationMarker(input.marker);
if (input.installKind === 'chromium-desktop') {
return completed;
}
@@ -359,9 +550,7 @@ export function deviceIsInAnonymousRollout(discovery: DomainMigrationDiscoveryRe
export function isExportableLocalStorageKey(key: string): boolean {
return (
!DENIED_LOCAL_STORAGE_KEYS.has(key) &&
!key.startsWith(DOMAIN_MIGRATION_MARKER_KEY) &&
!PUSH_SUBSCRIPTION_KEY_PATTERN.test(key)
!DENIED_LOCAL_STORAGE_KEYS.has(key) && !isDomainMigrationStorageKey(key) && !PUSH_SUBSCRIPTION_KEY_PATTERN.test(key)
);
}
@@ -416,6 +605,7 @@ export interface DomainMigrationPayload {
accounts: Array<StoredAccount>;
custom_sounds?: Array<DomainMigrationCustomSound>;
theme_library?: DomainMigrationThemeLibrary;
media_devices?: Array<DomainMigrationMediaDevice>;
notification_permission: string;
}
@@ -508,16 +698,31 @@ export function parseDomainMigrationPayload(value: unknown, expectedSource: stri
if (value.theme_library !== undefined && !isThemeLibrary(value.theme_library)) {
return null;
}
return value as unknown as DomainMigrationPayload;
const mediaDevices = Array.isArray(value.media_devices) ? value.media_devices.filter(isMediaDevice) : [];
return {
...(value as unknown as DomainMigrationPayload),
media_devices: mediaDevices.length > 0 ? mediaDevices : undefined,
};
}
function withoutRuntimeConfig(snapshot: Record<string, string> | undefined): Record<string, string> {
const {runtimeConfig: _runtimeConfig, ...rest} = snapshot ?? {};
return rest;
function exportableSnapshot(snapshot: Record<string, string> | undefined): Record<string, string> {
return Object.fromEntries(Object.entries(snapshot ?? {}).filter(([key]) => isExportableLocalStorageKey(key)));
}
export function keepValidTargetSession(incoming: StoredAccount, existing: StoredAccount | undefined): StoredAccount {
if (incoming.isValid !== false || !existing?.token || existing.isValid === false) {
return incoming;
}
return {
...incoming,
token: existing.token,
isValid: existing.isValid,
userData: existing.userData ?? incoming.userData,
};
}
export function rewriteImportedAccount(record: StoredAccount, instance: RuntimeConfigSnapshot): StoredAccount {
const managed = withoutRuntimeConfig(record.managedStorageData ?? record.localStorageData);
const managed = exportableSnapshot(record.managedStorageData ?? record.localStorageData);
return {
...record,
localStorageData: managed,
@@ -0,0 +1,178 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type DomainMigrationDeviceMap,
type DomainMigrationMediaDevice,
type DomainMigrationMediaDeviceKind,
readDomainMigrationDeviceMap,
writeDomainMigrationDeviceMap,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {awaitHydration} from '@app/features/platform/utils/MobXPersistence';
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {
getVoiceAudioDeviceMetadata,
normalizeDeviceMatchLabel,
type VoiceDeviceState,
} from '@app/features/voice/utils/VoiceDeviceManager';
const remappedDeviceIds = new Map<DomainMigrationMediaDeviceKind, Map<string, string>>();
let installed = false;
let pendingDeviceMap: DomainMigrationDeviceMap | null = null;
function devicesOfKind(state: VoiceDeviceState, kind: DomainMigrationMediaDeviceKind): Array<MediaDeviceInfo> {
const devices =
kind === 'audioinput' ? state.inputDevices : kind === 'audiooutput' ? state.outputDevices : state.videoDevices;
return devices.filter(
(device) =>
device.deviceId.trim().length > 0 &&
device.deviceId !== 'default' &&
device.deviceId !== 'communications' &&
getVoiceAudioDeviceMetadata(device) === null,
);
}
function hasLabelledDevices(state: VoiceDeviceState, kind: DomainMigrationMediaDeviceKind): boolean {
const devices = devicesOfKind(state, kind);
const permission = kind === 'videoinput' ? state.permissionStatus.video : state.permissionStatus.audio;
return devices.some((device) => device.label.trim().length > 0) || (permission === 'granted' && devices.length > 0);
}
function groupByLabel<T>(
entries: ReadonlyArray<T>,
labelOf: (entry: T) => string,
idOf: (entry: T) => string,
): Map<string, Array<string>> {
const groups = new Map<string, Array<string>>();
for (const entry of entries) {
const label = labelOf(entry);
if (label.length === 0) {
continue;
}
groups.set(label, [...(groups.get(label) ?? []), idOf(entry)]);
}
return groups;
}
function mapDevicesOfKind(
kind: DomainMigrationMediaDeviceKind,
sourceDevices: ReadonlyArray<DomainMigrationMediaDevice>,
targetDevices: ReadonlyArray<MediaDeviceInfo>,
): Map<string, string> {
const sourceByLabel = groupByLabel(
sourceDevices.filter((device) => device.kind === kind),
(device) => normalizeDeviceMatchLabel(kind, device.device_id, device.label),
(device) => device.device_id,
);
const targetByLabel = groupByLabel(
targetDevices,
(device) => normalizeDeviceMatchLabel(kind, device.deviceId, device.label),
(device) => device.deviceId,
);
const mapping = new Map<string, string>();
for (const [label, sourceIds] of sourceByLabel) {
const targetIds = targetByLabel.get(label);
if (sourceIds.length === 1 && targetIds?.length === 1 && sourceIds[0] !== targetIds[0]) {
mapping.set(sourceIds[0], targetIds[0]);
}
}
return mapping;
}
export function remapMigratedDeviceId(kind: DomainMigrationMediaDeviceKind, deviceId: string): string {
return remappedDeviceIds.get(kind)?.get(deviceId) ?? deviceId;
}
export function remapMigratedDeviceIds(
deviceIds: Array<string>,
kinds: ReadonlyArray<DomainMigrationMediaDeviceKind>,
): Array<string> {
let changed = false;
const remapped = deviceIds.flatMap((deviceId) => {
const targets = [...new Set(kinds.map((kind) => remapMigratedDeviceId(kind, deviceId)))].filter(
(target) => target !== deviceId,
);
if (targets.length === 0) {
return [deviceId];
}
changed = true;
return targets;
});
return changed ? [...new Set(remapped)] : deviceIds;
}
export function isPendingMigratedDeviceId(deviceId: string): boolean {
const map = pendingDeviceMap;
return map?.devices.some((device) => device.device_id === deviceId && !map.resolved.includes(device.kind)) ?? false;
}
function applyToVoiceSettings(): void {
const current = {
inputDeviceId: VoiceSettings.getInputDeviceId(),
outputDeviceId: VoiceSettings.getOutputDeviceId(),
videoDeviceId: VoiceSettings.getVideoDeviceId(),
screenShareAudioDeviceId: VoiceSettings.getScreenShareAudioDeviceId(),
};
const next = {
inputDeviceId: remapMigratedDeviceId('audioinput', current.inputDeviceId),
outputDeviceId: remapMigratedDeviceId('audiooutput', current.outputDeviceId),
videoDeviceId: remapMigratedDeviceId('videoinput', current.videoDeviceId),
screenShareAudioDeviceId: remapMigratedDeviceId('audioinput', current.screenShareAudioDeviceId),
};
if (
next.inputDeviceId !== current.inputDeviceId ||
next.outputDeviceId !== current.outputDeviceId ||
next.videoDeviceId !== current.videoDeviceId ||
next.screenShareAudioDeviceId !== current.screenShareAudioDeviceId
) {
VoiceSettings.updateSettings(next);
}
}
function reconcileMigratedDevices(state: VoiceDeviceState): void {
const map = pendingDeviceMap;
if (map === null) {
return;
}
const pendingKinds = [...new Set(map.devices.map((device) => device.kind))].filter(
(kind) => !map.resolved.includes(kind),
);
const resolvableKinds = pendingKinds.filter((kind) => hasLabelledDevices(state, kind));
if (pendingKinds.length > 0 && resolvableKinds.length === 0) {
return;
}
for (const kind of resolvableKinds) {
const mapping = mapDevicesOfKind(kind, map.devices, devicesOfKind(state, kind));
remappedDeviceIds.set(kind, new Map([...(remappedDeviceIds.get(kind) ?? []), ...mapping]));
}
applyToVoiceSettings();
const resolved = [...map.resolved, ...resolvableKinds];
const allResolved = pendingKinds.every((kind) => resolved.includes(kind));
pendingDeviceMap = allResolved ? null : {...map, resolved};
const storage = getProtectedLocalStorage();
const stored = readDomainMigrationDeviceMap(storage, Date.now());
if (stored === null) {
return;
}
const storedResolved = [...new Set([...stored.resolved, ...resolved])];
const storedKinds = new Set(stored.devices.map((device) => device.kind));
writeDomainMigrationDeviceMap(
storage,
[...storedKinds].every((kind) => storedResolved.includes(kind)) ? null : {...stored, resolved: storedResolved},
);
}
export async function installMigratedDeviceRemap(): Promise<void> {
if (installed) {
return;
}
const map = readDomainMigrationDeviceMap(getProtectedLocalStorage(), Date.now());
if (map === null) {
return;
}
installed = true;
pendingDeviceMap = {...map, resolved: []};
await awaitHydration('VoiceSettings');
VoiceDevicePermissionState.addDeviceStateReconciler(reconcileMigratedDevices);
}
@@ -1,10 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
desktopPasskeysSupported,
hasStoredAccount,
readActiveSessionToken,
readDomainMigrationDiscovery,
readDomainMigrationEnvironment,
readDomainMigrationGateInput,
startDomainMigrationFromSource,
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
import {
anonymousRolloutIsOpen,
@@ -15,24 +18,33 @@ import {
DOMAIN_MIGRATION_DEFAULT_NEXT_PATH,
DOMAIN_MIGRATION_DEVICE_KEY,
DOMAIN_MIGRATION_MARKER_KEY,
DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS,
DOMAIN_MIGRATION_NOTIFICATIONS_KEY,
DOMAIN_MIGRATION_PAYLOAD_VERSION,
DOMAIN_MIGRATION_PENDING_KEY,
DOMAIN_MIGRATION_PENDING_MAX_AGE_MS,
DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES,
type DomainMigrationCustomSound,
type DomainMigrationMediaDevice,
type DomainMigrationPayload,
type DomainMigrationSide,
type DomainMigrationThemeLibrary,
deviceIsInAnonymousRollout,
environmentAllowsDomainMigration,
environmentMayForward,
importIsRunning,
intentConfirmsCompletion,
isCompletedDomainMigrationMarker,
isDomainMigrationOneShotRoute,
isExportableLocalStorageKey,
keepValidTargetSession,
markDomainMigrationCompleted,
markDomainMigrationFailed,
markDomainMigrationHandedOff,
parseDomainMigrationMarker,
parseDomainMigrationPayload,
readDomainMigrationEnrollment,
readDomainMigrationImport,
readDomainMigrationIntent,
readDomainMigrationMarker,
resolveDomainMigrationSide,
@@ -40,7 +52,10 @@ import {
type StorageLike,
sanitizeNextPath,
shouldForwardCompletedSource,
shouldStartDomainMigration,
withoutOptionalPayloadData,
writeDomainMigrationDeviceMap,
writeDomainMigrationImport,
writeDomainMigrationIntent,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import {
@@ -52,6 +67,11 @@ import {
sha256Hex,
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
import type {SoundType} from '@app/features/notification/utils/SoundUtils';
import {
AuthSessionStorageKey,
parseStoredSessionValue,
readStoredSessionUserId,
} from '@app/features/platform/state/auth_session/AuthSessionStorage';
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
import type {
@@ -67,6 +87,11 @@ const NONCE_BYTES = 32;
const DEVICE_ID_BYTES = 16;
const BASE64URL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
const MAX_HANDOFF_PAYLOAD_LENGTH = 8 * 1024 * 1024;
const IMPORT_WAIT_MS = 20_000;
const IMPORT_POLL_MS = 250;
const MEDIA_DEVICES_WAIT_MS = 1500;
type DomainMigrationLanding = 'target' | 'source';
type DomainMigrationFailureReason =
| 'disabled'
@@ -97,6 +122,11 @@ function navigate(url: string): true {
return true;
}
function mountInPlace(next: string): false {
window.history.replaceState(null, '', next);
return false;
}
function readCurrentPath(): {pathname: string; search: string; hash: string} {
return {pathname: window.location.pathname, search: window.location.search, hash: window.location.hash};
}
@@ -155,6 +185,27 @@ async function restoreCustomSounds(sounds: ReadonlyArray<DomainMigrationCustomSo
}
}
async function collectMediaDevices(): Promise<Array<DomainMigrationMediaDevice> | undefined> {
try {
const devices = await Promise.race([
navigator.mediaDevices?.enumerateDevices?.() ?? Promise.resolve([]),
new Promise<Array<MediaDeviceInfo>>((resolve) => setTimeout(() => resolve([]), MEDIA_DEVICES_WAIT_MS)),
]);
const labelled = devices.flatMap((device): Array<DomainMigrationMediaDevice> => {
const deviceId = device.deviceId.trim();
const label = device.label.trim();
if (!deviceId || !label || deviceId === 'default' || deviceId === 'communications') {
return [];
}
return [{kind: device.kind, device_id: deviceId, label}];
});
return labelled.length > 0 ? labelled : undefined;
} catch (err) {
logger.warn('Skipping media devices in the domain migration export:', err);
return undefined;
}
}
async function collectThemeLibrary(): Promise<DomainMigrationThemeLibrary | undefined> {
try {
const db = await import('@app/features/theme/utils/ThemeLibraryDb');
@@ -279,7 +330,7 @@ function discoveryAllowsMigration(): boolean {
}
function revokeCompletedMarker(storage: StorageLike | null): void {
if (readDomainMigrationMarker(storage)?.state === 'completed') {
if (isCompletedDomainMigrationMarker(readDomainMigrationMarker(storage))) {
markDomainMigrationFailed(storage, Date.now());
}
}
@@ -296,11 +347,20 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
if (nonce === null || !BASE64URL_TOKEN_PATTERN.test(nonce)) {
throw new Error('Missing or malformed nonce');
}
const [{default: accountStorage}, {default: RuntimeConfig}] = await Promise.all([
import('@app/features/auth/state/AccountStorage'),
import('@app/features/app/state/RuntimeConfig'),
]);
const accounts = (await accountStorage.getAllAccounts()).filter((account) => Boolean(account.token));
const previous = readDomainMigrationMarker(storage);
const attempts = previous?.state === 'handed-off' ? previous.attempts + 1 : Math.max(previous?.attempts ?? 0, 1);
if (attempts > DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS) {
throw new Error('Too many domain migration attempts');
}
const [{default: accountStorage}, {default: RuntimeConfig, runtimeConfigSnapshotsAreSameInstance}] =
await Promise.all([
import('@app/features/auth/state/AccountStorage'),
import('@app/features/app/state/RuntimeConfig'),
]);
const instance = RuntimeConfig.getSnapshot();
const accounts = (await accountStorage.getAllAccounts()).filter(
(account) => Boolean(account.token) && runtimeConfigSnapshotsAreSameInstance(account.instance, instance),
);
const token = readActiveSessionToken() ?? accounts.find((account) => account.isValid !== false)?.token ?? null;
if (!token) {
throw new Error('No stored account to export');
@@ -313,6 +373,7 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
accounts,
custom_sounds: await collectCustomSounds(),
theme_library: await collectThemeLibrary(),
media_devices: await collectMediaDevices(),
notification_permission: readNotificationPermission(),
};
let sealed = await encryptDomainMigrationPayload(payload);
@@ -321,7 +382,14 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
}
const handoffId = await createHandoff(RuntimeConfig.apiEndpoint, token, await sha256Hex(nonce), sealed.payload);
writeDomainMigrationIntent(sessionStorage, {at: intent.at, handoff_id: handoffId});
return navigate(`${side.target}/migrate/complete#h=${handoffId}&k=${sealed.key}`);
markDomainMigrationHandedOff(storage, side.target, Date.now(), attempts);
const landing: DomainMigrationLanding = environmentMayForward(
readDomainMigrationEnvironment(),
readDomainMigrationDiscovery(),
)
? 'target'
: 'source';
return navigate(`${side.target}/migrate/complete#h=${handoffId}&k=${sealed.key}&land=${landing}`);
} catch (err) {
logger.warn('Domain migration export failed:', err);
clearDomainMigrationIntent(sessionStorage);
@@ -330,20 +398,45 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
}
}
async function forwardCompletedSource(side: DomainMigrationSide): Promise<true> {
const {pathname, search, hash} = readCurrentPath();
if (!(await hasStoredAccount())) {
return navigate(buildTargetUrl(side.target, pathname, search, hash));
}
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = sanitizeNextPath(`${pathname}${search}${hash}`);
const imported = readDomainMigrationMarker(getProtectedLocalStorage())?.state === 'completed' ? '&imported=1' : '';
return navigate(`${side.target}/migrate/begin?resume=1${imported}&next=${encodeURIComponent(next)}`);
}
async function startEnrolledSource(side: DomainMigrationSide): Promise<boolean> {
if (
!readDomainMigrationEnrollment(getProtectedLocalStorage()) ||
!shouldStartDomainMigration(readDomainMigrationGateInput(true, false))
) {
return false;
}
if (!(await hasStoredAccount()) || !(await desktopPasskeysSupported())) {
return false;
}
return startDomainMigrationFromSource(side);
}
async function forwardFromSource(side: DomainMigrationSide): Promise<boolean> {
const {pathname, search, hash} = readCurrentPath();
if (isDomainMigrationOneShotRoute(pathname)) {
return false;
}
if (await startEnrolledSource(side)) {
return true;
}
const environment = readDomainMigrationEnvironment();
const discovery = readDomainMigrationDiscovery();
if (!environmentMayForward(environment, discovery)) {
return false;
}
const {pathname, search, hash} = readCurrentPath();
if (shouldForwardCompletedSource(discovery, readDomainMigrationMarker(getProtectedLocalStorage()), environment)) {
if (!(await hasStoredAccount())) {
return navigate(buildTargetUrl(side.target, pathname, search, hash));
}
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = sanitizeNextPath(`${pathname}${search}${hash}`);
return navigate(`${side.target}/migrate/begin?resume=1&next=${encodeURIComponent(next)}`);
return forwardCompletedSource(side);
}
if (
discovery !== null &&
@@ -360,6 +453,7 @@ async function forwardWhenIdle(side: DomainMigrationSide): Promise<void> {
const {default: MediaEngine} = await import('@app/features/voice/engine/MediaEngineFacade');
await when(() => !MediaEngine.connected && !MediaEngine.connecting);
if (
isDomainMigrationOneShotRoute(window.location.pathname) ||
!shouldForwardCompletedSource(
readDomainMigrationDiscovery(),
readDomainMigrationMarker(getProtectedLocalStorage()),
@@ -368,8 +462,7 @@ async function forwardWhenIdle(side: DomainMigrationSide): Promise<void> {
) {
return;
}
const {pathname, search, hash} = readCurrentPath();
navigate(buildTargetUrl(side.target, pathname, search, hash));
await forwardCompletedSource(side);
}
function installSourceMarkerListener(side: DomainMigrationSide): void {
@@ -486,18 +579,35 @@ async function importHandoff(side: DomainMigrationSide, fragment: URLSearchParam
if (payload === null) {
throw new DomainMigrationImportError('invalid_payload');
}
const storage = getProtectedLocalStorage();
const previousImport = readDomainMigrationImport(storage);
writeDomainMigrationImport(storage, {state: 'running', at: Date.now()});
try {
const [{default: accountStorage}, {default: RuntimeConfig}] = await Promise.all([
import('@app/features/auth/state/AccountStorage'),
import('@app/features/app/state/RuntimeConfig'),
]);
const instance = RuntimeConfig.getSnapshot();
await accountStorage.importAccounts(payload.accounts.map((account) => rewriteImportedAccount(account, instance)));
const activeToken = readActiveSessionToken();
const activeUserId = storage ? readStoredSessionUserId(storage) : null;
const incomingToken = parseStoredSessionValue(payload.local_storage[AuthSessionStorageKey.Token] ?? null);
const incomingUserId = parseStoredSessionValue(payload.local_storage[AuthSessionStorageKey.UserId] ?? null);
const existing = new Map((await accountStorage.getAllAccounts()).map((account) => [account.userId, account]));
await accountStorage.importAccounts(
payload.accounts.map((account) =>
keepValidTargetSession(rewriteImportedAccount(account, instance), existing.get(account.userId)),
),
);
if (activeToken !== null && activeUserId !== null && incomingToken !== null && incomingUserId !== activeUserId) {
await accountStorage.stashAccountData(activeUserId, activeToken, undefined, instance).catch((err: unknown) => {
logger.warn('Failed to keep the replaced target session:', err);
});
}
} catch (err) {
writeDomainMigrationImport(storage, previousImport);
logger.warn('Failed to import migrated accounts:', err);
throw new DomainMigrationImportError('import_failed');
}
const storage = getProtectedLocalStorage();
for (const [storageKey, value] of Object.entries(payload.local_storage)) {
if (!isExportableLocalStorageKey(storageKey)) {
continue;
@@ -508,13 +618,17 @@ async function importHandoff(side: DomainMigrationSide, fragment: URLSearchParam
logger.warn(`Failed to import localStorage key ${storageKey}:`, err);
}
}
await restoreCustomSounds(payload.custom_sounds);
await restoreThemeLibrary(payload.theme_library);
if (payload.media_devices) {
writeDomainMigrationDeviceMap(storage, {at: Date.now(), devices: payload.media_devices, resolved: []});
}
if (payload.notification_permission === 'granted') {
try {
storage?.setItem(DOMAIN_MIGRATION_NOTIFICATIONS_KEY, 'granted');
} catch {}
}
writeDomainMigrationImport(storage, {state: 'done', at: Date.now()});
await restoreCustomSounds(payload.custom_sounds);
await restoreThemeLibrary(payload.theme_library);
await persistDesktopAppOrigin();
}
@@ -531,23 +645,50 @@ function doneUrl(side: DomainMigrationSide, next: string, handoffId: string | nu
return `${side.source}/migrate/done?${handoff}next=${encodeURIComponent(next)}`;
}
function landingUrl(side: DomainMigrationSide, fragment: URLSearchParams, next: string): string {
switch (fragment.get('land')) {
case 'target':
return `${side.target}${next}`;
case 'source':
return sourceUrl(side, next);
}
return doneUrl(side, next, fragment.get('h'));
}
function failedUrl(side: DomainMigrationSide, reason: DomainMigrationFailureReason, next: string): string {
return `${side.source}/migrate/failed?reason=${reason}&next=${encodeURIComponent(next)}`;
}
async function waitForRunningImport(): Promise<void> {
const deadline = Date.now() + IMPORT_WAIT_MS;
while (importIsRunning(readDomainMigrationImport(getProtectedLocalStorage()), Date.now()) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_MS));
}
}
async function beginOnTarget(side: DomainMigrationSide, params: URLSearchParams): Promise<boolean> {
const next = sanitizeNextPath(params.get('next'));
if (readDomainMigrationDiscovery()?.enabled !== true) {
return navigate(failedUrl(side, 'disabled', next));
}
const storage = getProtectedLocalStorage();
const started = params.get('start') === '1';
if (await hasStoredAccount()) {
const resumed = params.get('resume') === '1';
if (resumed) {
await waitForRunningImport();
}
const imported = readDomainMigrationImport(storage)?.state === 'done' || (resumed && params.get('imported') === '1');
if ((resumed || started) && (imported || (started && (await hasStoredAccount())))) {
await persistDesktopAppOrigin();
return navigate(params.get('resume') === '1' || started ? `${side.target}${next}` : doneUrl(side, next, null));
return mountInPlace(next);
}
if (started) {
return navigate(`${side.source}/migrate/start?next=${encodeURIComponent(next)}`);
}
if (imported) {
return navigate(doneUrl(side, next, null));
}
writeDomainMigrationImport(storage, {state: 'running', at: Date.now()});
const nonce = randomBase64Url(NONCE_BYTES);
const pending: PendingHandoff = {nonce, next, at: Date.now()};
getProtectedSessionStorage()?.setItem(DOMAIN_MIGRATION_PENDING_KEY, JSON.stringify(pending));
@@ -559,22 +700,29 @@ async function completeOnTarget(side: DomainMigrationSide): Promise<boolean> {
window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`);
const pending = takePendingHandoff();
if (pending === null || Date.now() - pending.at > DOMAIN_MIGRATION_PENDING_MAX_AGE_MS) {
const lastImport = readDomainMigrationImport(getProtectedLocalStorage());
if (lastImport?.state === 'done' && Date.now() - lastImport.at < DOMAIN_MIGRATION_PENDING_MAX_AGE_MS) {
return navigate(
fragment.get('land') === 'source'
? sourceUrl(side, DOMAIN_MIGRATION_DEFAULT_NEXT_PATH)
: `${side.target}${DOMAIN_MIGRATION_DEFAULT_NEXT_PATH}`,
);
}
return navigate(failedUrl(side, 'no_pending', DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
}
const next = sanitizeNextPath(pending.next);
const handoffId = fragment.get('h');
if (await hasStoredAccount()) {
await persistDesktopAppOrigin();
return navigate(doneUrl(side, next, handoffId));
}
try {
await importHandoff(side, fragment, pending.nonce);
} catch (err) {
const reason = err instanceof DomainMigrationImportError ? err.reason : 'import_failed';
logger.warn('Domain migration import failed:', err);
const storage = getProtectedLocalStorage();
if (readDomainMigrationImport(storage)?.state === 'running') {
writeDomainMigrationImport(storage, null);
}
return navigate(failedUrl(side, reason, next));
}
return navigate(doneUrl(side, next, handoffId));
return navigate(landingUrl(side, fragment, next));
}
async function handleTarget(side: DomainMigrationSide): Promise<boolean> {
@@ -1,13 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
markDomainMigrationEnrolled,
readDomainMigrationEnrollment,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import SessionManager from '@app/features/platform/state/AuthSession';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {readDomainMigrationAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
readDomainMigrationAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {makeAutoObservable} from 'mobx';
const logger = new Logger('DomainMigrationRollout');
class DomainMigrationRolloutSelector {
get enabled(): boolean {
deviceEnrolled = readDomainMigrationEnrollment(getProtectedLocalStorage());
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
get assignmentReady(): boolean {
return (
ExperimentAssignments.response !== INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE &&
ExperimentAssignments.ownerId === SessionManager.userId
);
}
get assignmentEnabled(): boolean {
if (!this.assignmentReady) {
return false;
}
try {
return readDomainMigrationAssignment(ExperimentAssignments.response).enabled;
} catch (err) {
@@ -15,6 +41,15 @@ class DomainMigrationRolloutSelector {
return false;
}
}
get enabled(): boolean {
return this.assignmentEnabled || this.deviceEnrolled;
}
markDeviceEnrolled(): void {
markDomainMigrationEnrolled(getProtectedLocalStorage(), Date.now());
this.deviceEnrolled = true;
}
}
export const DomainMigrationRollout = new DomainMigrationRolloutSelector();
@@ -1,80 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import {
desktopPasskeysSupported,
readDomainMigrationDiscovery,
readDomainMigrationEnvironment,
readDomainMigrationGateInput,
startDomainMigrationFromSource,
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
import {
DOMAIN_MIGRATION_IMPORT_KEY,
DOMAIN_MIGRATION_NOTIFICATIONS_KEY,
type DomainMigrationGateInput,
type DomainMigrationSide,
markDomainMigrationFailed,
readDomainMigrationMarker,
domainMigrationProbeIsDue,
environmentAllowsDomainMigration,
markDomainMigrationProbed,
markerAllowsDomainMigration,
readDomainMigrationImport,
resolveDomainMigrationSide,
shouldStartDomainMigration,
writeDomainMigrationIntent,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import DomainMigrationRollout from '@app/features/app/domain_migration/DomainMigrationRollout';
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import AccountManager from '@app/features/auth/state/AccountManager';
import {EXPERIMENT_ASSIGNMENTS_PATH} from '@app/features/experiment/state/ExperimentAssignments';
import SessionManager from '@app/features/platform/state/AuthSession';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {when} from 'mobx';
import {
ExperimentAssignmentsResponse,
readDomainMigrationAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {compareShallow, reaction} from 'mobx';
const logger = new Logger('DomainMigrationTrigger');
const ONE_SHOT_ROUTE_PREFIXES: ReadonlyArray<string> = [
Routes.RESET_PASSWORD,
Routes.VERIFY_EMAIL,
Routes.AUTHORIZE_IP,
Routes.EMAIL_REVERT,
Routes.OAUTH_AUTHORIZE,
Routes.SSO_CALLBACK,
Routes.PREMIUM_CALLBACK,
Routes.AGE_VERIFICATION_CALLBACK,
Routes.CONNECTION_CALLBACK,
];
let started = false;
let navigating = false;
let probing = false;
interface SourceGateState {
assignmentReady: boolean;
assignmentEnabled: boolean;
deviceEnrolled: boolean;
userId: string | null;
sessionSettled: boolean;
voiceActive: boolean;
}
function isVoiceActive(): boolean {
return MediaEngine.connected || MediaEngine.connecting;
}
function isOneShotRoute(pathname: string): boolean {
return ONE_SHOT_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
function isSessionSettled(): boolean {
return SessionManager.isAuthenticated && SessionManager.userId !== null && !AccountManager.transitioning;
}
function readGateInput(assignmentEnabled: boolean): DomainMigrationGateInput {
return {
environment: readDomainMigrationEnvironment(),
assignmentEnabled,
discovery: readDomainMigrationDiscovery(),
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
now: Date.now(),
voiceActive: isVoiceActive(),
oneShotRoute: isOneShotRoute(window.location.pathname),
};
function sourceMayStart(): boolean {
return (
!navigating &&
isSessionSettled() &&
shouldStartDomainMigration(readDomainMigrationGateInput(DomainMigrationRollout.enabled, isVoiceActive()))
);
}
async function evaluateSource(side: DomainMigrationSide, assignmentEnabled: boolean): Promise<void> {
if (navigating || !shouldStartDomainMigration(readGateInput(assignmentEnabled))) {
async function evaluateSource(side: DomainMigrationSide): Promise<void> {
if (!sourceMayStart()) {
return;
}
const passkeysSupported = await desktopPasskeysSupported();
if (navigating || !passkeysSupported || !shouldStartDomainMigration(readGateInput(DomainMigrationRollout.enabled))) {
if (!passkeysSupported || !sourceMayStart()) {
return;
}
navigating = true;
markDomainMigrationFailed(getProtectedLocalStorage(), Date.now());
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.replace(`${side.target}/migrate/begin?next=${encodeURIComponent(next)}`);
startDomainMigrationFromSource(side);
}
async function accountIsEnrolled(token: string): Promise<boolean> {
try {
const response = await fetch(
`${RuntimeConfig.apiEndpoint}/v${RuntimeConfig.apiCodeVersion}${EXPERIMENT_ASSIGNMENTS_PATH}`,
{credentials: 'omit', headers: {Authorization: token}},
);
if (!response.ok) {
return false;
}
const parsed = ExperimentAssignmentsResponse.safeParse(await response.json());
return parsed.success && readDomainMigrationAssignment(parsed.data).enabled;
} catch {
return false;
}
}
async function probeStoredAccounts(): Promise<void> {
const storage = getProtectedLocalStorage();
const gate = readDomainMigrationGateInput(false, false);
if (
probing ||
DomainMigrationRollout.deviceEnrolled ||
gate.discovery?.enabled !== true ||
!environmentAllowsDomainMigration(gate.environment) ||
!markerAllowsDomainMigration(gate.marker, gate.now) ||
!domainMigrationProbeIsDue(storage, gate.now)
) {
return;
}
const activeUserId = SessionManager.userId;
const tokens = SessionManager.accounts
.filter((account) => account.userId !== activeUserId && account.isValid !== false && Boolean(account.token))
.map((account) => account.token);
if (tokens.length === 0) {
return;
}
probing = true;
markDomainMigrationProbed(storage, gate.now);
try {
for (const token of tokens) {
if (await accountIsEnrolled(token)) {
DomainMigrationRollout.markDeviceEnrolled();
return;
}
}
} finally {
probing = false;
}
}
function readSourceGateState(): SourceGateState {
return {
assignmentReady: DomainMigrationRollout.assignmentReady,
assignmentEnabled: DomainMigrationRollout.assignmentEnabled,
deviceEnrolled: DomainMigrationRollout.deviceEnrolled,
userId: SessionManager.userId,
sessionSettled: isSessionSettled(),
voiceActive: isVoiceActive(),
};
}
function handleSourceGateState(side: DomainMigrationSide, state: SourceGateState): void {
if (state.assignmentEnabled && !state.deviceEnrolled) {
DomainMigrationRollout.markDeviceEnrolled();
return;
}
if (navigating || !state.sessionSettled || (!state.assignmentReady && !state.deviceEnrolled)) {
return;
}
if (!state.deviceEnrolled) {
probeStoredAccounts().catch((err) => {
logger.warn('Domain migration enrollment probe failed:', err);
});
return;
}
evaluateSource(side).catch((err) => {
logger.warn('Domain migration trigger failed:', err);
});
}
function offerNotificationReenable(): void {
@@ -92,6 +170,27 @@ function offerNotificationReenable(): void {
}
}
function reloadAfterSiblingImport(event: StorageEvent): void {
if (
event.key === DOMAIN_MIGRATION_IMPORT_KEY &&
readDomainMigrationImport(getProtectedLocalStorage())?.state === 'done'
) {
window.location.reload();
}
}
export function probeSignedOutDeviceEnrollment(): void {
if (typeof window === 'undefined' || SessionManager.isAuthenticated) {
return;
}
if (resolveDomainMigrationSide(window.location.origin)?.role !== 'source') {
return;
}
probeStoredAccounts().catch((err) => {
logger.warn('Domain migration enrollment probe failed:', err);
});
}
export function startDomainMigrationTrigger(): void {
if (started || typeof window === 'undefined') {
return;
@@ -103,14 +202,11 @@ export function startDomainMigrationTrigger(): void {
started = true;
if (side.role === 'target') {
setTimeout(offerNotificationReenable, 0);
window.addEventListener('storage', reloadAfterSiblingImport);
return;
}
when(
() => ExperimentAssignments.response !== INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
() => {
evaluateSource(side, DomainMigrationRollout.enabled).catch((err) => {
logger.warn('Domain migration trigger failed:', err);
});
},
);
reaction(readSourceGateState, (state) => handleSourceGateState(side, state), {
fireImmediately: true,
equals: compareShallow,
});
}