mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(app): smooth the fluxer.com migration and expired re-login (#3019)
This commit is contained in:
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,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user