Windows & permissions
Declare multiple native windows and give each renderer only the commands it needs.
Murasaki creates application windows from murasaki.config.ts. The primary
window is the existing window field and always has the label main.
Additional entries live in windows, keyed by a stable label:
import { defineConfig } from 'murasaki'
export default defineConfig({
appId: 'com.example.notes',
productName: 'Notes',
capabilities: ['clipboard:readText'], // fallback for main only
window: {
route: '/',
width: 1100,
height: 760,
capabilities: ['window:getLabel', 'window:open', 'window:list', 'window:manage'],
},
windows: {
settings: {
route: '/settings',
title: 'Settings',
width: 720,
height: 560,
// visible defaults to false for secondary windows
capabilities: [],
},
preview: {
route: '/preview',
visible: false,
capabilities: ['clipboard:readText'],
},
report: {
route: '/report',
createOnLaunch: false,
capabilities: [],
},
},
})Declared windows use createOnLaunch: true by default. A startup-created
secondary starts hidden unless visible: true is set. Set
createOnLaunch: false to keep a declaration dormant until trusted Node Main
calls windows.create(label). A secondary with no capabilities is deny-all;
it never inherits the top-level list. The primary is always created at launch
and resolves permissions as
window.capabilities ?? capabilities ?? [].
Open a hidden secondary from a visible renderer, normally main. WebKit and
WebView2 may either run that renderer before it is shown or defer its
JavaScript until after it becomes visible, so do not depend on either timing
and do not make the hidden renderer responsible for calling windows.open()
on itself. Native visibility is applied by the OS event loop; when code must
observe the post-open state through windows.list(), wait for visible to
become true instead of assuming the first read is synchronous with the
compositor.
The Windows backend console option is primary-only because it controls the
application backend console, not an individual native window. Putting
console on a secondary declaration is rejected during configuration
resolution.
Labels must be 1–64 characters, start with a letter or number, and contain
only letters, numbers, ., _, or -. main is reserved. A route must be
a same-origin path beginning with /; full URLs, protocol-relative URLs, and
backslashes are rejected during configuration resolution.
Identify and manage windows
Use appWindow for the renderer's own native window and windows for a
declared window by label:
'use client'
import { appWindow, windows } from 'murasaki/native'
const current = await appWindow.getLabel()
await windows.open('settings')
await windows.focus('settings')
const all = await windows.list()
const settings = all.find((item) => item.label === 'settings')
console.log({ current, settings })
await windows.hide('settings')windows.list() returns WindowInfo[]:
interface WindowInfo {
label: string
primary: boolean
visible: boolean
focused: boolean
minimized: boolean
maximized: boolean
}| API | Capability | Behavior |
|---|---|---|
appWindow.getLabel() | window:getLabel | Returns the current renderer's label. |
windows.open(label) | window:open | Shows, restores, and focuses a live declared window. |
windows.list() | window:list | Returns state for all live declared windows. |
windows.show(label) / hide(label) / focus(label) / close(label) | window:manage | Manages another declared window. |
Only labels declared in configuration are available. These APIs do not create
an arbitrary route or a new runtime window. windows.open(label) is show-only:
it rejects when the declared label is dormant or has been destroyed. Use the
Node Main window manager to create that declaration first.
The OS close control and appWindow.close() hide a secondary window, so
windows.open(label) can show that same declared window again. In contrast,
windows.close(label) explicitly destroys a secondary target; a destroyed
secondary can only be recreated by trusted Node Main. Closing main remains
an application quit request. See Node Main.
Frameless windows and custom titlebars
Set decorations: false to remove the OS window chrome on every platform, or
macOS's titleBarStyle: 'hidden' to keep the traffic-light buttons but hide
the title text and extend the WebView underneath them:
export default defineConfig({
appId: 'com.example.notes',
productName: 'Notes',
window: {
decorations: false, // frameless on every platform
// titleBarStyle: 'hidden', // macOS only; accepted (and ignored, with a
// // config warning) on Windows/Linux
},
})Without a native titlebar, the OS has nothing left to drag the window by —
opt a region back in with useWindowDrag():
'use client'
import { appWindow } from 'murasaki/native'
import { useWindowDrag } from 'murasaki'
function Titlebar() {
const drag = useWindowDrag()
return (
<header {...drag} style={{ WebkitUserSelect: 'none' }}>
<span>My App</span>
<button data-murasaki-no-drag onClick={() => appWindow.close()}>
×
</button>
</header>
)
}useWindowDrag() starts a native drag on primary-button pointerdown, skipping
interactive targets (button, input, a, select, textarea, or anything
marked data-murasaki-no-drag) so titlebar controls underneath the draggable
region keep working. It is a silent no-op outside the native renderer and
when the OS declines the drag (for example, outside an active mouse-down).
Fullscreen, max size, and monitors
| API | Capability | Behavior |
|---|---|---|
appWindow.startDragging() | window:manage | Starts an OS window drag — see useWindowDrag() above. |
appWindow.setFullscreen(bool) / isFullscreen() | window:manage | Enters/exits borderless fullscreen on the window's current monitor. Exclusive (dedicated-video-mode) fullscreen is not supported. |
appWindow.setMaxSize({ width?, height? }) | window:manage | Sets the maximum inner size. Both omitted or null clears it; a single axis is rejected — provide both or neither. |
appWindow.getMonitors() | window:manage | Returns every OS display visible to the window, in physical pixels. |
These act on the calling renderer's own window, so window:manage gates them
by simple grant membership — unlike windows.show/hide/focus/close(label)
above, there is no other-window label argument for a scoped grant to apply to.
window.fullscreen (also under windows.<label>) sets the initial state at
launch; window.maxWidth/maxHeight set the initial maximum size and must be
greater than or equal to minWidth/minHeight when both are configured:
export default defineConfig({
appId: 'com.example.notes',
productName: 'Notes',
window: { maxWidth: 1600, maxHeight: 1200 },
})getMonitors() resolves { monitors: WindowMonitorInfo[] }:
interface WindowMonitorInfo {
name: string | null
isPrimary: boolean
isCurrent: boolean
x: number
y: number
width: number
height: number
scaleFactor: number
}Primary close and application lifecycle
Using the OS close control or appWindow.close() on a secondary hides that
window and affects no other window. Closing main is an application quit
request: it runs beforeQuit(), can be cancelled there, and then runs bounded
shutdown() before the native host exits. Murasaki does not currently expose
a separate per-window close hook.
Session isolation
Each window label has its own WebContext/profile, so cookies, Web Storage,
Service Workers, and SharedWorkers are not shared between startup-created or
dynamically created labels. Recreating the same label reuses its process
context. On Windows, each persistent label is stored below the app-scoped
WebView2 data directory. With webview.incognito: true, every label uses a
non-persistent context.
Per-window least privilege
Permissions belong to the renderer that makes the call. A settings window
that only edits React state can use capabilities: []; a preview that reads
the clipboard can receive only clipboard:readText. Management permissions
should normally live on the trusted primary renderer:
export default defineConfig({
appId: 'com.example.notes',
productName: 'Notes',
window: {
route: '/',
capabilities: [
'window:list',
{ permission: 'window:open', allow: { windows: ['settings'] } },
{ permission: 'window:manage', allow: { windows: ['settings'] } },
],
backendCapabilities: ['api:GET:/api/account'],
},
windows: {
settings: { route: '/settings', capabilities: [], backendCapabilities: ['api:GET:/api/settings'] },
importer: { route: '/import', capabilities: ['dialog:openFile'], backendCapabilities: [] },
},
})Per-window capability lists limit which native command names a compromised
renderer can call. Structured window:open and window:manage grants also
limit exact target labels; deny takes precedence over allow. Dialog
defaults and many other command arguments are not scopeable. Do not load remote
or user-authored executable content into any privileged renderer, and validate
app-level intent before broad operations.
Native and backend allowlists protect separate boundaries. Every native window
receives a label-bound HMAC identity, and backendCapabilities limits its
Server Actions, 'use main', API routes, updater routes, events, and diagnostics.
Secondary windows default to no backend grants. Although routes use one HTTP
origin, each window receives an isolated browser profile so a Service Worker,
SharedWorker, cookie, or storage entry from one window cannot inherit another
window's backend authority. The primary keeps the historical application
profile. Secondary profiles persist on Windows/Linux and macOS 14+; macOS
11–13 uses a separate non-persistent store because custom persistent WebKit
stores are unavailable there. This is still not a complete renderer sandbox:
an XSS can use every resource granted to its own window. Authenticate and
authorize user/session/object-level operations inside handlers; do not load
untrusted executable content into an application window. Share durable state
through Main/API handlers rather than browser storage when multiple windows
need the same data.