snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export interface AuthenticationRequest {
|
||||
method: 'magic-link' | 'oauth' | 'open-app-signin';
|
||||
payload: Record<string, any>;
|
||||
server?: string;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Menu } from 'electron';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
addTab,
|
||||
closeTab,
|
||||
reloadView,
|
||||
type TabAction,
|
||||
WebContentViewsManager,
|
||||
} from './tab-views';
|
||||
|
||||
export const showTabContextMenu = async (
|
||||
tabId: string,
|
||||
viewIndex: number
|
||||
): Promise<TabAction | null> => {
|
||||
const workbenches = WebContentViewsManager.instance.tabViewsMeta.workbenches;
|
||||
const tabMeta = workbenches.find(w => w.id === tabId);
|
||||
if (!tabMeta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { resolve, promise } = Promise.withResolvers<TabAction | null>();
|
||||
|
||||
const template: Parameters<typeof Menu.buildFromTemplate>[0] = [
|
||||
tabMeta.pinned
|
||||
? {
|
||||
label: 'Unpin tab',
|
||||
click: () => {
|
||||
WebContentViewsManager.instance.pinTab(tabId, false);
|
||||
},
|
||||
}
|
||||
: {
|
||||
label: 'Pin tab',
|
||||
click: () => {
|
||||
WebContentViewsManager.instance.pinTab(tabId, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Refresh tab',
|
||||
click: () => {
|
||||
reloadView().catch(err => logger.error(err));
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Duplicate tab',
|
||||
click: () => {
|
||||
addTab({
|
||||
basename: tabMeta.basename,
|
||||
view: tabMeta.views,
|
||||
show: false,
|
||||
}).catch(err => logger.error(err));
|
||||
},
|
||||
},
|
||||
|
||||
{ type: 'separator' },
|
||||
|
||||
tabMeta.views.length > 1
|
||||
? {
|
||||
label: 'Separate tabs',
|
||||
click: () => {
|
||||
WebContentViewsManager.instance.separateView(tabId, viewIndex);
|
||||
},
|
||||
}
|
||||
: {
|
||||
label: 'Open in split view',
|
||||
click: () => {
|
||||
WebContentViewsManager.instance.openInSplitView({ tabId });
|
||||
},
|
||||
},
|
||||
|
||||
...(workbenches.length > 1
|
||||
? ([
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Close tab',
|
||||
click: () => {
|
||||
closeTab(tabId).catch(err => logger.error(err));
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Close other tabs',
|
||||
click: () => {
|
||||
const tabsToRetain =
|
||||
WebContentViewsManager.instance.tabViewsMeta.workbenches.filter(
|
||||
w => w.id === tabId || w.pinned
|
||||
);
|
||||
|
||||
WebContentViewsManager.instance.patchTabViewsMeta({
|
||||
workbenches: tabsToRetain,
|
||||
activeWorkbenchId: tabId,
|
||||
});
|
||||
},
|
||||
},
|
||||
] as const)
|
||||
: []),
|
||||
];
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
menu.popup();
|
||||
let unsub: (() => void) | undefined;
|
||||
const subscription = WebContentViewsManager.instance.tabAction$.subscribe(
|
||||
action => {
|
||||
resolve(action);
|
||||
unsub?.();
|
||||
}
|
||||
);
|
||||
menu.on('menu-will-close', () => {
|
||||
setTimeout(() => {
|
||||
resolve(null);
|
||||
unsub?.();
|
||||
});
|
||||
});
|
||||
unsub = () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
return promise;
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { BrowserWindow, type Display, screen } from 'electron';
|
||||
|
||||
import { customThemeViewUrl } from '../../shared/internal-origin';
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { logger } from '../logger';
|
||||
import { buildWebPreferences } from '../web-preferences';
|
||||
|
||||
let customThemeWindow: Promise<BrowserWindow> | undefined;
|
||||
|
||||
const getScreenSize = (display: Display) => {
|
||||
const { width, height } = isMacOS() ? display.bounds : display.workArea;
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
async function createCustomThemeWindow(additionalArguments: string[]) {
|
||||
logger.info('creating custom theme window');
|
||||
|
||||
const { width: maxWidth, height: maxHeight } = getScreenSize(
|
||||
screen.getPrimaryDisplay()
|
||||
);
|
||||
|
||||
const browserWindow = new BrowserWindow({
|
||||
width: Math.min(maxWidth, 800),
|
||||
height: Math.min(maxHeight, 600),
|
||||
resizable: true,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
webPreferences: buildWebPreferences({
|
||||
webgl: true,
|
||||
preload: join(__dirname, './preload.js'),
|
||||
additionalArguments: additionalArguments,
|
||||
}),
|
||||
});
|
||||
|
||||
await browserWindow.loadURL(customThemeViewUrl);
|
||||
|
||||
browserWindow.on('closed', () => {
|
||||
customThemeWindow = undefined;
|
||||
});
|
||||
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
const getWindowAdditionalArguments = async () => {
|
||||
const { getExposedMeta } = await import('../exposed');
|
||||
const mainExposedMeta = getExposedMeta();
|
||||
return [
|
||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||
`--window-name=theme-editor`,
|
||||
];
|
||||
};
|
||||
|
||||
export async function getOrCreateCustomThemeWindow() {
|
||||
const additionalArguments = await getWindowAdditionalArguments();
|
||||
if (
|
||||
!customThemeWindow ||
|
||||
(await customThemeWindow.then(w => w.isDestroyed()))
|
||||
) {
|
||||
customThemeWindow = createCustomThemeWindow(additionalArguments);
|
||||
}
|
||||
|
||||
return customThemeWindow;
|
||||
}
|
||||
|
||||
export async function getCustomThemeWindow() {
|
||||
if (!customThemeWindow) return;
|
||||
const window = await customThemeWindow;
|
||||
if (window.isDestroyed()) return;
|
||||
return window;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './authentication';
|
||||
export * from './launcher';
|
||||
export * from './main-window';
|
||||
export * from './onboarding';
|
||||
export * from './stage';
|
||||
export * from './tab-views';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { logger } from '../logger';
|
||||
import { initAndShowMainWindow } from './main-window';
|
||||
|
||||
/**
|
||||
* Launch app depending on launch stage
|
||||
*/
|
||||
export async function launch() {
|
||||
initAndShowMainWindow().catch(e => {
|
||||
logger.error('Failed to restore or create window:', e);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, nativeTheme } from 'electron';
|
||||
import electronWindowState from 'electron-window-state';
|
||||
import { BehaviorSubject, map, shareReplay } from 'rxjs';
|
||||
|
||||
import { mainWindowOrigin } from '../../shared/internal-origin';
|
||||
import { isLinux, isMacOS, isWindows, resourcesPath } from '../../shared/utils';
|
||||
import { beforeAppQuit } from '../cleanup';
|
||||
import { buildType } from '../config';
|
||||
import { ensureHelperProcess } from '../helper-process';
|
||||
import { logger } from '../logger';
|
||||
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
|
||||
import { globalStateStorage } from '../shared-storage/storage';
|
||||
import { uiSubjects } from '../ui/subject';
|
||||
import { buildWebPreferences } from '../web-preferences';
|
||||
|
||||
const IS_DEV: boolean =
|
||||
process.env.NODE_ENV === 'development' && !process.env.CI;
|
||||
const DOCK_VISIBILITY_THROTTLE_MS = 1100;
|
||||
const FULL_SCREEN_EXIT_TIMEOUT_MS = 2000;
|
||||
|
||||
const TraySettingsState = {
|
||||
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
|
||||
map(v => MenubarStateSchema.parse(v ?? {})),
|
||||
shareReplay(1)
|
||||
),
|
||||
|
||||
get value() {
|
||||
return MenubarStateSchema.parse(
|
||||
globalStateStorage.get(MenubarStateKey) ?? {}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function closeAllWindows() {
|
||||
BrowserWindow.getAllWindows().forEach(w => {
|
||||
if (!w.isDestroyed()) {
|
||||
w.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export class MainWindowManager {
|
||||
static readonly instance = new MainWindowManager();
|
||||
mainWindowReady: Promise<BrowserWindow> | undefined;
|
||||
mainWindow$ = new BehaviorSubject<BrowserWindow | undefined>(undefined);
|
||||
|
||||
private backgroundRequested = false;
|
||||
private hiddenMacWindow: BrowserWindow | undefined;
|
||||
private lastDockShowAt = 0;
|
||||
private pendingDockHide: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
private constructor() {
|
||||
const traySettingsSubscription = TraySettingsState.$.subscribe(state => {
|
||||
if (!state.enabled || !state.closeToTray) {
|
||||
void this.ensureDockVisible().catch(err => {
|
||||
logger.error('Failed to restore Dock visibility:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
beforeAppQuit(() => {
|
||||
traySettingsSubscription.unsubscribe();
|
||||
this.cancelPendingDockHide();
|
||||
this.cleanupWindows();
|
||||
});
|
||||
}
|
||||
|
||||
get mainWindow() {
|
||||
return this.mainWindow$.value;
|
||||
}
|
||||
|
||||
// #region private methods
|
||||
private preventMacAppQuit() {
|
||||
if (!this.hiddenMacWindow && isMacOS()) {
|
||||
this.hiddenMacWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 100,
|
||||
height: 100,
|
||||
webPreferences: buildWebPreferences(),
|
||||
});
|
||||
this.hiddenMacWindow.on('close', () => {
|
||||
this.cleanupWindows();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupWindows() {
|
||||
this.cancelPendingDockHide();
|
||||
closeAllWindows();
|
||||
this.mainWindowReady = undefined;
|
||||
this.mainWindow$.next(undefined);
|
||||
this.hiddenMacWindow?.destroy();
|
||||
this.hiddenMacWindow = undefined;
|
||||
}
|
||||
|
||||
private cancelPendingDockHide() {
|
||||
if (this.pendingDockHide) {
|
||||
clearTimeout(this.pendingDockHide);
|
||||
this.pendingDockHide = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldHideDock() {
|
||||
const settings = TraySettingsState.value;
|
||||
return (
|
||||
isMacOS() &&
|
||||
this.backgroundRequested &&
|
||||
settings.enabled &&
|
||||
settings.closeToTray
|
||||
);
|
||||
}
|
||||
|
||||
private async showDock() {
|
||||
this.cancelPendingDockHide();
|
||||
if (app.isReady() && app.dock && !app.dock.isVisible()) {
|
||||
await app.dock.show();
|
||||
this.lastDockShowAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleDockHide(mainWindow: BrowserWindow) {
|
||||
this.cancelPendingDockHide();
|
||||
if (!app.dock || !this.shouldHideDock()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hideDock = () => {
|
||||
this.pendingDockHide = undefined;
|
||||
if (
|
||||
this.shouldHideDock() &&
|
||||
!mainWindow.isDestroyed() &&
|
||||
!mainWindow.isVisible()
|
||||
) {
|
||||
app.dock?.hide();
|
||||
}
|
||||
};
|
||||
const delay =
|
||||
DOCK_VISIBILITY_THROTTLE_MS - (Date.now() - this.lastDockShowAt);
|
||||
if (delay > 0) {
|
||||
this.pendingDockHide = setTimeout(hideDock, delay);
|
||||
} else {
|
||||
hideDock();
|
||||
}
|
||||
}
|
||||
|
||||
private async hideMainWindow(mainWindow: BrowserWindow) {
|
||||
this.backgroundRequested = true;
|
||||
this.cancelPendingDockHide();
|
||||
|
||||
if (mainWindow.isFullScreen()) {
|
||||
await new Promise<void>(resolve => {
|
||||
const done = () => {
|
||||
clearTimeout(timeout);
|
||||
mainWindow.removeListener('leave-full-screen', done);
|
||||
mainWindow.removeListener('closed', done);
|
||||
resolve();
|
||||
};
|
||||
const timeout = setTimeout(done, FULL_SCREEN_EXIT_TIMEOUT_MS);
|
||||
mainWindow.once('leave-full-screen', done);
|
||||
mainWindow.once('closed', done);
|
||||
mainWindow.setFullScreen(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.backgroundRequested && !mainWindow.isDestroyed()) {
|
||||
mainWindow.hide();
|
||||
this.scheduleDockHide(mainWindow);
|
||||
}
|
||||
}
|
||||
|
||||
private async createMainWindow() {
|
||||
logger.info('create window');
|
||||
const mainWindowState = electronWindowState({
|
||||
defaultWidth: 1000,
|
||||
defaultHeight: 800,
|
||||
});
|
||||
|
||||
const browserWindow = new BrowserWindow({
|
||||
titleBarStyle: isMacOS()
|
||||
? 'hiddenInset'
|
||||
: isWindows()
|
||||
? 'hidden'
|
||||
: 'default',
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
width: mainWindowState.width,
|
||||
autoHideMenuBar: isLinux(),
|
||||
minWidth: 640,
|
||||
minHeight: 480,
|
||||
visualEffectState: 'active',
|
||||
vibrancy: 'under-window',
|
||||
// backgroundMaterial: 'mica',
|
||||
height: mainWindowState.height,
|
||||
show: false, // Use 'ready-to-show' event to show window
|
||||
webPreferences: buildWebPreferences({
|
||||
webgl: true,
|
||||
}),
|
||||
});
|
||||
const helper = await ensureHelperProcess();
|
||||
helper.connectMain(browserWindow);
|
||||
|
||||
if (isLinux()) {
|
||||
browserWindow.setIcon(
|
||||
// __dirname is `packages/frontend/apps/electron/dist` (the bundled output directory)
|
||||
join(resourcesPath, `icons/icon_${buildType}_64x64.png`)
|
||||
);
|
||||
}
|
||||
|
||||
nativeTheme.themeSource = 'light';
|
||||
mainWindowState.manage(browserWindow);
|
||||
|
||||
this.bindEvents(browserWindow);
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
private bindEvents(mainWindow: BrowserWindow) {
|
||||
/**
|
||||
* If you install `show: true` then it can cause issues when trying to close the window.
|
||||
* Use `show: false` and listener events `ready-to-show` to fix these issues.
|
||||
*
|
||||
* @see https://github.com/electron/electron/issues/25012
|
||||
*/
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
logger.info('main window is ready to show');
|
||||
|
||||
uiSubjects.onMaximized$.next(mainWindow.isMaximized());
|
||||
uiSubjects.onFullScreen$.next(mainWindow.isFullScreen());
|
||||
});
|
||||
|
||||
mainWindow.on('close', e => {
|
||||
// TODO(@pengx17): gracefully close the app, for example, ask user to save unsaved changes
|
||||
e.preventDefault();
|
||||
if (!isMacOS()) {
|
||||
if (
|
||||
TraySettingsState.value.enabled &&
|
||||
TraySettingsState.value.closeToTray
|
||||
) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
closeAllWindows();
|
||||
this.mainWindowReady = undefined;
|
||||
this.mainWindow$.next(undefined);
|
||||
}
|
||||
} else {
|
||||
void this.hideMainWindow(mainWindow).catch(err => {
|
||||
logger.error('Failed to hide main window:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const refreshBound = (timeout = 0) => {
|
||||
setTimeout(() => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
// FIXME: workaround for theme bug in full screen mode
|
||||
const size = mainWindow.getSize();
|
||||
mainWindow.setSize(size[0] + 1, size[1] + 1);
|
||||
mainWindow.setSize(size[0], size[1]);
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
// seems call this too soon may cause the app to crash
|
||||
refreshBound();
|
||||
refreshBound(1000);
|
||||
uiSubjects.onMaximized$.next(false);
|
||||
uiSubjects.onFullScreen$.next(false);
|
||||
});
|
||||
|
||||
mainWindow.on('maximize', () => {
|
||||
uiSubjects.onMaximized$.next(true);
|
||||
});
|
||||
|
||||
mainWindow.on('unmaximize', () => {
|
||||
uiSubjects.onMaximized$.next(false);
|
||||
});
|
||||
|
||||
// full-screen == maximized in UI on windows
|
||||
mainWindow.on('enter-full-screen', () => {
|
||||
uiSubjects.onFullScreen$.next(true);
|
||||
});
|
||||
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
uiSubjects.onFullScreen$.next(false);
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
|
||||
async ensureMainWindow(): Promise<BrowserWindow> {
|
||||
if (
|
||||
!this.mainWindowReady ||
|
||||
(await this.mainWindowReady.then(w => w.isDestroyed()))
|
||||
) {
|
||||
this.mainWindowReady = this.createMainWindow();
|
||||
this.mainWindow$.next(await this.mainWindowReady);
|
||||
this.preventMacAppQuit();
|
||||
}
|
||||
return this.mainWindowReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* Init main BrowserWindow. Will create a new window if it's not created yet.
|
||||
*/
|
||||
async initAndShowMainWindow() {
|
||||
const mainWindow = await this.ensureMainWindow();
|
||||
|
||||
if (IS_DEV) {
|
||||
// do not gain focus in dev mode
|
||||
this.backgroundRequested = false;
|
||||
await this.showDock();
|
||||
mainWindow.showInactive();
|
||||
} else if (
|
||||
!TraySettingsState.value.enabled ||
|
||||
!TraySettingsState.value.startMinimized
|
||||
) {
|
||||
await this.showMainWindow();
|
||||
}
|
||||
|
||||
this.preventMacAppQuit();
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
async showMainWindow() {
|
||||
this.backgroundRequested = false;
|
||||
const mainWindow = await this.ensureMainWindow();
|
||||
await this.showDock();
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
async closeMainWindowToBackground() {
|
||||
await this.hideMainWindow(await this.ensureMainWindow());
|
||||
}
|
||||
|
||||
async ensureDockVisible() {
|
||||
if (!this.shouldHideDock()) {
|
||||
await this.showDock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function initAndShowMainWindow() {
|
||||
return MainWindowManager.instance.initAndShowMainWindow();
|
||||
}
|
||||
|
||||
export async function getMainWindow() {
|
||||
return MainWindowManager.instance.ensureMainWindow();
|
||||
}
|
||||
|
||||
export async function showMainWindow() {
|
||||
return MainWindowManager.instance.showMainWindow();
|
||||
}
|
||||
|
||||
export async function closeMainWindowToBackground() {
|
||||
return MainWindowManager.instance.closeMainWindowToBackground();
|
||||
}
|
||||
|
||||
export async function ensureDockVisible() {
|
||||
return MainWindowManager.instance.ensureDockVisible();
|
||||
}
|
||||
|
||||
const getWindowAdditionalArguments = async () => {
|
||||
const { getExposedMeta } = await import('../exposed');
|
||||
const mainExposedMeta = getExposedMeta();
|
||||
return [
|
||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||
`--window-name=hidden-window`,
|
||||
];
|
||||
};
|
||||
|
||||
function transformToAppUrl(url: URL) {
|
||||
const params = url.searchParams;
|
||||
return mainWindowOrigin + url.pathname + '?' + params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in a hidden window.
|
||||
*/
|
||||
export async function openUrlInHiddenWindow(urlObj: URL) {
|
||||
const url = transformToAppUrl(urlObj);
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 600,
|
||||
webPreferences: buildWebPreferences({
|
||||
preload: join(__dirname, './preload.js'),
|
||||
additionalArguments: await getWindowAdditionalArguments(),
|
||||
}),
|
||||
show: BUILD_CONFIG.debug,
|
||||
});
|
||||
|
||||
if (BUILD_CONFIG.debug) {
|
||||
win.webContents.openDevTools();
|
||||
}
|
||||
|
||||
logger.info('loading page at', url);
|
||||
win.loadURL(url).catch(e => {
|
||||
logger.error('failed to load url', e);
|
||||
});
|
||||
return win;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { BrowserWindow, screen } from 'electron';
|
||||
|
||||
import { onboardingViewUrl } from '../../shared/internal-origin';
|
||||
import { isDev } from '../config';
|
||||
// import { getExposedMeta } from './exposed';
|
||||
import { logger } from '../logger';
|
||||
import { buildWebPreferences } from '../web-preferences';
|
||||
import { fullscreenAndCenter, getScreenSize } from './utils';
|
||||
|
||||
// todo: not all window need all of the exposed meta
|
||||
const getWindowAdditionalArguments = async () => {
|
||||
const { getExposedMeta } = await import('../exposed');
|
||||
const mainExposedMeta = getExposedMeta();
|
||||
return [
|
||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||
`--window-name=onboarding`,
|
||||
];
|
||||
};
|
||||
|
||||
async function createOnboardingWindow(additionalArguments: string[]) {
|
||||
logger.info('creating onboarding window');
|
||||
|
||||
// get user's screen size
|
||||
const { width, height } = getScreenSize(screen.getPrimaryDisplay());
|
||||
|
||||
const browserWindow = new BrowserWindow({
|
||||
width,
|
||||
height,
|
||||
frame: false,
|
||||
show: false,
|
||||
resizable: false,
|
||||
closable: false,
|
||||
minimizable: false,
|
||||
movable: false,
|
||||
titleBarStyle: 'hidden',
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
// skipTaskbar: true,
|
||||
transparent: true,
|
||||
hasShadow: false,
|
||||
roundedCorners: false,
|
||||
webPreferences: buildWebPreferences({
|
||||
webgl: true,
|
||||
preload: join(__dirname, './preload.js'),
|
||||
additionalArguments: additionalArguments,
|
||||
}),
|
||||
});
|
||||
|
||||
// workaround for the phantom title bar on windows when losing focus
|
||||
// see https://github.com/electron/electron/issues/39959#issuecomment-1758736966
|
||||
browserWindow.on('focus', () => {
|
||||
browserWindow.setBackgroundColor('#00000000');
|
||||
});
|
||||
|
||||
browserWindow.on('blur', () => {
|
||||
browserWindow.setBackgroundColor('#00000000');
|
||||
});
|
||||
|
||||
browserWindow.on('ready-to-show', () => {
|
||||
// forcing zoom factor to 1 to avoid onboarding display issues
|
||||
browserWindow.webContents.setZoomFactor(1);
|
||||
fullscreenAndCenter(browserWindow);
|
||||
// TODO(@catsjuice): add a timeout to avoid flickering, window is ready, but dom is not ready
|
||||
setTimeout(() => {
|
||||
browserWindow.show();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// When moved to another screen, resize to fit the screen
|
||||
browserWindow.on('moved', () => {
|
||||
fullscreenAndCenter(browserWindow);
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
browserWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
await browserWindow.loadURL(onboardingViewUrl);
|
||||
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
let onBoardingWindow: Promise<BrowserWindow> | undefined;
|
||||
|
||||
export async function getOrCreateOnboardingWindow() {
|
||||
const additionalArguments = await getWindowAdditionalArguments();
|
||||
if (
|
||||
!onBoardingWindow ||
|
||||
(await onBoardingWindow.then(w => w.isDestroyed()))
|
||||
) {
|
||||
onBoardingWindow = createOnboardingWindow(additionalArguments);
|
||||
}
|
||||
|
||||
return onBoardingWindow;
|
||||
}
|
||||
|
||||
export async function getOnboardingWindow() {
|
||||
if (!onBoardingWindow) return;
|
||||
const window = await onBoardingWindow;
|
||||
if (window.isDestroyed()) return;
|
||||
return window;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { join } from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { BrowserWindow, type BrowserWindowConstructorOptions } from 'electron';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { popupViewUrl } from '../../shared/internal-origin';
|
||||
import { logger } from '../logger';
|
||||
import type { MainEventRegister, NamespaceHandlers } from '../type';
|
||||
import { buildWebPreferences } from '../web-preferences';
|
||||
import { ensureDockVisible } from './main-window';
|
||||
import { getCurrentDisplay } from './utils';
|
||||
|
||||
type PopupWindowType = 'notification' | 'recording';
|
||||
|
||||
async function getAdditionalArguments(name: string) {
|
||||
const { getExposedMeta } = await import('../exposed');
|
||||
const mainExposedMeta = getExposedMeta();
|
||||
return [
|
||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||
`--window-name=${name}`,
|
||||
];
|
||||
}
|
||||
|
||||
const POPUP_PADDING = 20; // padding between the popup and the edge of the screen
|
||||
const NOTIFICATION_SIZE = [300, 128];
|
||||
const RECORDING_SIZE = [300, 36];
|
||||
|
||||
async function animate(
|
||||
current: number,
|
||||
target: number,
|
||||
setter: (val: number) => void,
|
||||
duration = 200,
|
||||
delay = 0
|
||||
): Promise<void> {
|
||||
const fps = 60;
|
||||
const steps = duration / (1000 / fps);
|
||||
const delta = target - current;
|
||||
const easing = (t: number) => -(Math.cos(Math.PI * t) - 1) / 2;
|
||||
|
||||
if (delay > 0) {
|
||||
await setTimeout(delay);
|
||||
}
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const progress = easing(i / steps);
|
||||
setter(current + delta * progress);
|
||||
await setTimeout(1000 / fps);
|
||||
}
|
||||
|
||||
// Ensure we hit the target exactly
|
||||
setter(target);
|
||||
}
|
||||
|
||||
abstract class PopupWindow {
|
||||
abstract readonly type: PopupWindowType;
|
||||
abstract readonly name: string;
|
||||
browserWindow: BrowserWindow | undefined;
|
||||
|
||||
abstract windowOptions: Partial<BrowserWindowConstructorOptions>;
|
||||
|
||||
ready = Promise.withResolvers<void>();
|
||||
|
||||
private readonly showing$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
get showing() {
|
||||
return this.showing$.value;
|
||||
}
|
||||
|
||||
async build(): Promise<BrowserWindow> {
|
||||
const browserWindow = new BrowserWindow({
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
closable: false,
|
||||
alwaysOnTop: true,
|
||||
hiddenInMissionControl: true,
|
||||
skipTaskbar: true,
|
||||
movable: false,
|
||||
titleBarStyle: 'hidden',
|
||||
show: false, // hide by default,
|
||||
backgroundColor: 'transparent',
|
||||
visualEffectState: 'active',
|
||||
vibrancy: 'under-window',
|
||||
...this.windowOptions,
|
||||
webPreferences: buildWebPreferences({
|
||||
webgl: true,
|
||||
transparent: true,
|
||||
spellcheck: false,
|
||||
preload: join(__dirname, './preload.js'), // this points to the bundled preload module
|
||||
...this.windowOptions.webPreferences,
|
||||
// serialize exposed meta that to be used in preload
|
||||
additionalArguments: await getAdditionalArguments(this.name),
|
||||
}),
|
||||
});
|
||||
|
||||
await ensureDockVisible();
|
||||
|
||||
// required to make the window transparent
|
||||
browserWindow.setBackgroundColor('#00000000');
|
||||
browserWindow.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
});
|
||||
|
||||
logger.info('loading popup', this.name, popupViewUrl);
|
||||
browserWindow.webContents.on('did-finish-load', () => {
|
||||
this.ready.resolve();
|
||||
logger.info('popup ready', this.name);
|
||||
});
|
||||
browserWindow.loadURL(popupViewUrl).catch(err => logger.error(err));
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
async show() {
|
||||
if (!this.browserWindow) {
|
||||
this.browserWindow = await this.build();
|
||||
}
|
||||
const browserWindow = this.browserWindow;
|
||||
const workArea = getCurrentDisplay(browserWindow).workArea;
|
||||
const popupSize = browserWindow.getSize();
|
||||
|
||||
await this.ready.promise;
|
||||
|
||||
this.showing$.next(true);
|
||||
|
||||
browserWindow.showInactive(); // focus the notification is too distracting right?
|
||||
browserWindow.setOpacity(0);
|
||||
|
||||
// Calculate start and end positions for x coordinate
|
||||
const startX = workArea.x + workArea.width + popupSize[0] + POPUP_PADDING;
|
||||
const endX = workArea.x + workArea.width - popupSize[0] - POPUP_PADDING;
|
||||
const y = workArea.y + POPUP_PADDING;
|
||||
|
||||
// Set initial position
|
||||
browserWindow.setPosition(startX, y);
|
||||
|
||||
logger.info('showing popup', this.name);
|
||||
|
||||
// First fade in, then slide
|
||||
await Promise.all([
|
||||
// Slide in animation
|
||||
animate(
|
||||
startX,
|
||||
endX,
|
||||
x => {
|
||||
browserWindow.setPosition(Math.round(x), y);
|
||||
},
|
||||
300
|
||||
),
|
||||
// Fade in animation
|
||||
animate(
|
||||
0,
|
||||
1,
|
||||
opacity => {
|
||||
this.browserWindow?.setOpacity(opacity);
|
||||
},
|
||||
100,
|
||||
100
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async hide() {
|
||||
if (!this.browserWindow) {
|
||||
return;
|
||||
}
|
||||
logger.info('hiding popup', this.name);
|
||||
this.showing$.next(false);
|
||||
await animate(this.browserWindow.getOpacity(), 0, opacity => {
|
||||
this.browserWindow?.setOpacity(opacity);
|
||||
});
|
||||
this.browserWindow?.hide();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.browserWindow?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// leave for future use
|
||||
type ElectronNotification = null;
|
||||
|
||||
class NotificationPopupWindow extends PopupWindow {
|
||||
readonly type = 'notification' as const;
|
||||
readonly name = `${this.type}`;
|
||||
|
||||
notification$ = new BehaviorSubject<ElectronNotification | null>(null);
|
||||
|
||||
windowOptions: Partial<BrowserWindowConstructorOptions> = {
|
||||
width: NOTIFICATION_SIZE[0],
|
||||
height: NOTIFICATION_SIZE[1],
|
||||
};
|
||||
|
||||
async notify(notification: ElectronNotification) {
|
||||
this.notification$.next(notification);
|
||||
await super.show();
|
||||
}
|
||||
}
|
||||
|
||||
// recording popup window is singleton across the app
|
||||
class RecordingPopupWindow extends PopupWindow {
|
||||
readonly type = 'recording' as const;
|
||||
readonly name = `${this.type}`;
|
||||
windowOptions: Partial<BrowserWindowConstructorOptions> = {
|
||||
width: RECORDING_SIZE[0],
|
||||
height: RECORDING_SIZE[1],
|
||||
movable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Type mapping from PopupWindowType to specific window class
|
||||
type PopupWindowTypeMap = {
|
||||
notification: NotificationPopupWindow;
|
||||
recording: RecordingPopupWindow;
|
||||
};
|
||||
|
||||
export class PopupManager {
|
||||
static readonly instance = new PopupManager();
|
||||
// there could be a single instance of each type of popup window
|
||||
readonly popupWindows$ = new BehaviorSubject<Map<string, PopupWindow>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
get<T extends PopupWindowType>(type: T): PopupWindowTypeMap[T] {
|
||||
// Check if popup of this type already exists
|
||||
const existingPopup = Array.from(this.popupWindows$.value.values()).find(
|
||||
popup => popup.type === type
|
||||
) as PopupWindowTypeMap[T] | undefined;
|
||||
|
||||
// If exists, return it
|
||||
if (existingPopup) {
|
||||
return existingPopup;
|
||||
}
|
||||
|
||||
// Otherwise create a new one
|
||||
const popupWindow = (() => {
|
||||
switch (type) {
|
||||
case 'notification':
|
||||
return new NotificationPopupWindow() as PopupWindowTypeMap[T];
|
||||
case 'recording':
|
||||
return new RecordingPopupWindow() as PopupWindowTypeMap[T];
|
||||
default:
|
||||
throw new Error(`Unknown popup type: ${type}`);
|
||||
}
|
||||
})();
|
||||
|
||||
this.popupWindows$.next(
|
||||
new Map(this.popupWindows$.value).set(popupWindow.type, popupWindow)
|
||||
);
|
||||
return popupWindow;
|
||||
}
|
||||
}
|
||||
|
||||
export const popupManager = PopupManager.instance;
|
||||
|
||||
// recording popup window events/handlers are in ../recording/index.ts
|
||||
export const popupHandlers = {
|
||||
getCurrentNotification: async () => {
|
||||
const notification = popupManager.get('notification').notification$.value;
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
return notification;
|
||||
},
|
||||
dismissCurrentNotification: async () => {
|
||||
return popupManager.get('notification').hide();
|
||||
},
|
||||
dismissCurrentRecording: async () => {
|
||||
return popupManager.get('recording').hide();
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
|
||||
export const popupEvents = {
|
||||
onNotificationChanged: (
|
||||
callback: (notification: ElectronNotification | null) => void
|
||||
) => {
|
||||
const notification = popupManager.get('notification');
|
||||
const sub = notification.notification$.subscribe(notification => {
|
||||
callback(notification);
|
||||
});
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { persistentConfig } from '../config-storage/persist';
|
||||
import type { LaunchStage } from './types';
|
||||
|
||||
export const launchStage: { value: LaunchStage } = {
|
||||
value: persistentConfig.get('onBoarding') ? 'onboarding' : 'main',
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export type LaunchStage = 'main' | 'onboarding';
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BrowserWindow, type Display, type Rectangle, screen } from 'electron';
|
||||
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
|
||||
export const getCurrentDisplay = (browserWindow: BrowserWindow) => {
|
||||
const position = browserWindow.getPosition();
|
||||
const size = browserWindow.getSize();
|
||||
const currentDisplay = screen.getDisplayNearestPoint({
|
||||
x: position[0] + size[0] / 2,
|
||||
y: position[1] + size[1] / 2,
|
||||
});
|
||||
return currentDisplay;
|
||||
};
|
||||
|
||||
export const getScreenSize = (display: Display | BrowserWindow): Rectangle => {
|
||||
if (display instanceof BrowserWindow) {
|
||||
return getScreenSize(getCurrentDisplay(display));
|
||||
}
|
||||
return isMacOS() ? display.bounds : display.workArea;
|
||||
};
|
||||
|
||||
export const fullscreenAndCenter = (browserWindow: BrowserWindow) => {
|
||||
const currentDisplay = getCurrentDisplay(browserWindow);
|
||||
const { width, height } = getScreenSize(currentDisplay);
|
||||
browserWindow.setSize(width, height);
|
||||
browserWindow.center();
|
||||
};
|
||||
Reference in New Issue
Block a user