Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/desktop-electron/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const REALTIME_UNSUBSCRIBE_CHANNEL = "ica:realtime:unsubscribe" as const;
export const CONTROL_PLANE_IPC_CHANNEL = "ica:control-plane:request" as const;
export const DESKTOP_PICK_PROJECT_IPC_CHANNEL = "ica:desktop:pick-project" as const;
export const DESKTOP_PICK_PUBLISH_IPC_CHANNEL = "ica:desktop:pick-publish" as const;
export const DESKTOP_OPEN_SETTINGS_IPC_CHANNEL = "ica:desktop:open-settings" as const;
export const DESKTOP_RUNTIME_INFO_IPC_CHANNEL = "ica:desktop:runtime-info" as const;
export const DESKTOP_REPORT_FAILURE_IPC_CHANNEL = "ica:desktop:report-failure" as const;
export const DESKTOP_UPDATE_CHECK_IPC_CHANNEL = "ica:desktop:update-check" as const;
Expand Down Expand Up @@ -53,6 +54,7 @@ export interface DesktopBridgeApi {
quitAndInstallAppUpdate(): Promise<{ accepted: boolean }>;
pickProjectDirectory(initialPath?: string): Promise<{ path: string }>;
pickPublishDirectory(initialPath?: string): Promise<{ path: string }>;
openSettingsWindow(): Promise<void>;
getRuntimeInfo(): Promise<DesktopRuntimeInfo>;
reportRendererFailure(payload: DesktopHostFailureReport): Promise<void>;
}
Expand Down
117 changes: 89 additions & 28 deletions src/desktop-electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from "node:path";
import { app, BrowserWindow, ipcMain } from "electron";
import {
CONTROL_PLANE_IPC_CHANNEL,
DESKTOP_OPEN_SETTINGS_IPC_CHANNEL,
DESKTOP_PICK_PROJECT_IPC_CHANNEL,
DESKTOP_PICK_PUBLISH_IPC_CHANNEL,
DESKTOP_REPORT_FAILURE_IPC_CHANNEL,
Expand Down Expand Up @@ -36,6 +37,31 @@ export interface RegisterElectronDesktopBridgeOptions {
logger?: DesktopStartupLogger;
}

function buildDesktopWindowUrl(startUrl: string, windowRole: "main" | "settings"): string {
const url = new URL(startUrl);
url.searchParams.set("windowRole", windowRole);
return url.toString();
}

async function loadDesktopWindow(window: BrowserWindow, targetUrl: string, logger: DesktopStartupLogger): Promise<void> {
installDesktopLoadDiagnostics(window, targetUrl, logger);
try {
await window.loadURL(targetUrl);
} catch (error) {
const reason = normalizeStartupErrorMessage(error);
logger.error(`[desktop] Initial renderer load rejected for '${targetUrl}': ${reason}`);
await showDesktopLoadFailure(
window,
{
attemptedUrl: targetUrl,
failingUrl: targetUrl,
reason,
},
logger,
);
}
}

export async function registerElectronDesktopBridge(
options: RegisterElectronDesktopBridgeOptions = {},
): Promise<{ createWindow: () => Promise<BrowserWindow>; dispose: () => Promise<void> }> {
Expand All @@ -54,6 +80,60 @@ export async function registerElectronDesktopBridge(
applicationService: options.applicationService,
});
const subscriptions = new Map<number, () => void>();
let mainWindow: BrowserWindow | null = null;
let settingsWindow: BrowserWindow | null = null;

async function createMainWindow(): Promise<BrowserWindow> {
if (mainWindow && !mainWindow.isDestroyed()) {
return mainWindow;
}

const window = new BrowserWindow({
width: 1440,
height: 940,
show: true,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
window.webContents.on("destroyed", () => {
if (mainWindow === window) {
mainWindow = null;
}
});
mainWindow = window;
await loadDesktopWindow(window, buildDesktopWindowUrl(startUrl, "main"), logger);
return window;
}

async function createSettingsWindow(): Promise<BrowserWindow> {
if (settingsWindow && !settingsWindow.isDestroyed()) {
return settingsWindow;
}

const window = new BrowserWindow({
width: 1120,
height: 860,
show: true,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
window.webContents.on("destroyed", () => {
if (settingsWindow === window) {
settingsWindow = null;
}
});
settingsWindow = window;
await loadDesktopWindow(window, buildDesktopWindowUrl(startUrl, "settings"), logger);
return window;
}

ipcMain.handle(CONTROL_PLANE_IPC_CHANNEL, async (_event, channel: keyof DesktopBridgeRequestMap, payload: DesktopBridgeRequestMap[keyof DesktopBridgeRequestMap]) => {
if (channel !== "control-plane.request") {
Expand All @@ -63,6 +143,11 @@ export async function registerElectronDesktopBridge(
});
ipcMain.handle(DESKTOP_PICK_PROJECT_IPC_CHANNEL, async (_event, initialPath?: string) => controlPlane.pickProjectDirectory(initialPath));
ipcMain.handle(DESKTOP_PICK_PUBLISH_IPC_CHANNEL, async (_event, initialPath?: string) => controlPlane.pickPublishDirectory(initialPath));
ipcMain.handle(DESKTOP_OPEN_SETTINGS_IPC_CHANNEL, async () => {
const window = await createSettingsWindow();
window.show();
window.focus();
});
ipcMain.handle(DESKTOP_RUNTIME_INFO_IPC_CHANNEL, async () => controlPlane.getRuntimeInfo());
ipcMain.handle(DESKTOP_REPORT_FAILURE_IPC_CHANNEL, async (_event, payload: DesktopHostFailureReport) => {
await controlPlane.reportRendererFailure(payload);
Expand Down Expand Up @@ -95,34 +180,7 @@ export async function registerElectronDesktopBridge(
return {
async createWindow() {
await app.whenReady();
const window = new BrowserWindow({
width: 1440,
height: 940,
show: true,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
installDesktopLoadDiagnostics(window, startUrl, logger);
try {
await window.loadURL(startUrl);
} catch (error) {
const reason = normalizeStartupErrorMessage(error);
logger.error(`[desktop] Initial renderer load rejected for '${startUrl}': ${reason}`);
await showDesktopLoadFailure(
window,
{
attemptedUrl: startUrl,
failingUrl: startUrl,
reason,
},
logger,
);
}
return window;
return createMainWindow();
},
async dispose() {
for (const unsubscribe of subscriptions.values()) {
Expand All @@ -132,13 +190,16 @@ export async function registerElectronDesktopBridge(
ipcMain.removeHandler(CONTROL_PLANE_IPC_CHANNEL);
ipcMain.removeHandler(DESKTOP_PICK_PROJECT_IPC_CHANNEL);
ipcMain.removeHandler(DESKTOP_PICK_PUBLISH_IPC_CHANNEL);
ipcMain.removeHandler(DESKTOP_OPEN_SETTINGS_IPC_CHANNEL);
ipcMain.removeHandler(DESKTOP_RUNTIME_INFO_IPC_CHANNEL);
ipcMain.removeHandler(DESKTOP_REPORT_FAILURE_IPC_CHANNEL);
ipcMain.removeHandler(DESKTOP_UPDATE_CHECK_IPC_CHANNEL);
ipcMain.removeHandler(DESKTOP_UPDATE_DOWNLOAD_IPC_CHANNEL);
ipcMain.removeHandler(DESKTOP_UPDATE_QUIT_AND_INSTALL_IPC_CHANNEL);
ipcMain.removeAllListeners(REALTIME_SUBSCRIBE_CHANNEL);
ipcMain.removeAllListeners(REALTIME_UNSUBSCRIBE_CHANNEL);
mainWindow = null;
settingsWindow = null;
await controlPlane.close();
},
};
Expand Down
5 changes: 5 additions & 0 deletions src/desktop-electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DESKTOP_UPDATE_QUIT_AND_INSTALL_IPC_CHANNEL,
DESKTOP_PICK_PROJECT_IPC_CHANNEL,
DESKTOP_PICK_PUBLISH_IPC_CHANNEL,
DESKTOP_OPEN_SETTINGS_IPC_CHANNEL,
DESKTOP_REPORT_FAILURE_IPC_CHANNEL,
DESKTOP_RUNTIME_INFO_IPC_CHANNEL,
REALTIME_EVENT_CHANNEL,
Expand Down Expand Up @@ -60,6 +61,10 @@ const desktopBridge: DesktopBridgeApi = {
return ipcRenderer.invoke(DESKTOP_PICK_PUBLISH_IPC_CHANNEL, initialPath) as Promise<{ path: string }>;
},

openSettingsWindow() {
return ipcRenderer.invoke(DESKTOP_OPEN_SETTINGS_IPC_CHANNEL) as Promise<void>;
},

getRuntimeInfo() {
return ipcRenderer.invoke(DESKTOP_RUNTIME_INFO_IPC_CHANNEL) as Promise<DesktopRuntimeInfo>;
},
Expand Down
7 changes: 5 additions & 2 deletions src/installer-dashboard/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import React from "react";
import { InstallerDashboard } from "./InstallerDashboard";
import { resolveDashboardWindowRole } from "./window-role";

export function App(): JSX.Element {
const windowRole = resolveDashboardWindowRole();

return (
<div className="desktop-shell-app">
<div className={`desktop-shell-app desktop-shell-app-${windowRole}`}>
<a className="skip-link" href="#main-content">
Skip to Main Content
</a>
<main id="main-content" className="desktop-shell-app-main">
<InstallerDashboard />
<InstallerDashboard windowRole={windowRole} />
</main>
</div>
);
Expand Down
87 changes: 87 additions & 0 deletions src/installer-dashboard/web/src/DesktopAppearanceSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React from "react";
import {
accentOptions,
backgroundOptions,
modeOptions,
type DashboardAccent,
type DashboardBackground,
type DashboardMode,
} from "./appearance";

interface DesktopAppearanceSettingsProps {
mode: DashboardMode;
accent: DashboardAccent;
background: DashboardBackground;
onModeChange(mode: DashboardMode): void;
onAccentChange(accent: DashboardAccent): void;
onBackgroundChange(background: DashboardBackground): void;
}

export function DesktopAppearanceSettings({
mode,
accent,
background,
onModeChange,
onAccentChange,
onBackgroundChange,
}: DesktopAppearanceSettingsProps): JSX.Element {
return (
<article className="panel panel-settings panel-spacious">
<p className="eyebrow">Desktop Preferences</p>
<h2>Appearance & Theme</h2>
<p className="subtle">Theme controls live in the dedicated Settings window so the main shell stays focused on workspace operations.</p>
<div className="theme-row">
<div className="theme-group">
<span className="theme-label">Theme</span>
<div className="theme-buttons">
{modeOptions.map((option) => (
<button
key={option.id}
className={`theme-btn ${mode === option.id ? "is-active" : ""}`}
type="button"
onClick={() => onModeChange(option.id)}
aria-pressed={mode === option.id}
>
{option.label}
</button>
))}
</div>
</div>
<div className="theme-group theme-group-accent">
<span className="theme-label">Accent</span>
<div className="theme-buttons">
{accentOptions.map((option) => (
<button
key={option.id}
className={`theme-btn theme-btn-accent ${accent === option.id ? "is-active" : ""}`}
type="button"
onClick={() => onAccentChange(option.id)}
aria-pressed={accent === option.id}
data-accent={option.id}
>
{option.label}
</button>
))}
</div>
</div>
</div>
<div className="theme-group theme-group-wide">
<span className="theme-label">Background</span>
<div className="theme-buttons">
{backgroundOptions.map((option) => (
<button
key={option.id}
className={`theme-btn theme-btn-background ${background === option.id ? "is-active" : ""}`}
type="button"
onClick={() => onBackgroundChange(option.id)}
aria-pressed={background === option.id}
data-background={option.id}
>
{option.label}
</button>
))}
</div>
</div>
</article>
);
}
Loading
Loading