diff --git a/docs/shell-design.md b/docs/shell-design.md index a2ad9f2cf..04176b627 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -88,15 +88,19 @@ motion with Tailwind's `motion-reduce` variant. Tauri uses `titleBarStyle: Overlay` and `hiddenTitle` on macOS. Native traffic lights have a reserved 104px left area before the community switcher only in the -macOS desktop runtime. Web gets no -inset or imitation window controls. Other -platforms retain their native decorations. Drag regions are limited to the -header background; controls remain clickable. On macOS, double-clicking that +macOS desktop runtime. Web gets no inset or imitation window controls. Linux +and Windows desktop use undecorated windows with app-owned minimize, +maximize/restore and close buttons in the header, including during identity +setup. Drag regions are limited to the header background; controls remain +clickable. On macOS, double-clicking that background follows the current system title-bar preference (Fill/Zoom, Minimize, or no action); changing the preference does not require restarting Buzz. Other platforms retain Tauri's native drag-region behavior. The main-window capability -grants only titlebar dragging and the internal maximize action used by that -handler, plus scoped HTTP(S) opening for +grants titlebar dragging and the internal maximize action used by that +handler. A Linux and Windows main-webview capability also grants minimize, +maximize/restore and close for the integrated controls. These actions do not +change the app’s existing close lifecycle. The main capability includes scoped +HTTP(S) opening for [external links](channels.md#run-the-integration). See [Tauri window customization](https://v2.tauri.app/learn/window-customization/). diff --git a/src-tauri/capabilities/desktop-window.json b/src-tauri/capabilities/desktop-window.json new file mode 100644 index 000000000..5131bdce6 --- /dev/null +++ b/src-tauri/capabilities/desktop-window.json @@ -0,0 +1,12 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "desktop-window", + "description": "Linux and Windows integrated window controls, restricted to the main local webview.", + "platforms": ["linux", "windows"], + "webviews": ["main"], + "permissions": [ + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close" + ] +} diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 000000000..9e9c49559 --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,15 @@ +{ + "app": { + "windows": [ + { + "title": "Buzz Foundation", + "dragDropEnabled": false, + "width": 1200, + "height": 800, + "decorations": false, + "minWidth": 480, + "minHeight": 400 + } + ] + } +} diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 000000000..9e9c49559 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,15 @@ +{ + "app": { + "windows": [ + { + "title": "Buzz Foundation", + "dragDropEnabled": false, + "width": 1200, + "height": 800, + "decorations": false, + "minWidth": 480, + "minHeight": 400 + } + ] + } +} diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index 297cab5cf..2c749096c 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -15,6 +15,7 @@ import { PageSearch, type SearchServices } from "./PageSearch"; import { orderPages, pagePresentation } from "./presentation"; import { PanelFrame } from "../../features/panels/PanelFrame"; import { macTitleBarDragHandlers } from "./title-bar"; +import { WindowControls } from "./WindowControls"; const macDesktop = isTauri() && /Mac/i.test(navigator.platform); const titleBarDragProps = macDesktop ? macTitleBarDragHandlers : {}; @@ -200,6 +201,7 @@ export function AppShell({ onSettings={() => onSelect("settings")} onProfile={onProfile} /> + diff --git a/src/app/shell/WindowControls.test.tsx b/src/app/shell/WindowControls.test.tsx new file mode 100644 index 000000000..a7b269491 --- /dev/null +++ b/src/app/shell/WindowControls.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { isTauri } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { WindowControls } from "./WindowControls"; + +vi.mock("@tauri-apps/api/core", () => ({ isTauri: vi.fn() })); +vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: vi.fn() })); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +it.each([ + [false, "Linux x86_64"], + [false, "Win32"], + [true, "MacIntel"], + [true, "Darwin"], +])( + "does not render outside native Linux or Windows (%s, %s)", + (native, platform) => { + vi.mocked(isTauri).mockReturnValue(native); + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + render(); + expect(screen.queryByRole("group")).not.toBeInTheDocument(); + expect(getCurrentWindow).not.toHaveBeenCalled(); + }, +); + +it.each(["Linux x86_64", "Win32"])( + "supports keyboard window actions and retry on %s", + async (platform) => { + vi.mocked(isTauri).mockReturnValue(true); + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + const minimize = vi + .fn() + .mockRejectedValueOnce(new Error("denied")) + .mockResolvedValue(undefined); + const toggleMaximize = vi.fn().mockResolvedValue(undefined); + const close = vi.fn().mockResolvedValue(undefined); + vi.mocked(getCurrentWindow).mockReturnValue({ + minimize, + toggleMaximize, + close, + } as unknown as ReturnType); + const user = userEvent.setup(); + render(); + await user.tab(); + expect( + screen.getByRole("button", { name: "Minimize window" }), + ).toHaveFocus(); + await user.keyboard("{Enter}"); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Window action failed", + ); + await user.keyboard("{Enter}"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + await user.tab(); + await user.keyboard("{Enter}"); + await user.tab(); + await user.keyboard(" "); + expect(minimize).toHaveBeenCalledTimes(2); + expect(toggleMaximize).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }, +); diff --git a/src/app/shell/WindowControls.tsx b/src/app/shell/WindowControls.tsx new file mode 100644 index 000000000..927e22500 --- /dev/null +++ b/src/app/shell/WindowControls.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { isTauri } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { + ArrowsOutIcon, + MinusIcon, + XIcon, +} from "../../shared/design-system/icons"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; + +export function hasIntegratedWindowControls() { + return isTauri() && /^(Linux|Win)/i.test(navigator.platform); +} + +/** App-owned chrome shared by the shell and pre-identity screen. */ +export function WindowControls() { + const [error, setError] = useState(); + if (!hasIntegratedWindowControls()) return null; + const run = async (action: () => Promise) => { + setError(undefined); + try { + await action(); + } catch { + setError("Window action failed. Try again."); + } + }; + return ( +
+ {error && ( + + {error} + + )} + } + onClick={() => void run(() => getCurrentWindow().minimize())} + /> + } + onClick={() => void run(() => getCurrentWindow().toggleMaximize())} + /> + } + onClick={() => void run(() => getCurrentWindow().close())} + /> +
+ ); +} diff --git a/src/features/identity/IdentitySetup.tsx b/src/features/identity/IdentitySetup.tsx index 53f4a7c5b..2bf28f8ed 100644 --- a/src/features/identity/IdentitySetup.tsx +++ b/src/features/identity/IdentitySetup.tsx @@ -1,5 +1,9 @@ import { useState, useSyncExternalStore, type ReactNode } from "react"; import { isTauri } from "@tauri-apps/api/core"; +import { + hasIntegratedWindowControls, + WindowControls, +} from "../../app/shell/WindowControls"; import { macTitleBarDragHandlers } from "../../app/shell/title-bar"; import { Button } from "../../shared/design-system/ui/Button"; import { Input } from "../../shared/design-system/ui/Input"; @@ -15,14 +19,19 @@ export function IdentitySetup({ const state = useSyncExternalStore(identity.subscribe, identity.snapshot); const [importing, setImporting] = useState(false); const [nsec, setNsec] = useState(""); + const macDesktop = isTauri() && /Mac/i.test(navigator.platform); + const integratedWindowControls = hasIntegratedWindowControls(); if (state.status === "ready") return children; return (
- {isTauri() && /Mac/i.test(navigator.platform) && ( + {(macDesktop || integratedWindowControls) && (
+ className="-mx-8 -mt-8 mb-8 flex h-14 items-center justify-end px-3" + data-tauri-drag-region={integratedWindowControls || undefined} + {...(macDesktop ? macTitleBarDragHandlers : {})} + > + +
)}

Your Buzz identity diff --git a/src/features/identity/identity.test.tsx b/src/features/identity/identity.test.tsx index b6cfc7ce7..bf3b763f7 100644 --- a/src/features/identity/identity.test.tsx +++ b/src/features/identity/identity.test.tsx @@ -93,10 +93,10 @@ it("keeps native titlebar actions available throughout identity setup without dr }); it.each([ - [true, "Win32"], - [true, "Linux x86_64"], + [false, "Win32"], + [false, "Linux x86_64"], [false, "MacIntel"], -])("does not add a macOS titlebar for native=%s on %s", (native, platform) => { +])("does not add a titlebar for native=%s on %s", (native, platform) => { vi.mocked(isTauri).mockReturnValue(native); vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); vi.mocked(invoke).mockReturnValue(new Promise(() => {})); @@ -286,3 +286,17 @@ it("enables packaged identity on desktop platforms but never in the live broker vi.spyOn(navigator, "platform", "get").mockReturnValue("iPhone"); expect(nativeIdentityEnabled()).toBe(false); }); + +it.each(["Linux x86_64", "Win32"])( + "keeps %s window controls available before identity restoration completes", + (platform) => { + vi.mocked(isTauri).mockReturnValue(true); + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + vi.mocked(invoke).mockReturnValue(new Promise(() => {})); + const identity = createIdentity(); + render(Signed in); + expect(titleBar()).toHaveAttribute("data-tauri-drag-region", "true"); + expect(screen.getByRole("button", { name: "Close window" })).toBeEnabled(); + identity.dispose(); + }, +);