Folo/apps/desktop/layer/main/src/manager/window.ts

437 lines
14 KiB
TypeScript

import { fileURLToPath } from "node:url"
import { is } from "@electron-toolkit/utils"
import { LEGACY_APP_PROTOCOL } from "@follow/shared"
import { callWindowExpose, WindowState } from "@follow/shared/bridge"
import { APP_PROTOCOL, DEV } from "@follow/shared/constants"
import type { BrowserWindowConstructorOptions } from "electron"
import { app, BrowserWindow, screen, shell } from "electron"
import type { Event } from "electron/main"
import path from "pathe"
import { START_IN_TRAY_ARGS } from "~/constants/app"
import { isMacOS, isWindows, isWindows11 } from "~/env"
import { filePathToAppUrl, getIconPath } from "~/helper"
import { t } from "~/lib/i18n"
import { store } from "~/lib/store"
import { getTrayConfig } from "~/lib/tray"
import { refreshBound } from "~/lib/utils"
import { logger } from "~/logger"
import { loadDynamicRenderEntry } from "~/updater/hot-updater"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
class WindowManagerStatic {
static get mainWindowDefaultSize() {
const primaryDisplay = screen.getPrimaryDisplay()
const { workAreaSize } = primaryDisplay
return {
height: workAreaSize.height,
width: workAreaSize.width,
}
}
// Window configuration properties for better DX
private readonly config = {
windowStateStoreKey: "windowState",
minWindowSize: {
width: 1024,
height: 500,
},
macOSTrafficLight: {
x: 18,
y: 18,
},
refreshBoundDelay: 1000,
devToolsFont: {
family:
'consolas, operator mono, Cascadia Code, OperatorMonoSSmLig Nerd Font, "Agave Nerd Font", "Cascadia Code PL", monospace',
size: "13px",
},
ignoreProtocols: [
"http",
"https",
LEGACY_APP_PROTOCOL,
APP_PROTOCOL,
"file",
"code",
"cursor",
"app",
] as const,
vibrancy: {
macOS: {
type: "sidebar" as const,
state: "followWindow" as const,
},
},
windowPreferences: {
preloadScript: path.join(__dirname, "../preload/index.mjs"),
},
} as const
readonly windowStateStoreKey = this.config.windowStateStoreKey
private windows = {
mainWindow: null as BrowserWindow | null,
}
private bindEvents(window: BrowserWindow) {
window.on("leave-html-full-screen", () => {
// To solve the vibrancy losing issue when leaving full screen mode
// @see https://github.com/toeverything/AFFiNE/blob/280e24934a27557529479a70ab38c4f5fc65cb00/packages/frontend/electron/src/main/windows-manager/main-window.ts:L157
refreshBound(window)
refreshBound(window, this.config.refreshBoundDelay)
})
window.on("ready-to-show", () => {
const shouldShowWindow =
!app.getLoginItemSettings().wasOpenedAsHidden && !process.argv.includes(START_IN_TRAY_ARGS)
if (shouldShowWindow) window.show()
})
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: "deny" }
})
const handleExternalProtocol = async (e: Event, url: string, window: BrowserWindow) => {
const { protocol } = new URL(url)
if (this.config.ignoreProtocols.includes(protocol.slice(0, -1) as any)) {
return
}
e.preventDefault()
const caller = callWindowExpose(window)
const confirm = await caller.dialog.ask({
title: t("dialog.openExternalApp.title"),
message: t("dialog.openExternalApp.message", {
url,
interpolation: { escapeValue: false },
}),
confirmText: t("dialog.open"),
cancelText: t("dialog.cancel"),
})
if (!confirm) {
return
}
shell.openExternal(url)
}
// Handle main window external links
window.webContents.on("will-navigate", (e, url) => handleExternalProtocol(e, url, window))
// Handle webview external links
window.webContents.on("did-attach-webview", (_, webContents) => {
webContents.on("will-navigate", (e, url) => handleExternalProtocol(e, url, window))
})
if (isWindows) {
// Change the default font-family and font-size of the devtools.
// Make it consistent with Chrome on Windows, instead of SimSun.
// ref: [[Feature Request]: Add possibility to change DevTools font · Issue #42055 · electron/electron](https://github.com/electron/electron/issues/42055)
window.webContents.on("devtools-opened", () => {
this.setupDevToolsFont(window)
})
}
this.bindWindowStateEvents(window)
}
private setupDevToolsFont(window: BrowserWindow) {
// source-code-font: For code such as Elements panel
// monospace-font: For sidebar such as Event Listener Panel
const css = `:root {--devtool-font-family: ${this.config.devToolsFont.family};--source-code-font-family:var(--devtool-font-family);--source-code-font-size: ${this.config.devToolsFont.size};--monospace-font-family: var(--devtool-font-family);--monospace-font-size: ${this.config.devToolsFont.size};}`
const js = `
const overriddenStyle = document.createElement('style');
overriddenStyle.innerHTML = '${css.replaceAll("\n", " ")}';
document.body.append(overriddenStyle);
document.querySelectorAll('.platform-windows').forEach(el => el.classList.remove('platform-windows'));
addStyleToAutoComplete();
const observer = new MutationObserver((mutationList, observer) => {
for (const mutation of mutationList) {
if (mutation.type === 'childList') {
for (let i = 0; i < mutation.addedNodes.length; i++) {
const item = mutation.addedNodes[i];
if (item instanceof HTMLElement && item.classList.contains('editor-tooltip-host')) {
addStyleToAutoComplete();
}
}
}
}
});
observer.observe(document.body, {childList: true});
function addStyleToAutoComplete() {
document.querySelectorAll('.editor-tooltip-host').forEach(element => {
if (element.shadowRoot && element.shadowRoot.querySelectorAll('[data-key="overridden-dev-tools-font"]').length === 0) {
const overriddenStyle = document.createElement('style');
overriddenStyle.setAttribute('data-key', 'overridden-dev-tools-font');
overriddenStyle.innerHTML = '.cm-tooltip-autocomplete ul[role=listbox] {font-family: consolas !important;}';
element.shadowRoot.append(overriddenStyle);
}
});
}
`
window.webContents.devToolsWebContents?.executeJavaScript(js)
}
private bindWindowStateEvents(window: BrowserWindow) {
// async render and main state
window.on("maximize", async () => {
const caller = callWindowExpose(window)
await caller.setWindowState(WindowState.MAXIMIZED)
})
window.on("unmaximize", async () => {
const caller = callWindowExpose(window)
await caller.setWindowState(WindowState.NORMAL)
})
window.on("minimize", async () => {
const caller = callWindowExpose(window)
await caller.setWindowState(WindowState.MINIMIZED)
})
window.on("restore", async () => {
const caller = callWindowExpose(window)
await caller.setWindowState(WindowState.NORMAL)
})
}
private bindMainWindowCloseHandlers(window: BrowserWindow) {
window.on("close", () => {
if (isWindows11) {
const windowStoreKey = Symbol.for("maximized")
if (window[windowStoreKey]) {
const stored = window[windowStoreKey]
store.set(this.windowStateStoreKey, {
width: stored.size[0],
height: stored.size[1],
x: stored.position[0],
y: stored.position[1],
})
return
}
}
const bounds = window.getBounds()
store.set(this.windowStateStoreKey, {
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
})
})
window.on("close", (event) => {
const minimizeToTray = getTrayConfig()
if (isMacOS || minimizeToTray) {
event.preventDefault()
if (window.isFullScreen()) {
window.once("leave-full-screen", () => {
window.hide()
})
window.setFullScreen(false)
} else {
window.hide()
}
const caller = callWindowExpose(window)
caller.onWindowClose()
} else {
this.windows.mainWindow = null
}
})
}
private getPlatformSpecificWindowConfig(): Partial<BrowserWindowConstructorOptions> {
const { platform } = process
switch (platform) {
case "darwin": {
return {
titleBarStyle: "hiddenInset",
trafficLightPosition: {
x: this.config.macOSTrafficLight.x,
y: this.config.macOSTrafficLight.y,
},
vibrancy: this.config.vibrancy.macOS.type,
visualEffectState: this.config.vibrancy.macOS.state,
transparent: true,
}
}
case "win32": {
return {
icon: getIconPath(),
titleBarStyle: "hidden",
// Electron material bug, comment this for now
// backgroundMaterial: isWindows11 ? "mica" : undefined,
frame: true,
}
}
default: {
return {
icon: getIconPath(),
}
}
}
}
createWindow = (
options: {
extraPath?: string
height: number
width: number
} & BrowserWindowConstructorOptions,
) => {
const { extraPath, height, width, ...configs } = options
const baseWindowConfig: Electron.BrowserWindowConstructorOptions = {
width,
height,
show: false,
resizable: configs?.resizable ?? true,
autoHideMenuBar: true,
alwaysOnTop: false,
webPreferences: {
preload: this.config.windowPreferences.preloadScript,
sandbox: false,
webviewTag: true,
webSecurity: !DEV,
nodeIntegration: true,
contextIsolation: false,
},
...this.getPlatformSpecificWindowConfig(),
}
// Create the browser window.
const window = new BrowserWindow({
...baseWindowConfig,
...configs,
})
this.bindEvents(window)
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
window.loadURL(process.env["ELECTRON_RENDERER_URL"] + (options?.extraPath || ""))
logger.log(process.env["ELECTRON_RENDERER_URL"] + (options?.extraPath || ""))
} else {
// Production entry
const dynamicRenderEntry = loadDynamicRenderEntry()
logger.info("load dynamic render entry", dynamicRenderEntry)
const appLoadFileEntry =
dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html")
const appLoadEntry = `${filePathToAppUrl(appLoadFileEntry)}${options?.extraPath || ""}`
window.loadURL(appLoadEntry)
logger.log("load URL", appLoadEntry)
}
return window
}
private ensureWindowBoundsInScreen(windowState?: {
width?: number
height?: number
x?: number
y?: number
}) {
const primaryDisplay = screen.getPrimaryDisplay()
const { workArea } = primaryDisplay
const maxWidth = workArea.width
const maxHeight = workArea.height
const defaultSize = WindowManagerStatic.mainWindowDefaultSize
const width = windowState?.width ? Math.min(windowState.width, maxWidth) : defaultSize.width
const height = windowState?.height
? Math.min(windowState.height, maxHeight)
: defaultSize.height
const ensureInBounds = (value: number, min: number, max: number): number => {
return Math.max(min, Math.min(value, max))
}
const x =
windowState?.x !== undefined
? ensureInBounds(windowState.x, workArea.x, workArea.x + workArea.width - width)
: undefined
const y =
windowState?.y !== undefined
? ensureInBounds(windowState.y, workArea.y, workArea.y + workArea.height - height)
: undefined
return { width, height, x, y, maxWidth, maxHeight }
}
createMainWindow = () => {
const windowState = store.get(this.windowStateStoreKey) as
| {
width?: number
height?: number
x?: number
y?: number
}
| undefined
const { width, height, x, y, maxWidth, maxHeight } =
this.ensureWindowBoundsInScreen(windowState)
const window = this.createWindow({
width,
height,
x,
y,
minWidth: Math.min(this.config.minWindowSize.width, maxWidth),
minHeight: Math.min(this.config.minWindowSize.height, maxHeight),
})
this.bindMainWindowCloseHandlers(window)
this.windows.mainWindow = window
return window
}
showSetting = (path?: string) => {
// We need to open the setting modal in the main window when the main window exists,
// if we open a new window then the state between the two windows will be out of sync.
if (this.windows.mainWindow) {
if (this.windows.mainWindow.isMinimized()) {
this.windows.mainWindow.restore()
}
this.windows.mainWindow.show()
callWindowExpose(this.windows.mainWindow).showSetting(path)
return
} else {
this.windows.mainWindow = this.createMainWindow()
this.windows.mainWindow.show()
callWindowExpose(this.windows.mainWindow).showSetting(path)
}
}
getMainWindow = () => this.windows.mainWindow
getMainWindowOrCreate = () => {
if (!this.windows.mainWindow) {
return this.createMainWindow()
}
return this.windows.mainWindow
}
destroyMainWindow = () => {
this.windows.mainWindow?.destroy()
this.windows.mainWindow = null
}
}
export const WindowManager = new WindowManagerStatic()