feat(core): initialize project skeleton with Electron + React + TypeScript
Set up the complete project foundation for ClawX, a graphical AI assistant: - Electron main process with IPC handlers, menu, tray, and gateway management - React renderer with routing, layout components, and page scaffolding - Zustand state management for gateway, settings, channels, skills, chat, and cron - shadcn/ui components with Tailwind CSS and CSS variable theming - Build tooling with Vite, electron-builder, and TypeScript configuration - Testing setup with Vitest and Playwright - Development configurations (ESLint, Prettier, gitignore, env example)
This commit is contained in:
118
electron/main/index.ts
Normal file
118
electron/main/index.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Electron Main Process Entry
|
||||
* Manages window creation, system tray, and IPC handlers
|
||||
*/
|
||||
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import { registerIpcHandlers } from './ipc-handlers';
|
||||
import { createTray } from './tray';
|
||||
import { createMenu } from './menu';
|
||||
import { PORTS } from '../utils/config';
|
||||
|
||||
// Disable GPU acceleration for better compatibility
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
// Global references
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
const gatewayManager = new GatewayManager();
|
||||
|
||||
/**
|
||||
* Create the main application window
|
||||
*/
|
||||
function createWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
},
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
show: false,
|
||||
});
|
||||
|
||||
// Show window when ready to prevent visual flash
|
||||
win.once('ready-to-show', () => {
|
||||
win.show();
|
||||
});
|
||||
|
||||
// Handle external links
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// Load the app
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(process.env.VITE_DEV_SERVER_URL);
|
||||
// Open DevTools in development
|
||||
win.webContents.openDevTools();
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../../dist/index.html'));
|
||||
}
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the application
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
// Set application menu
|
||||
createMenu();
|
||||
|
||||
// Create the main window
|
||||
mainWindow = createWindow();
|
||||
|
||||
// Create system tray
|
||||
createTray(mainWindow);
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(gatewayManager, mainWindow);
|
||||
|
||||
// Handle window close
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Start Gateway automatically (optional based on settings)
|
||||
try {
|
||||
await gatewayManager.start();
|
||||
console.log('Gateway started successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to start Gateway:', error);
|
||||
// Notify renderer about the error
|
||||
mainWindow?.webContents.send('gateway:error', String(error));
|
||||
}
|
||||
}
|
||||
|
||||
// Application lifecycle
|
||||
app.whenReady().then(initialize);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// On macOS, keep the app running in the menu bar
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
// On macOS, re-create window when dock icon is clicked
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
// Clean up Gateway process
|
||||
await gatewayManager.stop();
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
export { mainWindow, gatewayManager };
|
||||
172
electron/main/ipc-handlers.ts
Normal file
172
electron/main/ipc-handlers.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* IPC Handlers
|
||||
* Registers all IPC handlers for main-renderer communication
|
||||
*/
|
||||
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
*/
|
||||
export function registerIpcHandlers(
|
||||
gatewayManager: GatewayManager,
|
||||
mainWindow: BrowserWindow
|
||||
): void {
|
||||
// Gateway handlers
|
||||
registerGatewayHandlers(gatewayManager, mainWindow);
|
||||
|
||||
// Shell handlers
|
||||
registerShellHandlers();
|
||||
|
||||
// Dialog handlers
|
||||
registerDialogHandlers();
|
||||
|
||||
// App handlers
|
||||
registerAppHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway-related IPC handlers
|
||||
*/
|
||||
function registerGatewayHandlers(
|
||||
gatewayManager: GatewayManager,
|
||||
mainWindow: BrowserWindow
|
||||
): void {
|
||||
// Get Gateway status
|
||||
ipcMain.handle('gateway:status', () => {
|
||||
return gatewayManager.getStatus();
|
||||
});
|
||||
|
||||
// Start Gateway
|
||||
ipcMain.handle('gateway:start', async () => {
|
||||
try {
|
||||
await gatewayManager.start();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Stop Gateway
|
||||
ipcMain.handle('gateway:stop', async () => {
|
||||
try {
|
||||
await gatewayManager.stop();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Restart Gateway
|
||||
ipcMain.handle('gateway:restart', async () => {
|
||||
try {
|
||||
await gatewayManager.stop();
|
||||
await gatewayManager.start();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Gateway RPC call
|
||||
ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown) => {
|
||||
try {
|
||||
const result = await gatewayManager.rpc(method, params);
|
||||
return { success: true, result };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Forward Gateway status events to renderer
|
||||
gatewayManager.on('status', (status) => {
|
||||
mainWindow.webContents.send('gateway:status-changed', status);
|
||||
});
|
||||
|
||||
gatewayManager.on('message', (message) => {
|
||||
mainWindow.webContents.send('gateway:message', message);
|
||||
});
|
||||
|
||||
gatewayManager.on('exit', (code) => {
|
||||
mainWindow.webContents.send('gateway:exit', code);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-related IPC handlers
|
||||
*/
|
||||
function registerShellHandlers(): void {
|
||||
// Open external URL
|
||||
ipcMain.handle('shell:openExternal', async (_, url: string) => {
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
|
||||
// Open path in file explorer
|
||||
ipcMain.handle('shell:showItemInFolder', async (_, path: string) => {
|
||||
shell.showItemInFolder(path);
|
||||
});
|
||||
|
||||
// Open path
|
||||
ipcMain.handle('shell:openPath', async (_, path: string) => {
|
||||
return await shell.openPath(path);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog-related IPC handlers
|
||||
*/
|
||||
function registerDialogHandlers(): void {
|
||||
// Show open dialog
|
||||
ipcMain.handle('dialog:open', async (_, options: Electron.OpenDialogOptions) => {
|
||||
const result = await dialog.showOpenDialog(options);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Show save dialog
|
||||
ipcMain.handle('dialog:save', async (_, options: Electron.SaveDialogOptions) => {
|
||||
const result = await dialog.showSaveDialog(options);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Show message box
|
||||
ipcMain.handle('dialog:message', async (_, options: Electron.MessageBoxOptions) => {
|
||||
const result = await dialog.showMessageBox(options);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* App-related IPC handlers
|
||||
*/
|
||||
function registerAppHandlers(): void {
|
||||
// Get app version
|
||||
ipcMain.handle('app:version', () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
// Get app name
|
||||
ipcMain.handle('app:name', () => {
|
||||
return app.getName();
|
||||
});
|
||||
|
||||
// Get app path
|
||||
ipcMain.handle('app:getPath', (_, name: Parameters<typeof app.getPath>[0]) => {
|
||||
return app.getPath(name);
|
||||
});
|
||||
|
||||
// Get platform
|
||||
ipcMain.handle('app:platform', () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
// Quit app
|
||||
ipcMain.handle('app:quit', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
// Relaunch app
|
||||
ipcMain.handle('app:relaunch', () => {
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
201
electron/main/menu.ts
Normal file
201
electron/main/menu.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Application Menu Configuration
|
||||
* Creates the native application menu for macOS/Windows/Linux
|
||||
*/
|
||||
import { Menu, app, shell, BrowserWindow } from 'electron';
|
||||
|
||||
/**
|
||||
* Create application menu
|
||||
*/
|
||||
export function createMenu(): void {
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
// App menu (macOS only)
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' as const },
|
||||
{ type: 'separator' as const },
|
||||
{
|
||||
label: 'Preferences...',
|
||||
accelerator: 'Cmd+,',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'services' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'hide' as const },
|
||||
{ role: 'hideOthers' as const },
|
||||
{ role: 'unhide' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'quit' as const },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
// File menu
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'New Chat',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/chat');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
isMac ? { role: 'close' } : { role: 'quit' },
|
||||
],
|
||||
},
|
||||
|
||||
// Edit menu
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
...(isMac
|
||||
? [
|
||||
{ role: 'pasteAndMatchStyle' as const },
|
||||
{ role: 'delete' as const },
|
||||
{ role: 'selectAll' as const },
|
||||
]
|
||||
: [
|
||||
{ role: 'delete' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'selectAll' as const },
|
||||
]),
|
||||
],
|
||||
},
|
||||
|
||||
// View menu
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
},
|
||||
|
||||
// Navigate menu
|
||||
{
|
||||
label: 'Navigate',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
accelerator: 'CmdOrCtrl+1',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Chat',
|
||||
accelerator: 'CmdOrCtrl+2',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/chat');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Channels',
|
||||
accelerator: 'CmdOrCtrl+3',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/channels');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Skills',
|
||||
accelerator: 'CmdOrCtrl+4',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/skills');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Cron Tasks',
|
||||
accelerator: 'CmdOrCtrl+5',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/cron');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
accelerator: isMac ? 'Cmd+,' : 'Ctrl+,',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Window menu
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'front' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'window' as const },
|
||||
]
|
||||
: [{ role: 'close' as const }]),
|
||||
],
|
||||
},
|
||||
|
||||
// Help menu
|
||||
{
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Documentation',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://docs.clawx.app');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Report Issue',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://github.com/clawx/clawx/issues');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'OpenClaw Documentation',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://docs.openclaw.ai');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
}
|
||||
145
electron/main/tray.ts
Normal file
145
electron/main/tray.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* System Tray Management
|
||||
* Creates and manages the system tray icon and menu
|
||||
*/
|
||||
import { Tray, Menu, BrowserWindow, app, nativeImage } from 'electron';
|
||||
import { join } from 'path';
|
||||
|
||||
let tray: Tray | null = null;
|
||||
|
||||
/**
|
||||
* Create system tray icon and menu
|
||||
*/
|
||||
export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
// Create tray icon
|
||||
const iconPath = join(__dirname, '../../resources/icons/tray-icon.png');
|
||||
|
||||
// Create a template image for macOS (adds @2x support automatically)
|
||||
let icon = nativeImage.createFromPath(iconPath);
|
||||
|
||||
// If icon doesn't exist, create a simple placeholder
|
||||
if (icon.isEmpty()) {
|
||||
// Create a simple 16x16 icon as placeholder
|
||||
icon = nativeImage.createEmpty();
|
||||
}
|
||||
|
||||
// On macOS, set as template image for proper dark/light mode support
|
||||
if (process.platform === 'darwin') {
|
||||
icon.setTemplateImage(true);
|
||||
}
|
||||
|
||||
tray = new Tray(icon);
|
||||
|
||||
// Set tooltip
|
||||
tray.setToolTip('ClawX - AI Assistant');
|
||||
|
||||
// Create context menu
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show ClawX',
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Gateway Status',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: ' Running',
|
||||
type: 'checkbox',
|
||||
checked: true,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Quick Actions',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Open Dashboard',
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Chat',
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/chat');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Settings',
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates...',
|
||||
click: () => {
|
||||
mainWindow.webContents.send('update:check');
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Quit ClawX',
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
// Click to show window (Windows/Linux)
|
||||
tray.on('click', () => {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Double-click to show window (Windows)
|
||||
tray.on('double-click', () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
return tray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tray tooltip with Gateway status
|
||||
*/
|
||||
export function updateTrayStatus(status: string): void {
|
||||
if (tray) {
|
||||
tray.setToolTip(`ClawX - ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy tray icon
|
||||
*/
|
||||
export function destroyTray(): void {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
}
|
||||
84
electron/main/window.ts
Normal file
84
electron/main/window.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Window Management Utilities
|
||||
* Handles window state persistence and multi-window management
|
||||
*/
|
||||
import { BrowserWindow, screen } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
|
||||
interface WindowState {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isMaximized: boolean;
|
||||
}
|
||||
|
||||
const store = new Store<{ windowState: WindowState }>({
|
||||
name: 'window-state',
|
||||
defaults: {
|
||||
windowState: {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
isMaximized: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get saved window state with bounds validation
|
||||
*/
|
||||
export function getWindowState(): WindowState {
|
||||
const state = store.get('windowState');
|
||||
|
||||
// Validate that the window is visible on a screen
|
||||
if (state.x !== undefined && state.y !== undefined) {
|
||||
const displays = screen.getAllDisplays();
|
||||
const isVisible = displays.some((display) => {
|
||||
const { x, y, width, height } = display.bounds;
|
||||
return (
|
||||
state.x! >= x &&
|
||||
state.x! < x + width &&
|
||||
state.y! >= y &&
|
||||
state.y! < y + height
|
||||
);
|
||||
});
|
||||
|
||||
if (!isVisible) {
|
||||
// Reset position if not visible
|
||||
delete state.x;
|
||||
delete state.y;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window state
|
||||
*/
|
||||
export function saveWindowState(win: BrowserWindow): void {
|
||||
const isMaximized = win.isMaximized();
|
||||
|
||||
if (!isMaximized) {
|
||||
const bounds = win.getBounds();
|
||||
store.set('windowState', {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized,
|
||||
});
|
||||
} else {
|
||||
store.set('windowState.isMaximized', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track window state changes
|
||||
*/
|
||||
export function trackWindowState(win: BrowserWindow): void {
|
||||
// Save state on window events
|
||||
['resize', 'move', 'close'].forEach((event) => {
|
||||
win.on(event as any, () => saveWindowState(win));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user