diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts index e687bf544e..5f05fcd274 100644 --- a/apps/desktop/src/desktopSettings.test.ts +++ b/apps/desktop/src/desktopSettings.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, + setDesktopLinuxTitleBarMode, setDesktopServerExposurePreference, writeDesktopSettings, } from "./desktopSettings"; @@ -35,10 +36,12 @@ describe("desktopSettings", () => { writeDesktopSettings(settingsPath, { serverExposureMode: "network-accessible", + linuxTitleBarMode: "custom", }); expect(readDesktopSettings(settingsPath)).toEqual({ serverExposureMode: "network-accessible", + linuxTitleBarMode: "custom", }); }); @@ -47,11 +50,13 @@ describe("desktopSettings", () => { setDesktopServerExposurePreference( { serverExposureMode: "local-only", + linuxTitleBarMode: DEFAULT_DESKTOP_SETTINGS.linuxTitleBarMode, }, "network-accessible", ), ).toEqual({ serverExposureMode: "network-accessible", + linuxTitleBarMode: DEFAULT_DESKTOP_SETTINGS.linuxTitleBarMode, }); }); @@ -61,4 +66,19 @@ describe("desktopSettings", () => { expect(readDesktopSettings(settingsPath)).toEqual(DEFAULT_DESKTOP_SETTINGS); }); + + it("updates the requested linux title bar mode", () => { + expect( + setDesktopLinuxTitleBarMode( + { + ...DEFAULT_DESKTOP_SETTINGS, + linuxTitleBarMode: "native", + }, + "overlay", + ), + ).toEqual({ + ...DEFAULT_DESKTOP_SETTINGS, + linuxTitleBarMode: "overlay", + }); + }); }); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts index 80ef229ea2..a3338e5181 100644 --- a/apps/desktop/src/desktopSettings.ts +++ b/apps/desktop/src/desktopSettings.ts @@ -1,13 +1,16 @@ import * as FS from "node:fs"; import * as Path from "node:path"; import type { DesktopServerExposureMode } from "@t3tools/contracts"; +import { DEFAULT_LINUX_TITLE_BAR_MODE, type LinuxTitleBarMode } from "@t3tools/contracts/settings"; export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; + readonly linuxTitleBarMode: LinuxTitleBarMode; } export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { serverExposureMode: "local-only", + linuxTitleBarMode: DEFAULT_LINUX_TITLE_BAR_MODE, }; export function setDesktopServerExposurePreference( @@ -22,6 +25,18 @@ export function setDesktopServerExposurePreference( }; } +export function setDesktopLinuxTitleBarMode( + settings: DesktopSettings, + requestedMode: LinuxTitleBarMode, +): DesktopSettings { + return settings.linuxTitleBarMode === requestedMode + ? settings + : { + ...settings, + linuxTitleBarMode: requestedMode, + }; +} + export function readDesktopSettings(settingsPath: string): DesktopSettings { try { if (!FS.existsSync(settingsPath)) { @@ -31,11 +46,16 @@ export function readDesktopSettings(settingsPath: string): DesktopSettings { const raw = FS.readFileSync(settingsPath, "utf8"); const parsed = JSON.parse(raw) as { readonly serverExposureMode?: unknown; + readonly linuxTitleBarMode?: unknown; }; return { serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + linuxTitleBarMode: + parsed.linuxTitleBarMode === "overlay" || parsed.linuxTitleBarMode === "custom" + ? parsed.linuxTitleBarMode + : DEFAULT_LINUX_TITLE_BAR_MODE, }; } catch { return DEFAULT_DESKTOP_SETTINGS; diff --git a/apps/desktop/src/env.test.ts b/apps/desktop/src/env.test.ts new file mode 100644 index 0000000000..914fae9bf5 --- /dev/null +++ b/apps/desktop/src/env.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getWindowChromeOptions, getWindowControlsLayout } from "./env"; + +vi.mock("./linuxWindowControls", () => ({ + getLinuxWindowControlsLayout: vi.fn().mockReturnValue({ + left: [], + right: ["minimize", "maximize", "close"], + }), +})); + +describe("getWindowControlsLayout", () => { + it("uses the standard macOS traffic-light placement in ltr locales", () => { + expect(getWindowControlsLayout({ locale: "en-US", platform: "macos" })).toEqual({ + left: ["close", "minimize", "maximize"], + right: [], + }); + }); + + it("keeps macOS traffic lights left-aligned in rtl locales", () => { + expect(getWindowControlsLayout({ locale: "ar", platform: "macos" })).toEqual({ + left: ["close", "minimize", "maximize"], + right: [], + }); + }); + + it("uses the standard Windows control layout in ltr locales", () => { + expect(getWindowControlsLayout({ locale: "en-US", platform: "windows" })).toEqual({ + left: [], + right: ["minimize", "maximize", "close"], + }); + }); + + it("mirrors Windows controls in rtl locales", () => { + expect(getWindowControlsLayout({ locale: "he", platform: "windows" })).toEqual({ + left: ["close", "maximize", "minimize"], + right: [], + }); + }); + + it("keeps Linux layout unchanged even in rtl locales", () => { + expect(getWindowControlsLayout({ locale: "ar", platform: "linux" })).toEqual({ + left: [], + right: ["minimize", "maximize", "close"], + }); + }); +}); + +describe("getWindowChromeOptions", () => { + it("uses transparent overlay and light symbols for windows in dark mode", () => { + expect( + getWindowChromeOptions({ + darkMode: true, + linuxTitleBarMode: "native", + platform: "windows", + }), + ).toEqual({ + titleBarStyle: "hidden", + titleBarOverlay: { + height: 52, + color: "#00000000", + symbolColor: "#ffffff", + }, + }); + }); + + it("uses transparent overlay and dark symbols for windows in light mode", () => { + expect( + getWindowChromeOptions({ + darkMode: false, + linuxTitleBarMode: "native", + platform: "windows", + }), + ).toEqual({ + titleBarStyle: "hidden", + titleBarOverlay: { + height: 52, + color: "#00000000", + symbolColor: "#000000", + }, + }); + }); + + it("keeps linux native titlebars unchanged", () => { + expect( + getWindowChromeOptions({ + darkMode: true, + linuxTitleBarMode: "native", + platform: "linux", + }), + ).toEqual({}); + }); + + it("keeps linux overlay transparent workaround and applies symbol contrast", () => { + expect( + getWindowChromeOptions({ + darkMode: false, + linuxTitleBarMode: "overlay", + platform: "linux", + }), + ).toEqual({ + titleBarStyle: "hidden", + titleBarOverlay: { + height: 52, + color: "#01000000", + symbolColor: "#000000", + }, + }); + }); +}); diff --git a/apps/desktop/src/env.ts b/apps/desktop/src/env.ts new file mode 100644 index 0000000000..2126ebb5bf --- /dev/null +++ b/apps/desktop/src/env.ts @@ -0,0 +1,120 @@ +import type { DesktopPlatform, DesktopWindowControlsLayout } from "@t3tools/contracts"; +import type { LinuxTitleBarMode } from "@t3tools/contracts/settings"; +import { DESKTOP_TITLEBAR_HEIGHT_PX } from "@t3tools/shared/desktop"; +import type { BrowserWindowConstructorOptions } from "electron"; +import { getLinuxWindowControlsLayout } from "./linuxWindowControls"; + +const RTL_LANGUAGES = new Set(["ar", "dv", "fa", "he", "ku", "ps", "sd", "ug", "ur", "yi"]); +const MACOS_WINDOW_CONTROLS_LAYOUT: DesktopWindowControlsLayout = { + left: ["close", "minimize", "maximize"], + right: [], +}; +const WINDOWS_WINDOW_CONTROLS_LAYOUT: DesktopWindowControlsLayout = { + left: [], + right: ["minimize", "maximize", "close"], +}; +const WINDOW_CONTROL_SYMBOL_COLOR_DARK = "#ffffff"; +const WINDOW_CONTROL_SYMBOL_COLOR_LIGHT = "#000000"; +type WindowChromeOptions = Pick< + BrowserWindowConstructorOptions, + "titleBarStyle" | "titleBarOverlay" | "trafficLightPosition" +>; + +export const platform: DesktopPlatform = (() => { + switch (process.platform) { + case "darwin": + return "macos"; + case "win32": + return "windows"; + case "linux": + return "linux"; + default: + throw new Error(`Unsupported desktop platform: ${process.platform}`); + } +})(); + +function isRightToLeftLocale(locale: string | undefined): boolean { + if (!locale) { + return false; + } + + const language = locale.split(/[-_]/, 1)[0]?.toLowerCase(); + return language !== undefined && RTL_LANGUAGES.has(language); +} + +function mirrorWindowControlsLayout( + layout: DesktopWindowControlsLayout, +): DesktopWindowControlsLayout { + return { + left: layout.right.toReversed(), + right: layout.left.toReversed(), + }; +} + +export function getWindowControlsLayout(options?: { + locale?: string; + platform?: DesktopPlatform; +}): DesktopWindowControlsLayout { + const resolvedPlatform = options?.platform ?? platform; + if (resolvedPlatform === "linux") { + return getLinuxWindowControlsLayout(); + } + + const rtl = isRightToLeftLocale(options?.locale); + const layout = + resolvedPlatform === "macos" ? MACOS_WINDOW_CONTROLS_LAYOUT : WINDOWS_WINDOW_CONTROLS_LAYOUT; + + if (!rtl || resolvedPlatform === "macos") { + return layout; + } + + return mirrorWindowControlsLayout(layout); +} + +export function getWindowChromeOptions(input: { + darkMode: boolean; + linuxTitleBarMode: LinuxTitleBarMode; + platform: DesktopPlatform; +}): WindowChromeOptions { + const symbolColor = input.darkMode + ? WINDOW_CONTROL_SYMBOL_COLOR_DARK + : WINDOW_CONTROL_SYMBOL_COLOR_LIGHT; + + switch (input.platform) { + case "macos": + return { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 18 }, + }; + + case "linux": + if (input.linuxTitleBarMode === "native") { + return {}; + } + + if (input.linuxTitleBarMode === "overlay") { + return { + titleBarStyle: "hidden", + titleBarOverlay: { + height: DESKTOP_TITLEBAR_HEIGHT_PX, + color: "#01000000", // #00000000 doesn't work falling back to default value, not sure why, probably some bug in Electron + symbolColor, + }, + }; + } + + return { + titleBarStyle: "hidden", + }; + + case "windows": + return { + titleBarStyle: "hidden", + titleBarOverlay: { + height: DESKTOP_TITLEBAR_HEIGHT_PX, + color: "#00000000", + symbolColor, + }, + }; + } +} diff --git a/apps/desktop/src/linuxWindowControls.test.ts b/apps/desktop/src/linuxWindowControls.test.ts new file mode 100644 index 0000000000..87d2312906 --- /dev/null +++ b/apps/desktop/src/linuxWindowControls.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getLinuxWindowControlsLayout } from "./linuxWindowControls"; + +describe("getLinuxWindowControlsLayout", () => { + it("reads KDE button placement from kwinrc", () => { + const layout = getLinuxWindowControlsLayout({ + existsSync: vi.fn().mockReturnValue(true), + homeDir: "/home/tester", + readFileSync: vi + .fn() + .mockReturnValue("[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=M\n"), + spawnSync: vi.fn(), + }); + + expect(layout).toEqual({ + left: ["close", "minimize", "maximize"], + right: [], + }); + }); + + it("falls back to GNOME button placement when KDE config is unavailable", () => { + const layout = getLinuxWindowControlsLayout({ + existsSync: vi.fn().mockReturnValue(false), + homeDir: "/home/tester", + readFileSync: vi.fn(), + spawnSync: vi.fn().mockReturnValue({ + status: 0, + stdout: "'close,minimize:maximize'\n", + }), + }); + + expect(layout).toEqual({ + left: ["close", "minimize"], + right: ["maximize"], + }); + }); + + it("falls back when kwinrc exists but does not define button placement", () => { + const layout = getLinuxWindowControlsLayout({ + existsSync: vi.fn().mockReturnValue(true), + homeDir: "/home/tester", + readFileSync: vi.fn().mockReturnValue("[org.kde.kdecoration2]\n"), + spawnSync: vi.fn().mockReturnValue({ + status: 0, + stdout: "'close,minimize:maximize'\n", + }), + }); + + expect(layout).toEqual({ + left: ["close", "minimize"], + right: ["maximize"], + }); + }); + + it("falls back to GNOME when reading kwinrc throws", () => { + const layout = getLinuxWindowControlsLayout({ + existsSync: vi.fn().mockReturnValue(true), + homeDir: "/home/tester", + readFileSync: vi.fn().mockImplementation(() => { + throw new Error("permission denied"); + }), + spawnSync: vi.fn().mockReturnValue({ + status: 0, + stdout: "'close,minimize:maximize'\n", + }), + }); + + expect(layout).toEqual({ + left: ["close", "minimize"], + right: ["maximize"], + }); + }); + + it("uses the default right-side controls when no desktop layout is available", () => { + const layout = getLinuxWindowControlsLayout({ + existsSync: vi.fn().mockReturnValue(false), + homeDir: "/home/tester", + readFileSync: vi.fn(), + spawnSync: vi.fn().mockReturnValue({ + status: 1, + stdout: "", + }), + }); + + expect(layout).toEqual({ + left: [], + right: ["minimize", "maximize", "close"], + }); + }); +}); diff --git a/apps/desktop/src/linuxWindowControls.ts b/apps/desktop/src/linuxWindowControls.ts new file mode 100644 index 0000000000..9cb390a2ae --- /dev/null +++ b/apps/desktop/src/linuxWindowControls.ts @@ -0,0 +1,146 @@ +import * as ChildProcess from "node:child_process"; +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; + +import type { DesktopWindowControl, DesktopWindowControlsLayout } from "@t3tools/contracts"; + +interface LinuxWindowControlsDependencies { + existsSync?: typeof FS.existsSync; + homeDir?: string; + readFileSync?: typeof FS.readFileSync; + spawnSync?: typeof ChildProcess.spawnSync; +} + +const FALLBACK_WINDOW_CONTROLS_LAYOUT: DesktopWindowControlsLayout = { + left: [], + right: ["minimize", "maximize", "close"], +}; + +function mapKdeButtonCode(code: string): DesktopWindowControl | null { + switch (code) { + case "I": + return "minimize"; + case "A": + return "maximize"; + case "X": + return "close"; + default: + return null; + } +} + +function parseKdeButtonBank(value: string | undefined): readonly DesktopWindowControl[] { + if (!value) { + return []; + } + + return [...value].flatMap((code) => { + const mapped = mapKdeButtonCode(code); + return mapped ? [mapped] : []; + }); +} + +function readKdeWindowControlsLayout( + dependencies: Required, +): DesktopWindowControlsLayout | null { + const kwinConfigPath = Path.join(dependencies.homeDir, ".config/kwinrc"); + if (!dependencies.existsSync(kwinConfigPath)) { + return null; + } + + const content = dependencies.readFileSync(kwinConfigPath, "utf8"); + const left = content.match(/^ButtonsOnLeft=(.*)$/m)?.[1]?.trim(); + const right = content.match(/^ButtonsOnRight=(.*)$/m)?.[1]?.trim(); + const parsedLeft = parseKdeButtonBank(left); + const parsedRight = parseKdeButtonBank(right); + + if (parsedLeft.length === 0 && parsedRight.length === 0) { + return null; + } + + return { + left: parsedLeft, + right: parsedRight, + }; +} + +function mapGnomeButtonName(name: string): DesktopWindowControl | null { + switch (name) { + case "minimize": + return "minimize"; + case "maximize": + return "maximize"; + case "close": + return "close"; + default: + return null; + } +} + +function parseGnomeButtonBank(value: string): readonly DesktopWindowControl[] { + return value + .split(",") + .map((part) => part.trim()) + .flatMap((name) => { + const mapped = mapGnomeButtonName(name); + return mapped ? [mapped] : []; + }); +} + +function readGnomeWindowControlsLayout( + dependencies: Required, +): DesktopWindowControlsLayout | null { + const result = dependencies.spawnSync( + "gsettings", + ["get", "org.gnome.desktop.wm.preferences", "button-layout"], + { encoding: "utf8" }, + ); + if (result.status !== 0) { + return null; + } + + const value = result.stdout.trim().replace(/^'+|'+$/g, ""); + if (!value) { + return null; + } + + const [left = "", right = ""] = value.split(":"); + return { + left: parseGnomeButtonBank(left), + right: parseGnomeButtonBank(right), + }; +} + +function safelyReadWindowControlsLayout( + source: string, + readLayout: () => DesktopWindowControlsLayout | null, +): DesktopWindowControlsLayout | null { + try { + return readLayout(); + } catch (error) { + console.warn(`[desktop] failed to read linux window controls from ${source}`, error); + return null; + } +} + +export function getLinuxWindowControlsLayout( + dependencies: LinuxWindowControlsDependencies = {}, +): DesktopWindowControlsLayout { + const resolvedDependencies: Required = { + existsSync: dependencies.existsSync ?? FS.existsSync, + homeDir: dependencies.homeDir ?? OS.homedir(), + readFileSync: dependencies.readFileSync ?? FS.readFileSync, + spawnSync: dependencies.spawnSync ?? ChildProcess.spawnSync, + }; + + return ( + safelyReadWindowControlsLayout("kde", () => + readKdeWindowControlsLayout(resolvedDependencies), + ) ?? + safelyReadWindowControlsLayout("gnome", () => + readGnomeWindowControlsLayout(resolvedDependencies), + ) ?? + FALLBACK_WINDOW_CONTROLS_LAYOUT + ); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ef30d3b4bb..42889e3bd4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -29,14 +29,17 @@ import type { DesktopUpdateState, } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; +import { Schema } from "effect"; import type { ContextMenuItem } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS, LinuxTitleBarMode } from "@t3tools/contracts/settings"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort"; import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, + setDesktopLinuxTitleBarMode, setDesktopServerExposurePreference, writeDesktopSettings, } from "./desktopSettings"; @@ -67,6 +70,7 @@ import { reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; +import { getWindowChromeOptions, getWindowControlsLayout, platform } from "./env"; syncShellEnvironment(); @@ -82,6 +86,10 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_PLATFORM_CHANNEL = "desktop:get-platform"; +const GET_LINUX_TITLE_BAR_MODE_CHANNEL = "desktop:get-linux-title-bar-mode"; +const SET_LINUX_TITLE_BAR_MODE_CHANNEL = "desktop:set-linux-title-bar-mode"; +const GET_WINDOW_CONTROLS_LAYOUT_CHANNEL = "desktop:get-window-controls-layout"; const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; @@ -91,6 +99,10 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const MINIMIZE_WINDOW_CHANNEL = "desktop:minimize-window"; +const TOGGLE_MAXIMIZE_WINDOW_CHANNEL = "desktop:toggle-maximize-window"; +const CLOSE_WINDOW_CHANNEL = "desktop:close-window"; +const RESTART_APP_CHANNEL = "desktop:restart-app"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); @@ -118,7 +130,6 @@ const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; - type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { setDesktopName?: (desktopName: string) => void; @@ -145,11 +156,13 @@ let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH); let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; +let currentClientSettings: ClientSettings = + readClientSettings(CLIENT_SETTINGS_PATH) ?? DEFAULT_CLIENT_SETTINGS; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ - platform: process.platform, + platform, processArch: process.arch, runningUnderArm64Translation: app.runningUnderARM64Translation === true, }); @@ -462,12 +475,31 @@ function captureBackendOutput(child: ChildProcess.ChildProcess): void { initializePackagedLogging(); -if (process.platform === "linux") { +function syncWindowChromeOverlay(window: BrowserWindow): void { + const overlay = getWindowChromeOptions({ + darkMode: nativeTheme.shouldUseDarkColors, + linuxTitleBarMode: desktopSettings.linuxTitleBarMode, + platform, + }).titleBarOverlay; + if (!overlay || overlay === true) { + return; + } + + window.setTitleBarOverlay(overlay); +} + +nativeTheme.on("updated", () => { + for (const window of BrowserWindow.getAllWindows()) { + syncWindowChromeOverlay(window); + } +}); + +if (platform === "linux") { app.commandLine.appendSwitch("class", LINUX_WM_CLASS); } function getDestructiveMenuIcon(): Electron.NativeImage | undefined { - if (process.platform !== "darwin") return undefined; + if (platform !== "macos") return undefined; if (destructiveMenuIconCache !== undefined) { return destructiveMenuIconCache ?? undefined; } @@ -734,7 +766,7 @@ function handleCheckForUpdatesMenuClick(): void { const disabledReason = getAutoUpdateDisabledReason({ isDevelopment, isPackaged: app.isPackaged, - platform: process.platform, + platform, appImage: process.env.APPIMAGE, disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", hasUpdateFeedConfig, @@ -781,7 +813,7 @@ async function checkForUpdatesFromMenu(): Promise { function configureApplicationMenu(): void { const template: MenuItemConstructorOptions[] = []; - if (process.platform === "darwin") { + if (platform === "macos") { template.push({ label: app.name, submenu: [ @@ -812,7 +844,7 @@ function configureApplicationMenu(): void { { label: "File", submenu: [ - ...(process.platform === "darwin" + ...(platform === "macos" ? [] : [ { @@ -822,7 +854,7 @@ function configureApplicationMenu(): void { }, { type: "separator" as const }, ]), - { role: process.platform === "darwin" ? "close" : "quit" }, + { role: platform === "macos" ? "close" : "quit" }, ], }, { role: "editMenu" }, @@ -891,9 +923,9 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { */ function resolveUserDataPath(): string { const appDataBase = - process.platform === "win32" + platform === "windows" ? process.env.APPDATA || Path.join(OS.homedir(), "AppData", "Roaming") - : process.platform === "darwin" + : platform === "macos" ? Path.join(OS.homedir(), "Library", "Application Support") : process.env.XDG_CONFIG_HOME || Path.join(OS.homedir(), ".config"); @@ -914,15 +946,15 @@ function configureAppIdentity(): void { version: commitHash ?? "unknown", }); - if (process.platform === "win32") { + if (platform === "windows") { app.setAppUserModelId(APP_USER_MODEL_ID); } - if (process.platform === "linux") { + if (platform === "linux") { (app as LinuxDesktopNamedApp).setDesktopName?.(LINUX_DESKTOP_ENTRY_NAME); } - if (process.platform === "darwin" && app.dock) { + if (platform === "macos" && app.dock) { const iconPath = resolveIconPath("png"); if (iconPath) { app.dock.setIcon(iconPath); @@ -954,7 +986,7 @@ function revealWindow(window: BrowserWindow): void { window.show(); } - if (process.platform === "darwin") { + if (platform === "macos") { app.focus({ steal: true }); } @@ -980,7 +1012,7 @@ function shouldEnableAutoUpdates(): boolean { getAutoUpdateDisabledReason({ isDevelopment, isPackaged: app.isPackaged, - platform: process.platform, + platform, appImage: process.env.APPIMAGE, disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", hasUpdateFeedConfig, @@ -1378,8 +1410,34 @@ function registerIpcHandlers(): void { } as const; }); + ipcMain.removeAllListeners(GET_PLATFORM_CHANNEL); + ipcMain.on(GET_PLATFORM_CHANNEL, (event) => { + event.returnValue = platform; + }); + + ipcMain.removeAllListeners(GET_LINUX_TITLE_BAR_MODE_CHANNEL); + ipcMain.on(GET_LINUX_TITLE_BAR_MODE_CHANNEL, (event) => { + event.returnValue = platform === "linux" ? desktopSettings.linuxTitleBarMode : null; + }); + + ipcMain.removeAllListeners(GET_WINDOW_CONTROLS_LAYOUT_CHANNEL); + ipcMain.on(GET_WINDOW_CONTROLS_LAYOUT_CHANNEL, (event) => { + event.returnValue = getWindowControlsLayout({ locale: app.getLocale() }); + }); + + ipcMain.removeHandler(SET_LINUX_TITLE_BAR_MODE_CHANNEL); + ipcMain.handle(SET_LINUX_TITLE_BAR_MODE_CHANNEL, async (_event, rawMode: unknown) => { + if (!Schema.is(LinuxTitleBarMode)(rawMode)) { + throw new Error("Invalid linux title bar mode."); + } + + desktopSettings = setDesktopLinuxTitleBarMode(desktopSettings, rawMode); + writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); + return desktopSettings.linuxTitleBarMode; + }); + ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL); - ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH)); + ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => currentClientSettings); ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL); ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => { @@ -1387,7 +1445,9 @@ function registerIpcHandlers(): void { throw new Error("Invalid client settings payload."); } - writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings); + const nextSettings = rawSettings as ClientSettings; + currentClientSettings = nextSettings; + writeClientSettings(CLIENT_SETTINGS_PATH, nextSettings); }); ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); @@ -1512,6 +1572,37 @@ function registerIpcHandlers(): void { } nativeTheme.themeSource = theme; + for (const window of BrowserWindow.getAllWindows()) { + syncWindowChromeOverlay(window); + } + }); + + ipcMain.removeHandler(MINIMIZE_WINDOW_CHANNEL); + ipcMain.handle(MINIMIZE_WINDOW_CHANNEL, async (event) => { + BrowserWindow.fromWebContents(event.sender)?.minimize(); + }); + + ipcMain.removeHandler(TOGGLE_MAXIMIZE_WINDOW_CHANNEL); + ipcMain.handle(TOGGLE_MAXIMIZE_WINDOW_CHANNEL, async (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (!window) { + return; + } + if (window.isMaximized()) { + window.unmaximize(); + return; + } + window.maximize(); + }); + + ipcMain.removeHandler(CLOSE_WINDOW_CHANNEL); + ipcMain.handle(CLOSE_WINDOW_CHANNEL, async (event) => { + BrowserWindow.fromWebContents(event.sender)?.close(); + }); + + ipcMain.removeHandler(RESTART_APP_CHANNEL); + ipcMain.handle(RESTART_APP_CHANNEL, async () => { + relaunchDesktopApp("manualRestart"); }); ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); @@ -1639,8 +1730,8 @@ function registerIpcHandlers(): void { } function getIconOption(): { icon: string } | Record { - if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle - const ext = process.platform === "win32" ? "ico" : "png"; + if (platform === "macos") return {}; // macOS uses .icns from app bundle + const ext = platform === "windows" ? "ico" : "png"; const iconPath = resolveIconPath(ext); return iconPath ? { icon: iconPath } : {}; } @@ -1660,8 +1751,11 @@ function createWindow(): BrowserWindow { backgroundColor: getInitialWindowBackgroundColor(), ...getIconOption(), title: APP_DISPLAY_NAME, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, + ...getWindowChromeOptions({ + darkMode: nativeTheme.shouldUseDarkColors, + linuxTitleBarMode: desktopSettings.linuxTitleBarMode, + platform, + }), webPreferences: { preload: Path.join(__dirname, "preload.js"), contextIsolation: true, @@ -1881,12 +1975,12 @@ app }); app.on("window-all-closed", () => { - if (process.platform !== "darwin" && !isQuitting) { + if (platform !== "macos" && !isQuitting) { app.quit(); } }); -if (process.platform !== "win32") { +if (platform !== "windows") { process.on("SIGINT", () => { if (isQuitting) return; isQuitting = true; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e3107a9248..1ccc4d1d77 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -13,6 +13,10 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_PLATFORM_CHANNEL = "desktop:get-platform"; +const GET_LINUX_TITLE_BAR_MODE_CHANNEL = "desktop:get-linux-title-bar-mode"; +const SET_LINUX_TITLE_BAR_MODE_CHANNEL = "desktop:set-linux-title-bar-mode"; +const GET_WINDOW_CONTROLS_LAYOUT_CHANNEL = "desktop:get-window-controls-layout"; const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; @@ -22,6 +26,10 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const MINIMIZE_WINDOW_CHANNEL = "desktop:minimize-window"; +const TOGGLE_MAXIMIZE_WINDOW_CHANNEL = "desktop:toggle-maximize-window"; +const CLOSE_WINDOW_CHANNEL = "desktop:close-window"; +const RESTART_APP_CHANNEL = "desktop:restart-app"; contextBridge.exposeInMainWorld("desktopBridge", { getLocalEnvironmentBootstrap: () => { @@ -31,6 +39,24 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getPlatform: () => { + return ipcRenderer.sendSync(GET_PLATFORM_CHANNEL) as ReturnType< + NonNullable + >; + }, + getLinuxTitleBarMode: () => { + return ipcRenderer.sendSync(GET_LINUX_TITLE_BAR_MODE_CHANNEL) as ReturnType< + NonNullable + >; + }, + setLinuxTitleBarMode: (mode) => ipcRenderer.invoke(SET_LINUX_TITLE_BAR_MODE_CHANNEL, mode), + getWindowControlsLayout: () => { + const result = ipcRenderer.sendSync(GET_WINDOW_CONTROLS_LAYOUT_CHANNEL); + if (typeof result !== "object" || result === null) { + return null; + } + return result as NonNullable>>; + }, getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings), getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), @@ -47,6 +73,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), + minimizeWindow: () => ipcRenderer.invoke(MINIMIZE_WINDOW_CHANNEL), + toggleMaximizeWindow: () => ipcRenderer.invoke(TOGGLE_MAXIMIZE_WINDOW_CHANNEL), + closeWindow: () => ipcRenderer.invoke(CLOSE_WINDOW_CHANNEL), + restartApp: () => ipcRenderer.invoke(RESTART_APP_CHANNEL), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { diff --git a/apps/desktop/src/runtimeArch.test.ts b/apps/desktop/src/runtimeArch.test.ts index 258a8fb215..e689cc8c4a 100644 --- a/apps/desktop/src/runtimeArch.test.ts +++ b/apps/desktop/src/runtimeArch.test.ts @@ -5,7 +5,7 @@ import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runti describe("resolveDesktopRuntimeInfo", () => { it("detects Rosetta-translated Intel builds on Apple Silicon", () => { const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "darwin", + platform: "macos", processArch: "x64", runningUnderArm64Translation: true, }); @@ -20,7 +20,7 @@ describe("resolveDesktopRuntimeInfo", () => { it("detects native Apple Silicon builds", () => { const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "darwin", + platform: "macos", processArch: "arm64", runningUnderArm64Translation: false, }); diff --git a/apps/desktop/src/runtimeArch.ts b/apps/desktop/src/runtimeArch.ts index 127abf51ab..58a1183a87 100644 --- a/apps/desktop/src/runtimeArch.ts +++ b/apps/desktop/src/runtimeArch.ts @@ -1,7 +1,7 @@ -import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts"; +import type { DesktopPlatform, DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts"; interface ResolveDesktopRuntimeInfoInput { - readonly platform: NodeJS.Platform; + readonly platform: DesktopPlatform; readonly processArch: string; readonly runningUnderArm64Translation: boolean; } @@ -17,7 +17,7 @@ export function resolveDesktopRuntimeInfo( ): DesktopRuntimeInfo { const appArch = normalizeDesktopArch(input.processArch); - if (input.platform !== "darwin") { + if (input.platform !== "macos") { return { hostArch: appArch, appArch, diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts index 8db92e1915..01a6123f52 100644 --- a/apps/desktop/src/updateState.test.ts +++ b/apps/desktop/src/updateState.test.ts @@ -68,7 +68,7 @@ describe("getAutoUpdateDisabledReason", () => { getAutoUpdateDisabledReason({ isDevelopment: true, isPackaged: false, - platform: "darwin", + platform: "macos", appImage: undefined, disabledByEnv: false, hasUpdateFeedConfig: true, @@ -81,7 +81,7 @@ describe("getAutoUpdateDisabledReason", () => { getAutoUpdateDisabledReason({ isDevelopment: false, isPackaged: true, - platform: "darwin", + platform: "macos", appImage: undefined, disabledByEnv: false, hasUpdateFeedConfig: false, @@ -94,7 +94,7 @@ describe("getAutoUpdateDisabledReason", () => { getAutoUpdateDisabledReason({ isDevelopment: false, isPackaged: true, - platform: "darwin", + platform: "macos", appImage: undefined, disabledByEnv: false, hasUpdateFeedConfig: true, @@ -107,7 +107,7 @@ describe("getAutoUpdateDisabledReason", () => { getAutoUpdateDisabledReason({ isDevelopment: false, isPackaged: true, - platform: "darwin", + platform: "macos", appImage: undefined, disabledByEnv: true, hasUpdateFeedConfig: true, diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts index 928bb40886..b9204cddc4 100644 --- a/apps/desktop/src/updateState.ts +++ b/apps/desktop/src/updateState.ts @@ -1,4 +1,4 @@ -import type { DesktopUpdateState } from "@t3tools/contracts"; +import type { DesktopPlatform, DesktopUpdateState } from "@t3tools/contracts"; export function shouldBroadcastDownloadProgress( currentState: DesktopUpdateState, @@ -31,7 +31,7 @@ export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState export function getAutoUpdateDisabledReason(args: { isDevelopment: boolean; isPackaged: boolean; - platform: NodeJS.Platform; + platform: DesktopPlatform; appImage?: string | undefined; disabledByEnv: boolean; hasUpdateFeedConfig: boolean; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5a2875cc..83408991c5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -34,7 +34,7 @@ import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; -import { isElectron } from "../env"; +import { isElectron, usesDesktopChromeHeader } from "../env"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -3293,8 +3293,10 @@ export default function ChatView(props: ChatViewProps) { {/* Top bar */}
{ + it("renders both control banks as separate minimal fixed roots", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("fixed left-3 top-0 z-50"); + expect(markup).toContain("fixed right-3 top-0 z-50"); + expect(markup).not.toContain("drag-region"); + expect(markup).toContain('aria-label="minimize"'); + expect(markup).toContain('aria-label="close"'); + }); +}); diff --git a/apps/web/src/components/DesktopChromeOverlay.tsx b/apps/web/src/components/DesktopChromeOverlay.tsx new file mode 100644 index 0000000000..aafb91475a --- /dev/null +++ b/apps/web/src/components/DesktopChromeOverlay.tsx @@ -0,0 +1,32 @@ +import { cn } from "~/lib/utils"; +import type { DesktopWindowControl, DesktopWindowControlsLayout } from "@t3tools/contracts"; +import { LinuxWindowControls } from "./LinuxWindowControls"; + +export function DesktopChromeOverlay(props: { layout: DesktopWindowControlsLayout }) { + return ( + <> + + + + ); +} + +function DesktopChromeOverlayBank(props: { + actions: readonly DesktopWindowControl[]; + side: "left" | "right"; +}) { + if (props.actions.length === 0) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index c08c53325d..fc00266c18 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; -import { isElectron } from "~/env"; +import { usesDesktopChromeHeader } from "~/env"; import { cn } from "~/lib/utils"; import { Skeleton } from "./ui/skeleton"; @@ -8,10 +8,11 @@ import { Skeleton } from "./ui/skeleton"; export type DiffPanelMode = "inline" | "sheet" | "sidebar"; function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { - const shouldUseDragRegion = isElectron && mode !== "sheet"; + const shouldUseDragRegion = usesDesktopChromeHeader && mode !== "sheet"; return cn( - "flex items-center justify-between gap-2 px-4", - shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12", + "flex items-center justify-between gap-2", + usesDesktopChromeHeader ? "desktop-chrome-safe-end pl-4" : "px-4", + shouldUseDragRegion ? "desktop-chrome drag-region border-b border-border" : "h-12", ); } @@ -20,7 +21,7 @@ export function DiffPanelShell(props: { header: ReactNode; children: ReactNode; }) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet"; + const shouldUseDragRegion = usesDesktopChromeHeader && props.mode !== "sheet"; return (
{ + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders buttons as a simple inline row", () => { + vi.stubGlobal("window", testWindow); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain("flex shrink-0 items-center gap-1"); + expect(markup).not.toContain("absolute inset-x-3"); + expect(markup).toContain('aria-label="minimize"'); + }); + + it("renders multiple buttons in order", () => { + vi.stubGlobal("window", testWindow); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('aria-label="minimize"'); + expect(markup).toContain('aria-label="close"'); + }); +}); diff --git a/apps/web/src/components/LinuxWindowControls.tsx b/apps/web/src/components/LinuxWindowControls.tsx new file mode 100644 index 0000000000..bb90bb3aad --- /dev/null +++ b/apps/web/src/components/LinuxWindowControls.tsx @@ -0,0 +1,61 @@ +import { MinusIcon, SquareIcon, XIcon } from "lucide-react"; + +import type { DesktopWindowControl } from "@t3tools/contracts"; +import { cn } from "~/lib/utils"; + +import { Button } from "./ui/button"; + +function LinuxWindowControlButton(props: { action: DesktopWindowControl }) { + const bridge = window.desktopBridge; + + const onClick = async () => { + switch (props.action) { + case "minimize": + await bridge?.minimizeWindow?.(); + return; + case "maximize": + await bridge?.toggleMaximizeWindow?.(); + return; + case "close": + await bridge?.closeWindow?.(); + return; + } + }; + + return ( + + ); +} + +export function LinuxWindowControls(props: { actions: readonly DesktopWindowControl[] }) { + if (props.actions.length === 0) { + return null; + } + + return ( +
+ {props.actions.map((action) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 39aa4e2f72..704d9adace 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,6 +1,6 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset, SidebarTrigger } from "./ui/sidebar"; -import { isElectron } from "../env"; +import { usesDesktopChromeHeader } from "../env"; import { cn } from "~/lib/utils"; export function NoActiveThreadState() { @@ -9,11 +9,13 @@ export function NoActiveThreadState() {
- {isElectron ? ( + {usesDesktopChromeHeader ? ( No active thread ) : (
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 03ae979017..69f4072205 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -54,7 +54,7 @@ import { type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { usePrimaryEnvironmentId } from "../environments/primary"; -import { isElectron } from "../env"; +import { isElectron, usesDesktopChromeHeader } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; @@ -1940,11 +1940,7 @@ function SortableProjectItem({ ); } -const SidebarChromeHeader = memo(function SidebarChromeHeader({ - isElectron, -}: { - isElectron: boolean; -}) { +const SidebarChromeHeader = memo(function SidebarChromeHeader() { const wordmark = (
@@ -1973,8 +1969,8 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({
); - return isElectron ? ( - + return usesDesktopChromeHeader ? ( + {wordmark} ) : ( @@ -3156,7 +3152,7 @@ export default function Sidebar() { return ( <> - + {isOnSettings ? ( diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 0337da23a4..874621bcac 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -282,6 +282,9 @@ const createDesktopBridgeStub = (overrides?: { wsBaseUrl: "ws://127.0.0.1:3773", bootstrapToken: "desktop-bootstrap-token", }), + getPlatform: () => "linux", + getLinuxTitleBarMode: () => "native", + setLinuxTitleBarMode: vi.fn().mockImplementation(async (mode) => mode), getClientSettings: vi.fn().mockResolvedValue(null), setClientSettings: vi.fn().mockResolvedValue(undefined), getSavedEnvironmentRegistry: vi.fn().mockResolvedValue([]), @@ -308,6 +311,7 @@ const createDesktopBridgeStub = (overrides?: { setTheme: vi.fn().mockResolvedValue(undefined), showContextMenu: vi.fn().mockResolvedValue(null), openExternal: vi.fn().mockResolvedValue(true), + restartApp: vi.fn().mockResolvedValue(undefined), onMenuAction: () => () => {}, getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c8766f2643..c802ab6b55 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -18,7 +18,11 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + DEFAULT_LINUX_TITLE_BAR_MODE, + DEFAULT_UNIFIED_SETTINGS, + type LinuxTitleBarMode, +} from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; @@ -32,7 +36,7 @@ import { import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; -import { isElectron } from "../../env"; +import { desktopPlatform, isElectron, runningLinuxTitleBarMode } from "../../env"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; @@ -98,6 +102,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const LINUX_TITLE_BAR_MODE_LABELS: Record = { + native: "Native", + overlay: "Overlay", + custom: "Custom", +}; + type InstallProviderSettings = { provider: ProviderKind; title: string; @@ -424,6 +434,13 @@ export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); + const [selectedLinuxTitleBarMode, setSelectedLinuxTitleBarMode] = useState( + desktopPlatform === "linux" + ? (window.desktopBridge?.getLinuxTitleBarMode?.() ?? DEFAULT_LINUX_TITLE_BAR_MODE) + : DEFAULT_LINUX_TITLE_BAR_MODE, + ); + const [isSavingLinuxTitleBarMode, setIsSavingLinuxTitleBarMode] = useState(false); + const [isRestartingForLinuxTitleBarMode, setIsRestartingForLinuxTitleBarMode] = useState(false); const [openingPathByTarget, setOpeningPathByTarget] = useState({ keybindings: false, logsDirectory: false, @@ -546,6 +563,49 @@ export function GeneralSettingsPanel() { const openDiagnosticsError = openPathErrorByTarget.logsDirectory ?? null; const isOpeningKeybindings = openingPathByTarget.keybindings; const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; + const linuxTitleBarModeNeedsRestart = + desktopPlatform === "linux" && selectedLinuxTitleBarMode !== runningLinuxTitleBarMode; + + const persistLinuxTitleBarMode = useCallback((mode: LinuxTitleBarMode) => { + const setLinuxTitleBarMode = window.desktopBridge?.setLinuxTitleBarMode; + if (!setLinuxTitleBarMode) { + return; + } + + setSelectedLinuxTitleBarMode(mode); + setIsSavingLinuxTitleBarMode(true); + void setLinuxTitleBarMode(mode) + .catch((error: unknown) => { + setSelectedLinuxTitleBarMode( + window.desktopBridge?.getLinuxTitleBarMode?.() ?? runningLinuxTitleBarMode, + ); + toastManager.add({ + type: "error", + title: "Could not update Linux title bar mode", + description: error instanceof Error ? error.message : "Saving failed.", + }); + }) + .finally(() => { + setIsSavingLinuxTitleBarMode(false); + }); + }, []); + + const restartForLinuxTitleBarMode = useCallback(() => { + const restartApp = window.desktopBridge?.restartApp; + if (!restartApp || isRestartingForLinuxTitleBarMode) { + return; + } + + setIsRestartingForLinuxTitleBarMode(true); + void restartApp().catch((error: unknown) => { + setIsRestartingForLinuxTitleBarMode(false); + toastManager.add({ + type: "error", + title: "Could not restart T3 Code", + description: error instanceof Error ? error.message : "Restart failed.", + }); + }); + }, [isRestartingForLinuxTitleBarMode]); const addCustomModel = useCallback( (provider: ProviderKind) => { @@ -718,6 +778,71 @@ export function GeneralSettingsPanel() { } /> + {desktopPlatform === "linux" ? ( + + {linuxTitleBarModeNeedsRestart ? ( + + ) : null} + {linuxTitleBarModeNeedsRestart + ? "Restart required to apply this change." + : isSavingLinuxTitleBarMode + ? "Saving preference..." + : undefined} +
+ } + resetAction={ + selectedLinuxTitleBarMode !== DEFAULT_LINUX_TITLE_BAR_MODE ? ( + persistLinuxTitleBarMode(DEFAULT_LINUX_TITLE_BAR_MODE)} + /> + ) : null + } + control={ + + } + /> + ) : null} + { + it("computes a safe inline inset from the number of controls", () => { + expect(resolveDesktopChromeSafeInlineSize(0)).toBe("0px"); + expect(resolveDesktopChromeSafeInlineSize(2)).toBe("4.5rem"); + }); + + it("maps both control banks into root CSS variables", () => { + expect( + resolveDesktopChromeSafeAreaStyle({ + leftControlCount: 1, + rightControlCount: 3, + }), + ).toEqual({ + "--desktop-chrome-safe-inline-start": "2.75rem", + "--desktop-chrome-safe-inline-end": "6.25rem", + }); + }); + + it("uses the previous hardcoded macos inset", () => { + expect( + resolveDesktopChromeRootStyle({ + platform: "macos", + linuxTitleBarMode: "native", + windowControlsLayout: null, + }), + ).toEqual({ + "--desktop-chrome-safe-inline-start": "90px", + "--desktop-chrome-safe-inline-end": "0px", + "--desktop-chrome-titlebar-height": `${DESKTOP_TITLEBAR_HEIGHT_PX}px`, + }); + }); + + it("uses control-count based insets for linux custom titlebars", () => { + expect( + resolveDesktopChromeRootStyle({ + platform: "linux", + linuxTitleBarMode: "custom", + windowControlsLayout: { + left: ["minimize"], + right: ["maximize", "close"], + }, + }), + ).toEqual({ + "--desktop-chrome-safe-inline-start": "2.75rem", + "--desktop-chrome-safe-inline-end": "4.5rem", + "--desktop-chrome-titlebar-height": `${DESKTOP_TITLEBAR_HEIGHT_PX}px`, + }); + }); + + it("uses titlebar area env vars for WCO platforms", () => { + expect( + resolveDesktopChromeRootStyle({ + platform: "windows", + linuxTitleBarMode: "native", + windowControlsLayout: null, + }), + ).toEqual({ + "--desktop-chrome-safe-inline-start": "env(titlebar-area-x, 0px)", + "--desktop-chrome-safe-inline-end": + "calc(100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px))", + "--desktop-chrome-titlebar-height": `${DESKTOP_TITLEBAR_HEIGHT_PX}px`, + }); + }); + + it("zeros all desktop chrome variables when no custom or WCO chrome is active", () => { + expect( + resolveDesktopChromeRootStyle({ + platform: "linux", + linuxTitleBarMode: "native", + windowControlsLayout: null, + }), + ).toEqual({ + "--desktop-chrome-safe-inline-start": "0px", + "--desktop-chrome-safe-inline-end": "0px", + "--desktop-chrome-titlebar-height": "0px", + }); + }); +}); diff --git a/apps/web/src/desktopChromeLayout.ts b/apps/web/src/desktopChromeLayout.ts new file mode 100644 index 0000000000..bea1fc6238 --- /dev/null +++ b/apps/web/src/desktopChromeLayout.ts @@ -0,0 +1,83 @@ +import type { DesktopWindowControlsLayout, Platform } from "@t3tools/contracts"; +import type { LinuxTitleBarMode } from "@t3tools/contracts/settings"; +import { DESKTOP_TITLEBAR_HEIGHT_PX } from "@t3tools/shared/desktop"; +import type * as React from "react"; +const DESKTOP_CHROME_MACOS_SAFE_INLINE_START_PX = 90; +const DESKTOP_CHROME_SAFE_INLINE_BASE_REM = 1; +const DESKTOP_CHROME_SAFE_INLINE_STEP_REM = 1.75; + +export type DesktopChromeSafeAreaStyle = React.CSSProperties & { + "--desktop-chrome-safe-inline-start"?: string; + "--desktop-chrome-safe-inline-end"?: string; + "--desktop-chrome-titlebar-height"?: string; +}; + +export function resolveDesktopChromeSafeInlineSize(controlCount: number): string { + if (controlCount === 0) { + return "0px"; + } + return `${Math.max(0, controlCount) * DESKTOP_CHROME_SAFE_INLINE_STEP_REM + DESKTOP_CHROME_SAFE_INLINE_BASE_REM}rem`; +} + +export function resolveDesktopChromeSafeAreaStyle(input: { + leftControlCount: number; + rightControlCount: number; +}): DesktopChromeSafeAreaStyle { + const leftSafeInline = resolveDesktopChromeSafeInlineSize(input.leftControlCount); + const rightSafeInline = resolveDesktopChromeSafeInlineSize(input.rightControlCount); + + return { + "--desktop-chrome-safe-inline-start": leftSafeInline, + "--desktop-chrome-safe-inline-end": rightSafeInline, + }; +} + +function resolveDesktopChromeWcoSafeAreaStyle(): DesktopChromeSafeAreaStyle { + return { + "--desktop-chrome-safe-inline-start": "env(titlebar-area-x, 0px)", + "--desktop-chrome-safe-inline-end": + "calc(100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px))", + "--desktop-chrome-titlebar-height": `${DESKTOP_TITLEBAR_HEIGHT_PX}px`, + }; +} + +export function resolveDesktopChromeRootStyle(input: { + platform: Platform; + linuxTitleBarMode: LinuxTitleBarMode; + windowControlsLayout: DesktopWindowControlsLayout | null; +}): DesktopChromeSafeAreaStyle { + if (input.platform === "macos") { + return { + "--desktop-chrome-safe-inline-start": `${DESKTOP_CHROME_MACOS_SAFE_INLINE_START_PX}px`, + "--desktop-chrome-safe-inline-end": "0px", + "--desktop-chrome-titlebar-height": `${DESKTOP_TITLEBAR_HEIGHT_PX}px`, + }; + } + + if ( + input.platform === "linux" && + input.linuxTitleBarMode === "custom" && + input.windowControlsLayout + ) { + return { + ...resolveDesktopChromeSafeAreaStyle({ + leftControlCount: input.windowControlsLayout.left.length, + rightControlCount: input.windowControlsLayout.right.length, + }), + "--desktop-chrome-titlebar-height": `${DESKTOP_TITLEBAR_HEIGHT_PX}px`, + }; + } + + if ( + input.platform === "windows" || + (input.platform === "linux" && input.linuxTitleBarMode === "overlay") + ) { + return resolveDesktopChromeWcoSafeAreaStyle(); + } + + return { + "--desktop-chrome-safe-inline-start": "0px", + "--desktop-chrome-safe-inline-end": "0px", + "--desktop-chrome-titlebar-height": "0px", + }; +} diff --git a/apps/web/src/env.test.ts b/apps/web/src/env.test.ts new file mode 100644 index 0000000000..d326c92710 --- /dev/null +++ b/apps/web/src/env.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { shouldRenderDesktopChromeHeader, shouldUseCustomWindowControls } from "./env"; + +describe("shouldRenderDesktopChromeHeader", () => { + it("returns false on web", () => { + expect(shouldRenderDesktopChromeHeader({ platform: "web" })).toBe(false); + }); + + it("returns true on macos", () => { + expect(shouldRenderDesktopChromeHeader({ platform: "macos" })).toBe(true); + }); + + it("returns true on windows", () => { + expect(shouldRenderDesktopChromeHeader({ platform: "windows" })).toBe(true); + }); + + it("returns true on linux when the title bar is custom", () => { + expect( + shouldRenderDesktopChromeHeader({ + platform: "linux", + linuxTitleBarMode: "custom", + }), + ).toBe(true); + }); + + it("returns false on linux native title bar mode", () => { + expect( + shouldRenderDesktopChromeHeader({ + platform: "linux", + linuxTitleBarMode: "native", + }), + ).toBe(false); + }); +}); + +describe("shouldUseCustomWindowControls", () => { + it("returns false on web", () => { + expect(shouldUseCustomWindowControls({ platform: "web" })).toBe(false); + }); + + it("returns false on macos", () => { + expect(shouldUseCustomWindowControls({ platform: "macos" })).toBe(false); + }); + + it("returns false on windows", () => { + expect(shouldUseCustomWindowControls({ platform: "windows" })).toBe(false); + }); + + it("returns false on linux overlay mode", () => { + expect( + shouldUseCustomWindowControls({ + platform: "linux", + linuxTitleBarMode: "overlay", + }), + ).toBe(false); + }); + + it("returns true on linux custom mode", () => { + expect( + shouldUseCustomWindowControls({ + platform: "linux", + linuxTitleBarMode: "custom", + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index fb2e493cad..8115578287 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -1,8 +1,50 @@ -/** - * True when running inside the Electron preload bridge, false in a regular browser. - * The preload script sets window.nativeApi via contextBridge before any web-app - * code executes, so this is reliable at module load time. - */ -export const isElectron = - typeof window !== "undefined" && - (window.desktopBridge !== undefined || window.nativeApi !== undefined); +import type { DesktopWindowControlsLayout, Platform } from "@t3tools/contracts"; +import { DEFAULT_LINUX_TITLE_BAR_MODE, type LinuxTitleBarMode } from "@t3tools/contracts/settings"; + +export const desktopPlatform: Platform = + typeof window === "undefined" ? "web" : (window.desktopBridge?.getPlatform?.() ?? "web"); +export const isElectron = desktopPlatform !== "web"; +export const runningLinuxTitleBarMode: LinuxTitleBarMode = + desktopPlatform === "linux" + ? (window.desktopBridge?.getLinuxTitleBarMode?.() ?? DEFAULT_LINUX_TITLE_BAR_MODE) + : DEFAULT_LINUX_TITLE_BAR_MODE; +export const windowControlsLayout: DesktopWindowControlsLayout | null = + typeof window === "undefined" + ? null + : (window.desktopBridge?.getWindowControlsLayout?.() ?? null); +export const usesWCO = + desktopPlatform === "windows" || + (desktopPlatform === "linux" && runningLinuxTitleBarMode === "overlay"); + +export function shouldUseCustomWindowControls(options?: { + platform?: Platform; + linuxTitleBarMode?: LinuxTitleBarMode; +}): boolean { + const resolvedPlatform = options?.platform ?? desktopPlatform; + if (resolvedPlatform !== "linux") { + return false; + } + + const resolvedLinuxTitleBarMode = options?.linuxTitleBarMode ?? runningLinuxTitleBarMode; + return resolvedLinuxTitleBarMode === "custom"; +} + +export function shouldRenderDesktopChromeHeader(options?: { + platform?: Platform; + linuxTitleBarMode?: LinuxTitleBarMode; +}): boolean { + const resolvedPlatform = options?.platform ?? desktopPlatform; + if (resolvedPlatform === "web") { + return false; + } + + if (resolvedPlatform === "linux") { + const resolvedLinuxTitleBarMode = options?.linuxTitleBarMode ?? runningLinuxTitleBarMode; + return resolvedLinuxTitleBarMode !== "native"; + } + + return true; +} + +export const usesDesktopChromeHeader = shouldRenderDesktopChromeHeader(); +export const usesCustomWindowControls = shouldUseCustomWindowControls(); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 71602ee05e..a694718fcf 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -180,6 +180,18 @@ code { -webkit-app-region: no-drag; } +.desktop-chrome { + height: var(--desktop-chrome-titlebar-height); +} + +.desktop-chrome-safe-start { + padding-inline-start: max(1rem, var(--desktop-chrome-safe-inline-start, 0px)); +} + +.desktop-chrome-safe-end { + padding-inline-end: max(1rem, var(--desktop-chrome-safe-inline-end, 0px)); +} + /* Scrollbar styling */ ::-webkit-scrollbar { width: 6px; diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 3883d77c8d..647337807b 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -160,6 +160,9 @@ function createLocalStorageStub(): Storage { function makeDesktopBridge(overrides: Partial = {}): DesktopBridge { return { getLocalEnvironmentBootstrap: () => null, + getPlatform: () => "linux", + getLinuxTitleBarMode: () => "native", + setLinuxTitleBarMode: async (mode) => mode, getClientSettings: async () => null, setClientSettings: async () => undefined, getSavedEnvironmentRegistry: async () => [], @@ -182,6 +185,7 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg setTheme: async () => undefined, showContextMenu: async () => null, openExternal: async () => true, + restartApp: async () => undefined, onMenuAction: () => () => undefined, getUpdateState: async () => { throw new Error("getUpdateState not implemented in test"); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8c5046af36..6767b2bf8a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,6 +13,8 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { DesktopChromeOverlay } from "../components/DesktopChromeOverlay"; +import { resolveDesktopChromeRootStyle } from "../desktopChromeLayout"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, @@ -22,6 +24,12 @@ import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readLocalApi } from "../localApi"; +import { + desktopPlatform, + runningLinuxTitleBarMode, + usesCustomWindowControls, + windowControlsLayout, +} from "../env"; import { getServerConfigUpdatedNotification, ServerConfigUpdatedNotification, @@ -67,6 +75,11 @@ export const Route = createRootRouteWithContext<{ function RootRouteView() { const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); + const desktopChromeStyle = resolveDesktopChromeRootStyle({ + platform: desktopPlatform, + linuxTitleBarMode: runningLinuxTitleBarMode, + windowControlsLayout, + }); useEffect(() => { const frame = window.requestAnimationFrame(() => { @@ -93,13 +106,18 @@ function RootRouteView() { - - - - - - - +
+ + + + + + + + {usesCustomWindowControls && windowControlsLayout ? ( + + ) : null} +
); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 543b182c37..fdeee146f0 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,15 +1,16 @@ import { RotateCcwIcon } from "lucide-react"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect, useState } from "react"; +import { cn } from "~/lib/utils"; import { ensurePrimaryEnvironmentReady, resolveInitialServerAuthGateState, } from "../environments/primary"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; +import { usesDesktopChromeHeader } from "../env"; import { Button } from "../components/ui/button"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; -import { isElectron } from "../env"; function SettingsContentLayout() { const [restoreSignal, setRestoreSignal] = useState(0); @@ -35,7 +36,7 @@ function SettingsContentLayout() { return (
- {!isElectron && ( + {!usesDesktopChromeHeader && (
@@ -55,8 +56,12 @@ function SettingsContentLayout() {
)} - {isElectron && ( -
+ {usesDesktopChromeHeader && ( +
Settings diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 31e8693805..4e0339a70e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -51,7 +51,7 @@ import type { } from "./orchestration"; import type { EnvironmentId } from "./baseSchemas"; import { EditorId } from "./editor"; -import { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings"; +import { ClientSettings, LinuxTitleBarMode, ServerSettings, ServerSettingsPatch } from "./settings"; export interface ContextMenuItem { id: T; @@ -71,7 +71,16 @@ export type DesktopUpdateStatus = | "error"; export type DesktopRuntimeArch = "arm64" | "x64" | "other"; +export type DesktopPlatform = "macos" | "windows" | "linux"; +export type WebPlatform = "web"; +export type Platform = DesktopPlatform | WebPlatform; export type DesktopTheme = "light" | "dark" | "system"; +export type DesktopWindowControl = "minimize" | "maximize" | "close"; + +export interface DesktopWindowControlsLayout { + left: readonly DesktopWindowControl[]; + right: readonly DesktopWindowControl[]; +} export interface DesktopRuntimeInfo { hostArch: DesktopRuntimeArch; @@ -132,6 +141,10 @@ export interface DesktopServerExposureState { export interface DesktopBridge { getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; + getPlatform?: () => DesktopPlatform | null; + getLinuxTitleBarMode?: () => LinuxTitleBarMode | null; + setLinuxTitleBarMode?: (mode: LinuxTitleBarMode) => Promise; + getWindowControlsLayout?: () => DesktopWindowControlsLayout | null; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; getSavedEnvironmentRegistry: () => Promise; @@ -146,6 +159,10 @@ export interface DesktopBridge { pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; + minimizeWindow?: () => Promise; + toggleMaximizeWindow?: () => Promise; + closeWindow?: () => Promise; + restartApp?: () => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 426f8bee56..8d87c121d5 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -23,6 +23,10 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const LinuxTitleBarMode = Schema.Literals(["native", "overlay", "custom"]); +export type LinuxTitleBarMode = typeof LinuxTitleBarMode.Type; +export const DEFAULT_LINUX_TITLE_BAR_MODE: LinuxTitleBarMode = "native"; + export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diff --git a/packages/shared/package.json b/packages/shared/package.json index ed65cbeaf3..fb73815236 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -59,6 +59,10 @@ "./qrCode": { "types": "./src/qrCode.ts", "import": "./src/qrCode.ts" + }, + "./desktop": { + "types": "./src/desktop.ts", + "import": "./src/desktop.ts" } }, "scripts": { diff --git a/packages/shared/src/desktop.ts b/packages/shared/src/desktop.ts new file mode 100644 index 0000000000..bd42a88591 --- /dev/null +++ b/packages/shared/src/desktop.ts @@ -0,0 +1 @@ +export const DESKTOP_TITLEBAR_HEIGHT_PX = 52;