diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index c7a273d5..876ece10 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -376,9 +376,9 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move ## Command Surface -`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, register the Windows main-window menu event bridge, and toggle state-dependent menu items from manifest groups. +`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, custom titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups. -Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation. +Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the custom desktop titlebar menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation. ## File System Integration diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3d3a8803..38589bec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -235,7 +235,7 @@ The main Tauri window also persists its last normal size and screen position in Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell. -Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, registers the macOS Window submenu with Tauri's reserved `WINDOW_SUBMENU_ID` so NSApp can add system window-management and window-list items, registers a window-scoped menu event handler on Windows where Tauri delivers menu clicks through the main `WebviewWindow`, and cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback. +Linux and Windows use custom React-rendered window chrome instead of the native Tauri menu bar. `setup_custom_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu is macOS-only so Services/Hide/Quit and the reserved `WINDOW_SUBMENU_ID` keep behaving like normal NSApp menu items, while cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback from the renderer menu. On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the webview. Native Wayland launches and AppImage launches inject `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, covering compositor-specific WebKit crashes without changing native X11 launches. AppImage launches keep the additional AppImage-only safeguards: on Wayland sessions Tolaria re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. Runtime startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache when fcitx is configured via `GTK_IM_MODULE=fcitx` or common fcitx environment hints; release packaging currently uses Tauri's stock linuxdeploy AppImage output plugin instead of Tolaria's experimental output-plugin shim. If the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep WebViews from blanking or crashing after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available. ## Multi-Window (Note Windows) @@ -904,7 +904,7 @@ Shortcut routing is explicit: - macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path - `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control - `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active -- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions; on Windows, `menu.rs` also listens to main-window menu events because Tauri attaches the native menu to the `WebviewWindow` +- `menu.rs`, `useMenuEvents`, and the custom titlebar `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions - `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once - Deterministic QA uses two explicit proof paths from the shared manifest: - renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()` diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index edc9496c..fd5c97b7 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -125,7 +125,7 @@ tolaria/ │ │ ├── CommandPalette.tsx # Cmd+K command launcher │ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions │ │ ├── WelcomeScreen.tsx # Onboarding screen -│ │ ├── LinuxTitlebar.tsx # Linux-only custom window chrome + controls +│ │ ├── LinuxTitlebar.tsx # Linux/Windows custom window chrome + controls │ │ ├── LinuxMenuButton.tsx # Linux titlebar menu mirroring app commands │ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL │ │ ├── AddRemoteModal.tsx # Connect a local-only vault to a remote later @@ -387,7 +387,7 @@ type SidebarSelection = ### Command Registry -`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command. +`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux and Windows, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because those builds use Tolaria's custom chrome instead of the native desktop menu bar. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command. Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad65c457..8b5a8da4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -284,9 +284,10 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box Result<(), Box> { +fn should_use_native_desktop_menu(target_os: &str) -> bool { + target_os == "macos" +} + +#[cfg(all(desktop, any(target_os = "linux", target_os = "windows")))] +fn setup_custom_window_chrome(app: &mut tauri::App) -> Result<(), Box> { use tauri::Manager; if let Some(window) = app.get_webview_window("main") { @@ -317,8 +322,8 @@ fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box Result<(), Box> { +#[cfg(not(all(desktop, any(target_os = "linux", target_os = "windows"))))] +fn setup_custom_window_chrome(_app: &mut tauri::App) -> Result<(), Box> { Ok(()) } @@ -572,6 +577,7 @@ pub fn run() { #[cfg(test)] mod tests { + use super::should_use_native_desktop_menu; use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS; use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS; @@ -726,4 +732,11 @@ mod tests { missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_a)).is_empty() ); } + + #[test] + fn native_desktop_menu_is_macos_only() { + assert!(should_use_native_desktop_menu("macos")); + assert!(!should_use_native_desktop_menu("windows")); + assert!(!should_use_native_desktop_menu("linux")); + } } diff --git a/src/components/BreadcrumbBar.visibility.test.tsx b/src/components/BreadcrumbBar.visibility.test.tsx index ac1eb7e7..e08055eb 100644 --- a/src/components/BreadcrumbBar.visibility.test.tsx +++ b/src/components/BreadcrumbBar.visibility.test.tsx @@ -71,7 +71,7 @@ describe('BreadcrumbBar filename visibility', () => { it('offsets the editor-only breadcrumb title past the macOS traffic lights', () => { const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8') - expect(editorCss).toContain('.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar') + expect(editorCss).toContain('body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar') expect(editorCss).toContain('--breadcrumb-bar-left-padding: 90px;') }) diff --git a/src/components/Editor.css b/src/components/Editor.css index 952ac6f7..472cd7dd 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -30,7 +30,7 @@ transition: border-color 0.2s ease; } -.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar { +body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar { --breadcrumb-bar-left-padding: 90px; } diff --git a/src/components/LinuxTitlebar.test.tsx b/src/components/LinuxTitlebar.test.tsx index b716f4e6..6e56a7b8 100644 --- a/src/components/LinuxTitlebar.test.tsx +++ b/src/components/LinuxTitlebar.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { LinuxTitlebar } from './LinuxTitlebar' -import { shouldUseLinuxWindowChrome } from '../utils/platform' +import { shouldUseCustomWindowChrome } from '../utils/platform' const { close, @@ -25,7 +25,7 @@ const { vi.mock('../utils/platform', () => ({ isMac: () => false, - shouldUseLinuxWindowChrome: vi.fn(), + shouldUseCustomWindowChrome: vi.fn(), })) vi.mock('@tauri-apps/api/core', () => ({ @@ -47,11 +47,11 @@ vi.mock('@tauri-apps/api/window', () => ({ describe('LinuxTitlebar', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(true) + vi.mocked(shouldUseCustomWindowChrome).mockReturnValue(true) }) - it('does not render when Linux chrome is disabled', () => { - vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(false) + it('does not render when custom desktop chrome is disabled', () => { + vi.mocked(shouldUseCustomWindowChrome).mockReturnValue(false) render() @@ -68,7 +68,7 @@ describe('LinuxTitlebar', () => { expect(startDragging).not.toHaveBeenCalled() }) - it('wires the Linux titlebar window controls to the current window', () => { + it('wires custom titlebar window controls to the current window', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Minimize' })) diff --git a/src/components/LinuxTitlebar.tsx b/src/components/LinuxTitlebar.tsx index c5ad803e..4c688121 100644 --- a/src/components/LinuxTitlebar.tsx +++ b/src/components/LinuxTitlebar.tsx @@ -2,7 +2,7 @@ import type { CSSProperties, MouseEvent, ReactNode } from 'react' import { useEffect, useState } from 'react' import { getCurrentWindow } from '@tauri-apps/api/window' import { useDragRegion } from '../hooks/useDragRegion' -import { shouldUseLinuxWindowChrome } from '../utils/platform' +import { shouldUseCustomWindowChrome } from '../utils/platform' import { LinuxMenuButton } from './LinuxMenuButton' import { Button } from './ui/button' @@ -36,11 +36,11 @@ const RESIZE_HANDLES: ReadonlyArray<{ ] export function LinuxTitlebar() { - const linuxChromeEnabled = shouldUseLinuxWindowChrome() + const customChromeEnabled = shouldUseCustomWindowChrome() const { onMouseDown } = useDragRegion() - const maximized = useLinuxMaximizedState(linuxChromeEnabled) + const maximized = useLinuxMaximizedState(customChromeEnabled) - if (!linuxChromeEnabled) return null + if (!customChromeEnabled) return null const appWindow = getCurrentWindow() @@ -90,7 +90,7 @@ function useLinuxMaximizedState(enabled: boolean): boolean { } function ResizeHandles() { - if (!shouldUseLinuxWindowChrome()) return null + if (!shouldUseCustomWindowChrome()) return null const startResize = (direction: ResizeDirection) => (event: MouseEvent) => { if (event.button !== 0) return diff --git a/src/components/NoteList.rendering.test.tsx b/src/components/NoteList.rendering.test.tsx index 4530200d..9a5a9cd5 100644 --- a/src/components/NoteList.rendering.test.tsx +++ b/src/components/NoteList.rendering.test.tsx @@ -32,6 +32,18 @@ function makeBookTypeEntries( } const noop = () => undefined +const MAC_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 Safari/605.1.15' +const WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36' + +function withUserAgent(userAgent: string, callback: () => T): T { + const originalUserAgent = navigator.userAgent + Object.defineProperty(window.navigator, 'userAgent', { value: userAgent, configurable: true }) + try { + return callback() + } finally { + Object.defineProperty(window.navigator, 'userAgent', { value: originalUserAgent, configurable: true }) + } +} function makeViewDefinition(overrides: Partial = {}): ViewFile { return { @@ -831,16 +843,28 @@ describe('NoteList type sections', () => { }) describe('NoteList traffic-light padding', () => { - it('adds left padding when the sidebar is collapsed', () => { - const { container } = renderNoteList({ sidebarCollapsed: true }) - const header = container.querySelector('.h-\\[52px\\]') as HTMLElement - expect(header.style.paddingLeft).toBe('80px') + it('adds left padding for macOS traffic lights when the sidebar is collapsed', () => { + withUserAgent(MAC_USER_AGENT, () => { + const { container } = renderNoteList({ sidebarCollapsed: true }) + const header = container.querySelector('.h-\\[52px\\]') as HTMLElement + expect(header.style.paddingLeft).toBe('80px') + }) + }) + + it('does not add macOS traffic-light padding on Windows when the sidebar is collapsed', () => { + withUserAgent(WINDOWS_USER_AGENT, () => { + const { container } = renderNoteList({ sidebarCollapsed: true }) + const header = container.querySelector('.h-\\[52px\\]') as HTMLElement + expect(header.style.paddingLeft).toBe('') + }) }) it('does not add extra left padding when the sidebar is expanded', () => { - const { container } = renderNoteList({ sidebarCollapsed: false }) - const header = container.querySelector('.h-\\[52px\\]') as HTMLElement - expect(header.style.paddingLeft).toBe('') + withUserAgent(MAC_USER_AGENT, () => { + const { container } = renderNoteList({ sidebarCollapsed: false }) + const header = container.querySelector('.h-\\[52px\\]') as HTMLElement + expect(header.style.paddingLeft).toBe('') + }) }) it('defaults to no extra padding when sidebarCollapsed is omitted', () => { diff --git a/src/components/note-list/NoteListHeader.tsx b/src/components/note-list/NoteListHeader.tsx index 74c46c21..cef34b22 100644 --- a/src/components/note-list/NoteListHeader.tsx +++ b/src/components/note-list/NoteListHeader.tsx @@ -11,9 +11,11 @@ import { SortDropdown } from '../SortDropdown' import { ListPropertiesPopover, type ListPropertiesPopoverProps } from './ListPropertiesPopover' import { GitRepositorySelect } from '../GitRepositorySelect' import type { GitRepositoryOption } from '../../utils/gitRepositories' +import { isMac } from '../../utils/platform' const NOTE_LIST_ACTION_BUTTON_CLASSNAME = '!h-auto !w-auto !min-w-0 !rounded-none !p-0 !text-muted-foreground hover:!bg-transparent hover:!text-foreground focus-visible:!bg-transparent data-[state=open]:!bg-transparent data-[state=open]:!text-foreground [&_svg]:!size-4' const NOTE_LIST_EXPAND_BUTTON_CLASSNAME = '!h-6 !w-6 !min-w-0 !rounded !p-0 !text-muted-foreground hover:!bg-accent hover:!text-foreground focus-visible:!bg-accent [&_svg]:!size-4' +const COLLAPSED_SIDEBAR_MAC_CHROME_PADDING = 80 const PROPERTY_TRIGGER_TITLE_KEYS: Record = { 'Customize columns': 'noteList.properties.customizeColumns', 'Customize All Notes columns': 'noteList.properties.customizeAllColumns', @@ -290,10 +292,13 @@ export function NoteListHeader({ onGitRepositoryChange, }: NoteListHeaderProps) { const { onMouseDown: onDragMouseDown } = useDragRegion() + const collapsedSidebarPadding = sidebarCollapsed && isMac() + ? COLLAPSED_SIDEBAR_MAC_CHROME_PADDING + : undefined return ( <> -
+
(userAgent: string, callback: () => Promise): Promise { + const originalUserAgent = navigator.userAgent + Object.defineProperty(window.navigator, 'userAgent', { value: userAgent, configurable: true }) + try { + return await callback() + } finally { + Object.defineProperty(window.navigator, 'userAgent', { value: originalUserAgent, configurable: true }) + } +} + function createDragEventWithDataTransfer( type: 'dragover' | 'drop', dataTransfer: Partial, @@ -112,6 +122,7 @@ describe('main entrypoint', () => { vi.resetModules() vi.clearAllMocks() document.body.innerHTML = '
' + document.body.className = '' window.__tolariaFrontendReady = false sessionStorage.clear() }) @@ -146,6 +157,14 @@ describe('main entrypoint', () => { expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack: '' }) }) + it('marks macOS chrome for traffic-light layout offsets', async () => { + await withUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 Safari/605.1.15', async () => { + await importEntrypoint() + }) + + expect(document.body).toHaveClass('mac-chrome') + }, 60_000) + it('ignores ResizeObserver loop notifications instead of showing the fatal overlay', async () => { await importEntrypoint() diff --git a/src/main.tsx b/src/main.tsx index 27519f02..b74671a4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,7 +17,7 @@ import { type AppCommandShortcutEventOptions, } from './hooks/appCommandCatalog' import { isRecoveredBlockNoteRenderError } from './components/blockNoteRenderRecovery' -import { shouldUseLinuxWindowChrome } from './utils/platform' +import { isMac, shouldUseCustomWindowChrome } from './utils/platform' import { reloadFrontendOnceIfStartupFailed } from './utils/frontendReady' import { isNoteWindow } from './utils/windowMode' @@ -60,8 +60,12 @@ if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) { document.addEventListener('contextmenu', preventNativeContextMenu, true) } -if (shouldUseLinuxWindowChrome()) { - document.body.classList.add('linux-chrome') +if (shouldUseCustomWindowChrome()) { + document.body.classList.add('custom-window-chrome') +} + +if (isMac()) { + document.body.classList.add('mac-chrome') } applyStoredThemeMode(document, window.localStorage) diff --git a/src/utils/openNoteWindow.test.ts b/src/utils/openNoteWindow.test.ts index 2c0bd27b..56f253e6 100644 --- a/src/utils/openNoteWindow.test.ts +++ b/src/utils/openNoteWindow.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildNoteWindowUrl, buildRuntimeNoteWindowUrl, openNoteInNewWindow } from './openNoteWindow' import { isTauri } from '../mock-tauri' -import { shouldUseLinuxWindowChrome } from './platform' +import { shouldUseCustomWindowChrome } from './platform' const webviewWindowCalls = vi.fn() const localStorageMock = (() => { @@ -21,7 +21,7 @@ vi.mock('../mock-tauri', () => ({ })) vi.mock('./platform', () => ({ - shouldUseLinuxWindowChrome: vi.fn(), + shouldUseCustomWindowChrome: vi.fn(), })) vi.mock('@tauri-apps/api/webviewWindow', () => ({ @@ -52,7 +52,7 @@ describe('openNoteWindow', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-04-14T16:00:00Z')) vi.mocked(isTauri).mockReturnValue(false) - vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(false) + vi.mocked(shouldUseCustomWindowChrome).mockReturnValue(false) localStorage.clear() }) @@ -104,9 +104,9 @@ describe('openNoteWindow', () => { }) }) - it('drops native decorations when Linux window chrome is active', async () => { + it('drops native decorations when custom desktop chrome is active', async () => { vi.mocked(isTauri).mockReturnValue(true) - vi.mocked(shouldUseLinuxWindowChrome).mockReturnValue(true) + vi.mocked(shouldUseCustomWindowChrome).mockReturnValue(true) await openNoteInNewWindow('/vault/linux.md', '/vault', 'Linux Note') diff --git a/src/utils/openNoteWindow.ts b/src/utils/openNoteWindow.ts index 4285b23b..107e4357 100644 --- a/src/utils/openNoteWindow.ts +++ b/src/utils/openNoteWindow.ts @@ -1,5 +1,5 @@ import { isTauri } from '../mock-tauri' -import { shouldUseLinuxWindowChrome } from './platform' +import { shouldUseCustomWindowChrome } from './platform' import { rememberNoteWindowParams } from './windowMode' const MACOS_TRAFFIC_LIGHT_POSITION = { x: 18, y: 24 } as const @@ -56,6 +56,6 @@ export async function openNoteInNewWindow(notePath: string, vaultPath: string, n titleBarStyle: 'overlay', trafficLightPosition: new LogicalPosition(MACOS_TRAFFIC_LIGHT_POSITION.x, MACOS_TRAFFIC_LIGHT_POSITION.y), hiddenTitle: true, - decorations: !shouldUseLinuxWindowChrome(), + decorations: !shouldUseCustomWindowChrome(), }) } diff --git a/src/utils/platform.test.ts b/src/utils/platform.test.ts index 7f1fd432..62a5803f 100644 --- a/src/utils/platform.test.ts +++ b/src/utils/platform.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { isTauri } from '../mock-tauri' -import { isLinux, isMac, isWindows, shouldUseLinuxWindowChrome } from './platform' +import { isLinux, isMac, isWindows, shouldUseCustomWindowChrome } from './platform' vi.mock('../mock-tauri', () => ({ isTauri: vi.fn(), @@ -42,12 +42,18 @@ describe('platform helpers', () => { expect(isWindows()).toBe(false) }) - it('only enables Linux window chrome inside Tauri', () => { + it('enables custom desktop chrome on Linux and Windows inside Tauri', () => { setUserAgent('Mozilla/5.0 (X11; Linux x86_64)') vi.mocked(isTauri).mockReturnValue(false) - expect(shouldUseLinuxWindowChrome()).toBe(false) + expect(shouldUseCustomWindowChrome()).toBe(false) vi.mocked(isTauri).mockReturnValue(true) - expect(shouldUseLinuxWindowChrome()).toBe(true) + expect(shouldUseCustomWindowChrome()).toBe(true) + + setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)') + expect(shouldUseCustomWindowChrome()).toBe(true) + + setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') + expect(shouldUseCustomWindowChrome()).toBe(false) }) }) diff --git a/src/utils/platform.ts b/src/utils/platform.ts index c64c89df..31a6c4b6 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -19,6 +19,6 @@ export function isWindows(): boolean { return getUserAgent().includes('Windows') } -export function shouldUseLinuxWindowChrome(): boolean { - return isTauri() && isLinux() +export function shouldUseCustomWindowChrome(): boolean { + return isTauri() && (isLinux() || isWindows()) }