mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(web): prepare the fluxer.com domain migration (#2952)
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
export const APP_PROTOCOL = 'fluxer';
|
||||
export const STABLE_APP_URL = 'https://web.fluxer.app';
|
||||
export const CANARY_APP_URL = 'https://web.canary.fluxer.app';
|
||||
export const STABLE_MIGRATED_APP_ORIGIN = 'https://fluxer.com';
|
||||
export const CANARY_MIGRATED_APP_ORIGIN = 'https://canary.fluxer.com';
|
||||
export const MIGRATED_APP_ENTRY_PATH = '/app';
|
||||
export const STATIC_CDN_URL = 'https://fluxerstatic.com';
|
||||
export const DEFAULT_WINDOW_WIDTH = 1280;
|
||||
export const DEFAULT_WINDOW_HEIGHT = 800;
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
|
||||
import {CANARY_APP_URL, STABLE_APP_URL} from '@electron/common/Constants';
|
||||
import {
|
||||
CANARY_APP_URL,
|
||||
CANARY_MIGRATED_APP_ORIGIN,
|
||||
MIGRATED_APP_ENTRY_PATH,
|
||||
STABLE_APP_URL,
|
||||
STABLE_MIGRATED_APP_ORIGIN,
|
||||
} from '@electron/common/Constants';
|
||||
import type {DesktopTroubleshootingSettings, DesktopWindowBehaviorSettings} from '@electron/common/Types';
|
||||
import log from 'electron-log';
|
||||
|
||||
@@ -18,6 +24,7 @@ interface DesktopConfig extends Record<string, unknown> {
|
||||
window_behavior?: PersistedDesktopWindowBehaviorSettings;
|
||||
troubleshooting?: PersistedDesktopTroubleshootingSettings;
|
||||
theme_allowed_local_files?: Array<string>;
|
||||
app_origin?: string;
|
||||
}
|
||||
|
||||
export type ChromiumSwitchesSetting = ReadonlyArray<string> | Record<string, unknown>;
|
||||
@@ -133,12 +140,34 @@ function sanitizeChromiumSwitchesSetting(value: unknown): ChromiumSwitchesSettin
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getLegacyAppUrl(): string {
|
||||
return BUILD_CHANNEL === 'canary' ? CANARY_APP_URL : STABLE_APP_URL;
|
||||
}
|
||||
|
||||
function getMigratedAppOrigin(): string {
|
||||
return BUILD_CHANNEL === 'canary' ? CANARY_MIGRATED_APP_ORIGIN : STABLE_MIGRATED_APP_ORIGIN;
|
||||
}
|
||||
|
||||
export function getOfficialAppOrigins(): Array<string> {
|
||||
return [new URL(getLegacyAppUrl()).origin, getMigratedAppOrigin()];
|
||||
}
|
||||
|
||||
function sanitizeAppOrigin(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && getOfficialAppOrigins().includes(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function sanitizeDesktopConfig(value: unknown): DesktopConfig {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
const nextConfig: DesktopConfig = {...value};
|
||||
delete nextConfig.app_url;
|
||||
const appOrigin = sanitizeAppOrigin(value.app_origin);
|
||||
if (appOrigin) {
|
||||
nextConfig.app_origin = appOrigin;
|
||||
} else {
|
||||
delete nextConfig.app_origin;
|
||||
}
|
||||
const chromiumSwitches = sanitizeChromiumSwitchesSetting(value.chromiumSwitches);
|
||||
if (chromiumSwitches) {
|
||||
nextConfig.chromiumSwitches = chromiumSwitches;
|
||||
@@ -311,7 +340,29 @@ export function getAppUrl(): string {
|
||||
if (runtimeAppUrlOverride) {
|
||||
return runtimeAppUrlOverride;
|
||||
}
|
||||
return BUILD_CHANNEL === 'canary' ? CANARY_APP_URL : STABLE_APP_URL;
|
||||
const migratedAppOrigin = getMigratedAppOrigin();
|
||||
if (config.app_origin === migratedAppOrigin) {
|
||||
return `${migratedAppOrigin}${MIGRATED_APP_ENTRY_PATH}`;
|
||||
}
|
||||
return getLegacyAppUrl();
|
||||
}
|
||||
|
||||
export function getAppUrlFallback(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).origin === getMigratedAppOrigin() ? getLegacyAppUrl() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAppOrigin(origin: string): boolean {
|
||||
const appOrigin = sanitizeAppOrigin(origin);
|
||||
if (appOrigin === undefined) {
|
||||
return false;
|
||||
}
|
||||
config.app_origin = appOrigin;
|
||||
saveDesktopConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getCustomAppUrl(): string | null {
|
||||
|
||||
@@ -627,6 +627,11 @@ export type TrayActionPayload =
|
||||
action: 'check-for-updates';
|
||||
};
|
||||
|
||||
export interface DomainMigrationApi {
|
||||
version: 1;
|
||||
setAppOrigin: (origin: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
platform: NodeJS.Platform;
|
||||
buildChannel: 'stable' | 'canary';
|
||||
@@ -759,6 +764,7 @@ export interface ElectronAPI {
|
||||
passkeyIsSupported: () => Promise<boolean>;
|
||||
passkeyAuthenticate: (options: PublicKeyCredentialRequestOptionsJSON) => Promise<AuthenticationResponseJSON>;
|
||||
passkeyRegister: (options: PublicKeyCredentialCreationOptionsJSON) => Promise<RegistrationResponseJSON>;
|
||||
domainMigration: DomainMigrationApi;
|
||||
virtmic: VirtmicApi;
|
||||
nativeAudio: NativeAudioApi;
|
||||
nativeScreenCapture: NativeScreenCaptureApi;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {describe, test} from 'node:test';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
function transform(relativePath) {
|
||||
const sourcePath = fileURLToPath(new URL(relativePath, import.meta.url));
|
||||
const code = esbuild.transformSync(fs.readFileSync(sourcePath, 'utf8'), {
|
||||
loader: 'ts',
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
}).code;
|
||||
return {sourcePath, code};
|
||||
}
|
||||
|
||||
const constantsSource = transform('../common/Constants.ts');
|
||||
const desktopConfigSource = transform('../common/DesktopConfig.ts');
|
||||
const domainMigrationSource = transform('./DomainMigration.ts');
|
||||
|
||||
const silentLog = {debug() {}, info() {}, warn() {}, error() {}};
|
||||
|
||||
function runModule({sourcePath, code}, requireStub) {
|
||||
const module = {exports: {}};
|
||||
const context = vm.createContext({
|
||||
require: requireStub,
|
||||
module,
|
||||
exports: module.exports,
|
||||
process,
|
||||
console,
|
||||
URL,
|
||||
JSON,
|
||||
});
|
||||
vm.runInContext(code, context, {filename: sourcePath});
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
function loadDesktop({channel = 'stable', settings} = {}) {
|
||||
const userDataPath = fs.mkdtempSync(path.join(os.tmpdir(), 'fluxer-domain-migration-test-'));
|
||||
const settingsPath = path.join(userDataPath, 'settings.json');
|
||||
if (settings !== undefined) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings), 'utf-8');
|
||||
}
|
||||
const constants = runModule(constantsSource, (specifier) => {
|
||||
throw new Error(`Unexpected import: ${specifier}`);
|
||||
});
|
||||
const desktopConfig = runModule(desktopConfigSource, (specifier) => {
|
||||
if (specifier === 'node:fs') return fs;
|
||||
if (specifier === 'node:path') return path;
|
||||
if (specifier === '@electron/common/BuildChannel') return {BUILD_CHANNEL: channel};
|
||||
if (specifier === '@electron/common/Constants') return constants;
|
||||
if (specifier === 'electron-log') return silentLog;
|
||||
throw new Error(`Unexpected import: ${specifier}`);
|
||||
});
|
||||
desktopConfig.loadDesktopConfig(userDataPath);
|
||||
const handlers = new Map();
|
||||
runModule(domainMigrationSource, (specifier) => {
|
||||
if (specifier === '@electron/common/DesktopConfig') return desktopConfig;
|
||||
if (specifier === '@electron/common/Logger') return {createChildLogger: () => silentLog};
|
||||
if (specifier === 'electron') {
|
||||
return {
|
||||
ipcMain: {
|
||||
handle: (name, handler) => {
|
||||
handlers.set(name, handler);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected import: ${specifier}`);
|
||||
}).registerDomainMigrationHandlers();
|
||||
const readSettings = () => JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
const setAppOrigin = (frame, origin) => handlers.get('domain-migration:set-app-origin')({senderFrame: frame}, origin);
|
||||
return {desktopConfig, readSettings, setAppOrigin, settingsPath};
|
||||
}
|
||||
|
||||
function topLevelFrame(url) {
|
||||
return {url, detached: false, parent: null};
|
||||
}
|
||||
|
||||
describe('DesktopConfig app origin', () => {
|
||||
test('keeps loading the legacy root when no app origin is stored', () => {
|
||||
assert.equal(loadDesktop().desktopConfig.getAppUrl(), 'https://web.fluxer.app');
|
||||
assert.equal(loadDesktop({channel: 'canary'}).desktopConfig.getAppUrl(), 'https://web.canary.fluxer.app');
|
||||
});
|
||||
|
||||
test('loads the app entry path for a stored migrated origin', () => {
|
||||
const stable = loadDesktop({settings: {app_origin: 'https://fluxer.com'}});
|
||||
const canary = loadDesktop({channel: 'canary', settings: {app_origin: 'https://canary.fluxer.com'}});
|
||||
|
||||
assert.equal(stable.desktopConfig.getAppUrl(), 'https://fluxer.com/app');
|
||||
assert.equal(canary.desktopConfig.getAppUrl(), 'https://canary.fluxer.com/app');
|
||||
});
|
||||
|
||||
test('loads the legacy root for a stored legacy origin', () => {
|
||||
const {desktopConfig} = loadDesktop({settings: {app_origin: 'https://web.fluxer.app'}});
|
||||
|
||||
assert.equal(desktopConfig.getAppUrl(), 'https://web.fluxer.app');
|
||||
});
|
||||
|
||||
test('drops stored origins outside the channel allowlist', () => {
|
||||
for (const appOrigin of [
|
||||
'https://canary.fluxer.com',
|
||||
'https://fluxer.com/',
|
||||
'https://evil.example',
|
||||
'http://fluxer.com',
|
||||
42,
|
||||
]) {
|
||||
const {desktopConfig} = loadDesktop({settings: {app_origin: appOrigin}});
|
||||
assert.equal(desktopConfig.getAppUrl(), 'https://web.fluxer.app');
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back from the migrated origin to the legacy root', () => {
|
||||
const stable = loadDesktop({settings: {app_origin: 'https://fluxer.com'}}).desktopConfig;
|
||||
const canary = loadDesktop({channel: 'canary', settings: {app_origin: 'https://canary.fluxer.com'}}).desktopConfig;
|
||||
|
||||
assert.equal(stable.getAppUrlFallback('https://fluxer.com/app'), 'https://web.fluxer.app');
|
||||
assert.equal(stable.getAppUrlFallback('https://fluxer.com/channels/@me'), 'https://web.fluxer.app');
|
||||
assert.equal(canary.getAppUrlFallback('https://canary.fluxer.com/app'), 'https://web.canary.fluxer.app');
|
||||
assert.equal(stable.getAppUrlFallback('https://web.fluxer.app/channels/@me'), null);
|
||||
assert.equal(stable.getAppUrlFallback('https://canary.fluxer.com/app'), null);
|
||||
assert.equal(stable.getAppUrlFallback('not a url'), null);
|
||||
});
|
||||
|
||||
test('the runtime override still wins over a stored origin', () => {
|
||||
const {desktopConfig} = loadDesktop({settings: {app_origin: 'https://fluxer.com'}});
|
||||
desktopConfig.setRuntimeAppUrlOverride('http://localhost:8088/');
|
||||
|
||||
assert.equal(desktopConfig.getAppUrl(), 'http://localhost:8088/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DomainMigration set app origin IPC', () => {
|
||||
test('persists an allowlisted origin from an official top-level frame', () => {
|
||||
const {desktopConfig, readSettings, setAppOrigin} = loadDesktop();
|
||||
|
||||
setAppOrigin(topLevelFrame('https://fluxer.com/migrate/complete'), 'https://fluxer.com');
|
||||
|
||||
assert.equal(readSettings().app_origin, 'https://fluxer.com');
|
||||
assert.equal(desktopConfig.getAppUrl(), 'https://fluxer.com/app');
|
||||
});
|
||||
|
||||
test('accepts the legacy frame of the running channel', () => {
|
||||
const {readSettings, setAppOrigin} = loadDesktop({channel: 'canary'});
|
||||
|
||||
setAppOrigin(topLevelFrame('https://web.canary.fluxer.app/channels/@me'), 'https://canary.fluxer.com');
|
||||
|
||||
assert.equal(readSettings().app_origin, 'https://canary.fluxer.com');
|
||||
});
|
||||
|
||||
test('rejects senders that are not official top-level frames of the channel', () => {
|
||||
const {setAppOrigin, settingsPath} = loadDesktop();
|
||||
const rejectedFrames = [
|
||||
null,
|
||||
topLevelFrame('https://evil.example/'),
|
||||
topLevelFrame('https://canary.fluxer.com/app'),
|
||||
{url: 'https://fluxer.com/app', detached: true, parent: null},
|
||||
{url: 'https://fluxer.com/app', detached: false, parent: topLevelFrame('https://fluxer.com/app')},
|
||||
topLevelFrame('not a url'),
|
||||
];
|
||||
for (const frame of rejectedFrames) {
|
||||
assert.throws(() => setAppOrigin(frame, 'https://fluxer.com'), /official app document/);
|
||||
}
|
||||
assert.equal(fs.existsSync(settingsPath), false);
|
||||
});
|
||||
|
||||
test('rejects origins outside the channel allowlist', () => {
|
||||
const {setAppOrigin, settingsPath} = loadDesktop();
|
||||
for (const origin of ['https://canary.fluxer.com', 'https://fluxer.com/app', 'https://evil.example', null]) {
|
||||
assert.throws(() => setAppOrigin(topLevelFrame('https://fluxer.com/app'), origin), /outside the allowlist/);
|
||||
}
|
||||
assert.equal(fs.existsSync(settingsPath), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getOfficialAppOrigins, setAppOrigin} from '@electron/common/DesktopConfig';
|
||||
import {createChildLogger} from '@electron/common/Logger';
|
||||
import {type IpcMainInvokeEvent, ipcMain} from 'electron';
|
||||
|
||||
const logger = createChildLogger('DomainMigration');
|
||||
|
||||
const DOMAIN_MIGRATION_SET_APP_ORIGIN_CHANNEL = 'domain-migration:set-app-origin';
|
||||
|
||||
function getTopLevelSenderOrigin(event: IpcMainInvokeEvent): string | null {
|
||||
const frame = event.senderFrame;
|
||||
if (frame == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (frame.detached || frame.parent != null) {
|
||||
return null;
|
||||
}
|
||||
return new URL(frame.url).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSetAppOrigin(event: IpcMainInvokeEvent, origin: unknown): void {
|
||||
const officialAppOrigins = getOfficialAppOrigins();
|
||||
const senderOrigin = getTopLevelSenderOrigin(event);
|
||||
if (senderOrigin === null || !officialAppOrigins.includes(senderOrigin)) {
|
||||
throw new Error(`${DOMAIN_MIGRATION_SET_APP_ORIGIN_CHANNEL} is only reachable from an official app document`);
|
||||
}
|
||||
if (typeof origin !== 'string' || !officialAppOrigins.includes(origin)) {
|
||||
throw new Error(`${DOMAIN_MIGRATION_SET_APP_ORIGIN_CHANNEL} received an origin outside the allowlist`);
|
||||
}
|
||||
if (!setAppOrigin(origin)) {
|
||||
throw new Error(`${DOMAIN_MIGRATION_SET_APP_ORIGIN_CHANNEL} could not store the app origin`);
|
||||
}
|
||||
logger.info('Stored app origin', {origin, senderOrigin});
|
||||
}
|
||||
|
||||
export function registerDomainMigrationHandlers(): void {
|
||||
ipcMain.handle(DOMAIN_MIGRATION_SET_APP_ORIGIN_CHANNEL, handleSetAppOrigin);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
hasActiveDesktopTray,
|
||||
updateTrayRuntimeState,
|
||||
} from '@electron/main/DesktopTray';
|
||||
import {registerDomainMigrationHandlers} from '@electron/main/DomainMigration';
|
||||
import {DownloadChecksumError, downloadFile} from '@electron/main/FileDownloads';
|
||||
import {
|
||||
type LinuxAppearanceSnapshot,
|
||||
@@ -136,6 +137,7 @@ function getActiveMiddleClickAutoscroll(): boolean {
|
||||
export function registerIpcHandlers(): void {
|
||||
registerVoiceDebugEventSinkPopoutIpcHandlers();
|
||||
registerVoiceBackgroundMediaCacheHandlers();
|
||||
registerDomainMigrationHandlers();
|
||||
ipcMain.handle('get-desktop-info', () => getDesktopInfo());
|
||||
ipcMain.handle('get-gpu-info', () => getGpuInfo());
|
||||
ipcMain.handle('get-app-metrics', () => getAppMetricsSnapshot());
|
||||
|
||||
@@ -5,13 +5,20 @@ import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {
|
||||
CANARY_APP_URL,
|
||||
CANARY_MIGRATED_APP_ORIGIN,
|
||||
DEFAULT_WINDOW_HEIGHT,
|
||||
DEFAULT_WINDOW_WIDTH,
|
||||
MIN_WINDOW_HEIGHT,
|
||||
MIN_WINDOW_WIDTH,
|
||||
STABLE_APP_URL,
|
||||
STABLE_MIGRATED_APP_ORIGIN,
|
||||
} from '@electron/common/Constants';
|
||||
import {getAppUrl, getCustomAppUrl, getDesktopWindowBehaviorSettings} from '@electron/common/DesktopConfig';
|
||||
import {
|
||||
getAppUrl,
|
||||
getAppUrlFallback,
|
||||
getCustomAppUrl,
|
||||
getDesktopWindowBehaviorSettings,
|
||||
} from '@electron/common/DesktopConfig';
|
||||
import {createChildLogger} from '@electron/common/Logger';
|
||||
import type {DesktopWindowBehaviorSettings} from '@electron/common/Types';
|
||||
import {
|
||||
@@ -60,7 +67,7 @@ const CUSTOM_TITLEBAR_TRAFFIC_LIGHT_POSITION = {
|
||||
y: Math.round((CUSTOM_TITLEBAR_HEIGHT_MAC - CUSTOM_TITLEBAR_TRAFFIC_LIGHT_DIAMETER) / 2),
|
||||
};
|
||||
const trustedWebOrigins = new Set(
|
||||
[STABLE_APP_URL, CANARY_APP_URL]
|
||||
[STABLE_APP_URL, CANARY_APP_URL, STABLE_MIGRATED_APP_ORIGIN, CANARY_MIGRATED_APP_ORIGIN]
|
||||
.map((url) => {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
@@ -726,7 +733,7 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
|
||||
const acceptFirstMouseOnFocus = isMac;
|
||||
initialUseNativeTitleBar = useNativeTitleBar;
|
||||
initialAllowTransparency = allowTransparency;
|
||||
const appUrl = getAppUrl();
|
||||
let appUrl = getAppUrl();
|
||||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
@@ -998,7 +1005,25 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
|
||||
});
|
||||
}, delay);
|
||||
};
|
||||
const fallBackFromMigratedAppOrigin = (failedUrl: string, detail: Record<string, unknown>): boolean => {
|
||||
const fallbackUrl = getAppUrlFallback(failedUrl);
|
||||
if (!mainWindow || mainWindow.isDestroyed() || fallbackUrl === null || appUrl === fallbackUrl) {
|
||||
return false;
|
||||
}
|
||||
logger.warn('Migrated app origin failed to load, falling back to the legacy app URL', {failedUrl, ...detail});
|
||||
appUrl = fallbackUrl;
|
||||
clearAppLoadRetry();
|
||||
mainWindow.loadURL(appUrl).catch((error) => {
|
||||
scheduleAppLoadRetry('legacy-fallback-load-url-rejected', {error});
|
||||
});
|
||||
return true;
|
||||
};
|
||||
webContents.on('did-finish-load', clearAppLoadRetry);
|
||||
webContents.on('did-navigate', (_event, url, httpResponseCode) => {
|
||||
if (httpResponseCode >= 400) {
|
||||
fallBackFromMigratedAppOrigin(url, {httpResponseCode});
|
||||
}
|
||||
});
|
||||
webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
if (isMainFrame) {
|
||||
logger.error('App main-frame load failed', {errorCode, errorDescription, validatedURL});
|
||||
@@ -1006,6 +1031,9 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
|
||||
if (!isMainFrame || !isTrustedOrigin(validatedURL) || !shouldRetryAppLoadFailure(errorCode)) {
|
||||
return;
|
||||
}
|
||||
if (fallBackFromMigratedAppOrigin(validatedURL, {errorCode, errorDescription})) {
|
||||
return;
|
||||
}
|
||||
scheduleAppLoadRetry('did-fail-load', {errorCode, errorDescription, validatedURL});
|
||||
});
|
||||
const loadAppUrl = (): void => {
|
||||
|
||||
@@ -451,6 +451,10 @@ const api: ElectronAPI = {
|
||||
options: PublicKeyCredentialCreationOptionsJSON,
|
||||
requestContext?: {pin?: string},
|
||||
): Promise<RegistrationResponseJSON> => ipcRenderer.invoke('passkey-register', options, requestContext),
|
||||
domainMigration: {
|
||||
version: 1,
|
||||
setAppOrigin: (origin: string): Promise<void> => ipcRenderer.invoke('domain-migration:set-app-origin', origin),
|
||||
},
|
||||
toggleDevTools: (): void => {
|
||||
ipcRenderer.send('toggle-devtools');
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user