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 (
+
+ );
+}
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();
+ },
+);