mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
609 lines
21 KiB
TypeScript
609 lines
21 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {
|
|
hasStoredAccount,
|
|
readActiveSessionToken,
|
|
readDomainMigrationDiscovery,
|
|
readDomainMigrationEnvironment,
|
|
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
|
import {
|
|
anonymousRolloutIsOpen,
|
|
buildTargetUrl,
|
|
clearDomainMigrationIntent,
|
|
collectExportableLocalStorage,
|
|
DOMAIN_MIGRATION_CUSTOM_SOUNDS_MAX_BYTES,
|
|
DOMAIN_MIGRATION_DEFAULT_NEXT_PATH,
|
|
DOMAIN_MIGRATION_DEVICE_KEY,
|
|
DOMAIN_MIGRATION_MARKER_KEY,
|
|
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 DomainMigrationPayload,
|
|
type DomainMigrationSide,
|
|
type DomainMigrationThemeLibrary,
|
|
deviceIsInAnonymousRollout,
|
|
environmentAllowsDomainMigration,
|
|
environmentMayForward,
|
|
intentConfirmsCompletion,
|
|
isExportableLocalStorageKey,
|
|
markDomainMigrationCompleted,
|
|
markDomainMigrationFailed,
|
|
parseDomainMigrationMarker,
|
|
parseDomainMigrationPayload,
|
|
readDomainMigrationIntent,
|
|
readDomainMigrationMarker,
|
|
resolveDomainMigrationSide,
|
|
rewriteImportedAccount,
|
|
type StorageLike,
|
|
sanitizeNextPath,
|
|
shouldForwardCompletedSource,
|
|
withoutOptionalPayloadData,
|
|
writeDomainMigrationIntent,
|
|
} from '@app/features/app/domain_migration/DomainMigrationCore';
|
|
import {
|
|
base64UrlToBytes,
|
|
bytesToBase64Url,
|
|
decryptDomainMigrationPayload,
|
|
encryptDomainMigrationPayload,
|
|
randomBase64Url,
|
|
sha256Hex,
|
|
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
|
|
import {resolvePasskeyBridgeOpenerOrigin} from '@app/features/auth/utils/PasskeyBridgeProtocol';
|
|
import type {SoundType} from '@app/features/notification/utils/SoundUtils';
|
|
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
|
import type {
|
|
ThemeLibraryAsset,
|
|
ThemeLibraryLocalFileReference,
|
|
ThemeLibraryTheme,
|
|
} from '@app/features/theme/state/ThemeLibrary';
|
|
import {when} from 'mobx';
|
|
|
|
const logger = new Logger('DomainMigration');
|
|
|
|
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;
|
|
|
|
type DomainMigrationFailureReason =
|
|
| 'disabled'
|
|
| 'no_pending'
|
|
| 'missing_handoff'
|
|
| 'handoff_expired'
|
|
| 'nonce_mismatch'
|
|
| 'redeem_failed'
|
|
| 'invalid_payload'
|
|
| 'import_failed'
|
|
| 'target_error';
|
|
|
|
class DomainMigrationImportError extends Error {
|
|
constructor(readonly reason: DomainMigrationFailureReason) {
|
|
super(`Domain migration import failed: ${reason}`);
|
|
this.name = 'DomainMigrationImportError';
|
|
}
|
|
}
|
|
|
|
interface PendingHandoff {
|
|
nonce: string;
|
|
next: string;
|
|
at: number;
|
|
}
|
|
|
|
function navigate(url: string): true {
|
|
window.location.replace(url);
|
|
return true;
|
|
}
|
|
|
|
function readCurrentPath(): {pathname: string; search: string; hash: string} {
|
|
return {pathname: window.location.pathname, search: window.location.search, hash: window.location.hash};
|
|
}
|
|
|
|
function readNotificationPermission(): string {
|
|
return typeof Notification === 'undefined' ? 'unsupported' : Notification.permission;
|
|
}
|
|
|
|
function readOrCreateDeviceId(): string {
|
|
const storage = getProtectedLocalStorage();
|
|
const existing = storage?.getItem(DOMAIN_MIGRATION_DEVICE_KEY);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const deviceId = randomBase64Url(DEVICE_ID_BYTES);
|
|
try {
|
|
storage?.setItem(DOMAIN_MIGRATION_DEVICE_KEY, deviceId);
|
|
} catch {}
|
|
return deviceId;
|
|
}
|
|
|
|
async function collectCustomSounds(): Promise<Array<DomainMigrationCustomSound> | undefined> {
|
|
try {
|
|
const {getAllCustomSounds} = await import('@app/features/notification/utils/CustomSoundDB');
|
|
const sounds = await getAllCustomSounds();
|
|
const totalBytes = sounds.reduce((sum, sound) => sum + sound.blob.size, 0);
|
|
if (totalBytes > DOMAIN_MIGRATION_CUSTOM_SOUNDS_MAX_BYTES) {
|
|
return undefined;
|
|
}
|
|
return await Promise.all(
|
|
sounds.map(async (sound) => ({
|
|
sound_type: sound.soundType,
|
|
file_name: sound.fileName,
|
|
mime_type: sound.blob.type,
|
|
data: bytesToBase64Url(new Uint8Array(await sound.blob.arrayBuffer())),
|
|
})),
|
|
);
|
|
} catch (err) {
|
|
logger.warn('Skipping custom sounds in the domain migration export:', err);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function restoreCustomSounds(sounds: ReadonlyArray<DomainMigrationCustomSound> | undefined): Promise<void> {
|
|
if (!sounds || sounds.length === 0) {
|
|
return;
|
|
}
|
|
const {saveCustomSound} = await import('@app/features/notification/utils/CustomSoundDB');
|
|
for (const sound of sounds) {
|
|
try {
|
|
const blob = new Blob([base64UrlToBytes(sound.data)], {type: sound.mime_type});
|
|
await saveCustomSound(sound.sound_type as SoundType, blob, sound.file_name);
|
|
} catch (err) {
|
|
logger.warn(`Failed to restore custom sound ${sound.sound_type}:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function collectThemeLibrary(): Promise<DomainMigrationThemeLibrary | undefined> {
|
|
try {
|
|
const db = await import('@app/features/theme/utils/ThemeLibraryDb');
|
|
const [themes, assets, localFiles, enabledThemeIds] = await Promise.all([
|
|
db.listThemeLibraryThemes(),
|
|
db.listThemeLibraryAssets(),
|
|
db.listThemeLibraryLocalFiles(),
|
|
db.getEnabledThemeIds(),
|
|
]);
|
|
const assetBytes = assets.reduce((sum, asset) => sum + (asset.data?.size ?? 0), 0);
|
|
const portableAssets = assetBytes > DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES ? [] : assets;
|
|
return {
|
|
themes: themes.map((theme) => ({...theme})),
|
|
assets: await Promise.all(
|
|
portableAssets.map(async (asset) => ({
|
|
id: asset.id,
|
|
name: asset.name,
|
|
mime_type: asset.mimeType,
|
|
size: asset.size,
|
|
data: asset.data ? bytesToBase64Url(new Uint8Array(await asset.data.arrayBuffer())) : undefined,
|
|
desktop_path: asset.desktopPath,
|
|
created_at: asset.createdAt,
|
|
updated_at: asset.updatedAt,
|
|
})),
|
|
),
|
|
local_files: localFiles.map((file) => ({...file})),
|
|
enabled_theme_ids: enabledThemeIds,
|
|
};
|
|
} catch (err) {
|
|
logger.warn('Skipping the theme library in the domain migration export:', err);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function restoreThemeLibrary(library: DomainMigrationThemeLibrary | undefined): Promise<void> {
|
|
if (!library) {
|
|
return;
|
|
}
|
|
try {
|
|
const db = await import('@app/features/theme/utils/ThemeLibraryDb');
|
|
for (const theme of library.themes) {
|
|
await db.saveThemeLibraryTheme(theme as unknown as ThemeLibraryTheme);
|
|
}
|
|
for (const asset of library.assets) {
|
|
const restored: ThemeLibraryAsset = {
|
|
id: asset.id,
|
|
name: asset.name,
|
|
mimeType: asset.mime_type,
|
|
size: asset.size,
|
|
data: asset.data === undefined ? undefined : new Blob([base64UrlToBytes(asset.data)], {type: asset.mime_type}),
|
|
desktopPath: asset.desktop_path,
|
|
createdAt: asset.created_at,
|
|
updatedAt: asset.updated_at,
|
|
};
|
|
await db.saveThemeLibraryAsset(restored);
|
|
}
|
|
for (const file of library.local_files) {
|
|
await db.saveThemeLibraryLocalFile(file as unknown as ThemeLibraryLocalFileReference);
|
|
}
|
|
if (library.enabled_theme_ids.length > 0) {
|
|
await db.setEnabledThemeIds(library.enabled_theme_ids);
|
|
}
|
|
} catch (err) {
|
|
logger.warn('Failed to restore the theme library:', err);
|
|
}
|
|
}
|
|
|
|
async function createHandoff(apiEndpoint: string, token: string, nonceHash: string, payload: string): Promise<string> {
|
|
const response = await fetch(`${apiEndpoint}/v1/auth/origin-handoff`, {
|
|
method: 'POST',
|
|
credentials: 'omit',
|
|
headers: {'Content-Type': 'application/json', Authorization: token},
|
|
body: JSON.stringify({nonce_hash: nonceHash, payload}),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Origin handoff was rejected with status ${response.status}`);
|
|
}
|
|
const body = (await response.json()) as {handoff_id?: unknown};
|
|
if (typeof body.handoff_id !== 'string' || !BASE64URL_TOKEN_PATTERN.test(body.handoff_id)) {
|
|
throw new Error('Origin handoff response is malformed');
|
|
}
|
|
return body.handoff_id;
|
|
}
|
|
|
|
async function redeemHandoff(target: string, handoffId: string, nonce: string): Promise<string> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`${target}/api/v1/auth/origin-handoff/redeem`, {
|
|
method: 'POST',
|
|
credentials: 'omit',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({handoff_id: handoffId, nonce}),
|
|
});
|
|
} catch {
|
|
throw new DomainMigrationImportError('redeem_failed');
|
|
}
|
|
if (response.status === 404) {
|
|
throw new DomainMigrationImportError('handoff_expired');
|
|
}
|
|
if (response.status === 400) {
|
|
throw new DomainMigrationImportError('nonce_mismatch');
|
|
}
|
|
if (!response.ok) {
|
|
throw new DomainMigrationImportError('redeem_failed');
|
|
}
|
|
const body = (await response.json()) as {payload?: unknown};
|
|
if (typeof body.payload !== 'string') {
|
|
throw new DomainMigrationImportError('redeem_failed');
|
|
}
|
|
return body.payload;
|
|
}
|
|
|
|
function sourceUrl(side: DomainMigrationSide, next: unknown): string {
|
|
return `${side.source}${sanitizeNextPath(next)}`;
|
|
}
|
|
|
|
function discoveryAllowsMigration(): boolean {
|
|
return (
|
|
readDomainMigrationDiscovery()?.enabled === true &&
|
|
environmentAllowsDomainMigration(readDomainMigrationEnvironment())
|
|
);
|
|
}
|
|
|
|
function revokeCompletedMarker(storage: StorageLike | null): void {
|
|
if (readDomainMigrationMarker(storage)?.state === 'completed') {
|
|
markDomainMigrationFailed(storage, Date.now());
|
|
}
|
|
}
|
|
|
|
async function exportFromSource(side: DomainMigrationSide, nonce: string | null): Promise<boolean> {
|
|
const storage = getProtectedLocalStorage();
|
|
const sessionStorage = getProtectedSessionStorage();
|
|
const intent = readDomainMigrationIntent(sessionStorage, Date.now());
|
|
if (intent === null || intent.handoff_id !== undefined) {
|
|
clearDomainMigrationIntent(sessionStorage);
|
|
return navigate(sourceUrl(side, DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
|
|
}
|
|
try {
|
|
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 token = readActiveSessionToken() ?? accounts.find((account) => account.isValid !== false)?.token ?? null;
|
|
if (!token) {
|
|
throw new Error('No stored account to export');
|
|
}
|
|
const payload: DomainMigrationPayload = {
|
|
version: DOMAIN_MIGRATION_PAYLOAD_VERSION,
|
|
source_origin: side.source,
|
|
exported_at: Date.now(),
|
|
local_storage: collectExportableLocalStorage(storage),
|
|
accounts,
|
|
custom_sounds: await collectCustomSounds(),
|
|
theme_library: await collectThemeLibrary(),
|
|
notification_permission: readNotificationPermission(),
|
|
};
|
|
let sealed = await encryptDomainMigrationPayload(payload);
|
|
if (sealed.payload.length > MAX_HANDOFF_PAYLOAD_LENGTH) {
|
|
sealed = await encryptDomainMigrationPayload(withoutOptionalPayloadData(payload));
|
|
}
|
|
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}`);
|
|
} catch (err) {
|
|
logger.warn('Domain migration export failed:', err);
|
|
clearDomainMigrationIntent(sessionStorage);
|
|
revokeCompletedMarker(storage);
|
|
return navigate(sourceUrl(side, DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
|
|
}
|
|
}
|
|
|
|
async function forwardFromSource(side: DomainMigrationSide): Promise<boolean> {
|
|
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)}`);
|
|
}
|
|
if (
|
|
discovery !== null &&
|
|
anonymousRolloutIsOpen(discovery) &&
|
|
!(await hasStoredAccount()) &&
|
|
deviceIsInAnonymousRollout(discovery, readOrCreateDeviceId())
|
|
) {
|
|
return navigate(buildTargetUrl(side.target, pathname, search, hash));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function forwardWhenIdle(side: DomainMigrationSide): Promise<void> {
|
|
const {default: MediaEngine} = await import('@app/features/voice/engine/MediaEngineFacade');
|
|
await when(() => !MediaEngine.connected && !MediaEngine.connecting);
|
|
if (
|
|
!shouldForwardCompletedSource(
|
|
readDomainMigrationDiscovery(),
|
|
readDomainMigrationMarker(getProtectedLocalStorage()),
|
|
readDomainMigrationEnvironment(),
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
const {pathname, search, hash} = readCurrentPath();
|
|
navigate(buildTargetUrl(side.target, pathname, search, hash));
|
|
}
|
|
|
|
function installSourceMarkerListener(side: DomainMigrationSide): void {
|
|
let waiting = false;
|
|
window.addEventListener('storage', (event) => {
|
|
if (event.key !== DOMAIN_MIGRATION_MARKER_KEY || waiting) {
|
|
return;
|
|
}
|
|
if (
|
|
!shouldForwardCompletedSource(
|
|
readDomainMigrationDiscovery(),
|
|
parseDomainMigrationMarker(event.newValue),
|
|
readDomainMigrationEnvironment(),
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
waiting = true;
|
|
forwardWhenIdle(side)
|
|
.catch((err) => {
|
|
logger.warn('Failed to forward a migrated source tab:', err);
|
|
})
|
|
.finally(() => {
|
|
waiting = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
function completeOnSource(side: DomainMigrationSide, params: URLSearchParams): boolean {
|
|
const sessionStorage = getProtectedSessionStorage();
|
|
const intent = readDomainMigrationIntent(sessionStorage, Date.now());
|
|
clearDomainMigrationIntent(sessionStorage);
|
|
if (!intentConfirmsCompletion(intent, params.get('h'))) {
|
|
return navigate(sourceUrl(side, params.get('next')));
|
|
}
|
|
markDomainMigrationCompleted(getProtectedLocalStorage(), side.target, Date.now());
|
|
if (!environmentMayForward(readDomainMigrationEnvironment(), readDomainMigrationDiscovery())) {
|
|
return navigate(sourceUrl(side, params.get('next')));
|
|
}
|
|
return navigate(`${side.target}${sanitizeNextPath(params.get('next'))}`);
|
|
}
|
|
|
|
function startOnSource(side: DomainMigrationSide, params: URLSearchParams): boolean {
|
|
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
|
|
return navigate(`${side.target}/migrate/begin?next=${encodeURIComponent(sanitizeNextPath(params.get('next')))}`);
|
|
}
|
|
|
|
function failOnSource(side: DomainMigrationSide, params: URLSearchParams): boolean {
|
|
const sessionStorage = getProtectedSessionStorage();
|
|
const intent = readDomainMigrationIntent(sessionStorage, Date.now());
|
|
clearDomainMigrationIntent(sessionStorage);
|
|
if (intent !== null) {
|
|
logger.warn(`Domain migration failed on ${side.target}: ${params.get('reason') ?? 'unknown'}`);
|
|
revokeCompletedMarker(getProtectedLocalStorage());
|
|
}
|
|
return navigate(sourceUrl(side, params.get('next')));
|
|
}
|
|
|
|
async function handleSource(side: DomainMigrationSide): Promise<boolean> {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const {pathname} = window.location;
|
|
if (pathname.startsWith('/migrate/') && !discoveryAllowsMigration()) {
|
|
clearDomainMigrationIntent(getProtectedSessionStorage());
|
|
return navigate(sourceUrl(side, params.get('next')));
|
|
}
|
|
switch (pathname) {
|
|
case '/migrate/export':
|
|
return exportFromSource(side, params.get('n'));
|
|
case '/migrate/start':
|
|
return startOnSource(side, params);
|
|
case '/migrate/done':
|
|
return completeOnSource(side, params);
|
|
case '/migrate/failed':
|
|
return failOnSource(side, params);
|
|
}
|
|
if (await forwardFromSource(side)) {
|
|
return true;
|
|
}
|
|
installSourceMarkerListener(side);
|
|
return false;
|
|
}
|
|
|
|
function takePendingHandoff(): PendingHandoff | null {
|
|
const storage = getProtectedSessionStorage();
|
|
try {
|
|
const raw = storage?.getItem(DOMAIN_MIGRATION_PENDING_KEY) ?? null;
|
|
storage?.removeItem(DOMAIN_MIGRATION_PENDING_KEY);
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
const value = JSON.parse(raw) as Partial<PendingHandoff>;
|
|
if (typeof value.nonce !== 'string' || typeof value.next !== 'string' || typeof value.at !== 'number') {
|
|
return null;
|
|
}
|
|
return {nonce: value.nonce, next: value.next, at: value.at};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function importHandoff(side: DomainMigrationSide, fragment: URLSearchParams, nonce: string): Promise<void> {
|
|
const handoffId = fragment.get('h');
|
|
const key = fragment.get('k');
|
|
if (!handoffId || !key || !BASE64URL_TOKEN_PATTERN.test(handoffId)) {
|
|
throw new DomainMigrationImportError('missing_handoff');
|
|
}
|
|
const sealed = await redeemHandoff(side.target, handoffId, nonce);
|
|
let payload: DomainMigrationPayload | null;
|
|
try {
|
|
payload = parseDomainMigrationPayload(await decryptDomainMigrationPayload(sealed, key), side.source);
|
|
} catch {
|
|
payload = null;
|
|
}
|
|
if (payload === null) {
|
|
throw new DomainMigrationImportError('invalid_payload');
|
|
}
|
|
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)));
|
|
} catch (err) {
|
|
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;
|
|
}
|
|
try {
|
|
storage?.setItem(storageKey, value);
|
|
} catch (err) {
|
|
logger.warn(`Failed to import localStorage key ${storageKey}:`, err);
|
|
}
|
|
}
|
|
await restoreCustomSounds(payload.custom_sounds);
|
|
await restoreThemeLibrary(payload.theme_library);
|
|
if (payload.notification_permission === 'granted') {
|
|
try {
|
|
storage?.setItem(DOMAIN_MIGRATION_NOTIFICATIONS_KEY, 'granted');
|
|
} catch {}
|
|
}
|
|
await persistDesktopAppOrigin();
|
|
}
|
|
|
|
async function persistDesktopAppOrigin(): Promise<void> {
|
|
try {
|
|
await window.electron?.domainMigration?.setAppOrigin(window.location.origin);
|
|
} catch (err) {
|
|
logger.warn('Failed to persist the desktop app origin:', err);
|
|
}
|
|
}
|
|
|
|
function doneUrl(side: DomainMigrationSide, next: string, handoffId: string | null): string {
|
|
const handoff = handoffId === null ? '' : `h=${encodeURIComponent(handoffId)}&`;
|
|
return `${side.source}/migrate/done?${handoff}next=${encodeURIComponent(next)}`;
|
|
}
|
|
|
|
function failedUrl(side: DomainMigrationSide, reason: DomainMigrationFailureReason, next: string): string {
|
|
return `${side.source}/migrate/failed?reason=${reason}&next=${encodeURIComponent(next)}`;
|
|
}
|
|
|
|
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 started = params.get('start') === '1';
|
|
if (await hasStoredAccount()) {
|
|
await persistDesktopAppOrigin();
|
|
return navigate(params.get('resume') === '1' || started ? `${side.target}${next}` : doneUrl(side, next, null));
|
|
}
|
|
if (started) {
|
|
return navigate(`${side.source}/migrate/start?next=${encodeURIComponent(next)}`);
|
|
}
|
|
const nonce = randomBase64Url(NONCE_BYTES);
|
|
const pending: PendingHandoff = {nonce, next, at: Date.now()};
|
|
getProtectedSessionStorage()?.setItem(DOMAIN_MIGRATION_PENDING_KEY, JSON.stringify(pending));
|
|
return navigate(`${side.source}/migrate/export?n=${nonce}`);
|
|
}
|
|
|
|
async function completeOnTarget(side: DomainMigrationSide): Promise<boolean> {
|
|
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
|
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) {
|
|
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);
|
|
return navigate(failedUrl(side, reason, next));
|
|
}
|
|
return navigate(doneUrl(side, next, handoffId));
|
|
}
|
|
|
|
async function handleTarget(side: DomainMigrationSide): Promise<boolean> {
|
|
switch (window.location.pathname) {
|
|
case '/migrate/begin':
|
|
return beginOnTarget(side, new URLSearchParams(window.location.search));
|
|
case '/migrate/complete':
|
|
return completeOnTarget(side);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function runDomainMigrationPreMount(): Promise<boolean> {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
const side = resolveDomainMigrationSide(window.location.origin);
|
|
if (side === null || resolvePasskeyBridgeOpenerOrigin(window.location.origin, window.location.pathname) !== null) {
|
|
return false;
|
|
}
|
|
try {
|
|
return side.role === 'source' ? await handleSource(side) : await handleTarget(side);
|
|
} catch (err) {
|
|
logger.error('Domain migration pre-mount step failed:', err);
|
|
if (side.role === 'target' && window.location.pathname.startsWith('/migrate/')) {
|
|
return navigate(failedUrl(side, 'target_error', DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
|
|
}
|
|
return false;
|
|
}
|
|
}
|