Restore Windows custom titlebar controls so desktop builds can manage the window again

The Windows desktop shell was rendering custom minimize, maximize, and close
buttons without the Tauri capabilities those commands require. The app was also
starting with decorated windows and then trying to disable decorations at
runtime, which is a weaker setup than the platform-specific config Tauri
expects for custom chrome.

This change grants the missing window permissions, moves Windows and macOS
window chrome settings into platform-specific Tauri config files, removes the
runtime decoration toggle, and adds a regression test that proves the custom
controls invoke the Tauri window API.

Constraint: Windows build validation will happen on the remote branch instead of this macOS workspace
Rejected: Keep runtime set_decorations(false) on Windows | leaves window chrome behavior dependent on post-start native mutation
Rejected: Ship only the capability fix | lower-risk hotfix, but it preserves the fragile cross-platform window config
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Keep custom titlebar behavior in config and capabilities; avoid moving Windows decoration changes back into runtime setup
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run test
Not-tested: Native Windows click/drag behavior on an actual packaged build from this local machine
Related: GitHub issue #62
This commit is contained in:
程序员阿江(Relakkes) 2026-04-19 13:59:31 +08:00
parent f958bf7e0d
commit e0b74cecae
7 changed files with 118 additions and 11 deletions

View File

@ -5,7 +5,10 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"shell:allow-open",
{
"identifier": "shell:allow-execute",

View File

@ -394,12 +394,6 @@ pub fn run() {
// 让 adapter 连上动态端口。
spawn_and_track_adapters_sidecar(&app.handle());
let _window = app.get_webview_window("main").unwrap();
// Windows: hide native decorations — frontend renders custom titlebar
#[cfg(target_os = "windows")]
let _ = _window.set_decorations(false);
Ok(())
})
.build(tauri::generate_context!())

View File

@ -18,8 +18,6 @@
"minWidth": 960,
"minHeight": 640,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"transparent": false,
"acceptFirstMouse": true
}

View File

@ -0,0 +1,18 @@
{
"app": {
"windows": [
{
"title": "Claude Code Haha",
"width": 1440,
"height": 960,
"minWidth": 960,
"minHeight": 640,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"transparent": false,
"acceptFirstMouse": true
}
]
}
}

View File

@ -0,0 +1,16 @@
{
"app": {
"windows": [
{
"title": "Claude Code Haha",
"width": 1440,
"height": 960,
"minWidth": 960,
"minHeight": 640,
"decorations": false,
"transparent": false,
"acceptFirstMouse": true
}
]
}
}

View File

@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
const minimize = vi.fn().mockResolvedValue(undefined)
const toggleMaximize = vi.fn().mockResolvedValue(undefined)
const close = vi.fn().mockResolvedValue(undefined)
const isMaximized = vi.fn().mockResolvedValue(false)
const onResized = vi.fn().mockResolvedValue(() => {})
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({
minimize,
toggleMaximize,
close,
isMaximized,
onResized,
}),
}))
describe('WindowControls', () => {
const originalPlatform = navigator.platform
beforeEach(async () => {
minimize.mockClear()
toggleMaximize.mockClear()
close.mockClear()
isMaximized.mockClear()
onResized.mockClear()
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {},
})
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'Win32',
})
vi.resetModules()
})
afterEach(() => {
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: originalPlatform,
})
})
it('invokes Tauri window APIs for custom controls on Windows', async () => {
const { WindowControls } = await import('./WindowControls')
render(<WindowControls />)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Minimize window' })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'Minimize window' }))
fireEvent.click(screen.getByRole('button', { name: 'Maximize window' }))
fireEvent.click(screen.getByRole('button', { name: 'Close window' }))
await waitFor(() => {
expect(minimize).toHaveBeenCalledTimes(1)
expect(toggleMaximize).toHaveBeenCalledTimes(1)
expect(close).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -34,13 +34,20 @@ export function WindowControls() {
return () => { unlisten?.() }
}, [])
const runWindowAction = (action: () => Promise<void>) => {
void action().catch((error) => {
console.error('Window control action failed', error)
})
}
if (!showWindowControls || !win) return null
return (
<div className="flex items-stretch flex-shrink-0 -my-px">
{/* Minimize */}
<button
onClick={() => win.minimize()}
onClick={() => runWindowAction(() => win.minimize())}
aria-label="Minimize window"
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
<svg width="10" height="1" viewBox="0 0 10 1">
@ -50,7 +57,8 @@ export function WindowControls() {
{/* Maximize / Restore */}
<button
onClick={() => win.toggleMaximize()}
onClick={() => runWindowAction(() => win.toggleMaximize())}
aria-label={maximized ? 'Restore window' : 'Maximize window'}
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
{maximized ? (
@ -67,7 +75,8 @@ export function WindowControls() {
{/* Close */}
<button
onClick={() => win.close()}
onClick={() => runWindowAction(() => win.close())}
aria-label="Close window"
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[#e81123] hover:text-white transition-colors"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">