Murasaki
Guides

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:

murasaki.config.ts
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
}
APICapabilityBehavior
appWindow.getLabel()window:getLabelReturns the current renderer's label.
windows.open(label)window:openShows, restores, and focuses a live declared window.
windows.list()window:listReturns state for all live declared windows.
windows.show(label) / hide(label) / focus(label) / close(label)window:manageManages 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:

murasaki.config.ts
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

APICapabilityBehavior
appWindow.startDragging()window:manageStarts an OS window drag — see useWindowDrag() above.
appWindow.setFullscreen(bool) / isFullscreen()window:manageEnters/exits borderless fullscreen on the window's current monitor. Exclusive (dedicated-video-mode) fullscreen is not supported.
appWindow.setMaxSize({ width?, height? })window:manageSets the maximum inner size. Both omitted or null clears it; a single axis is rejected — provide both or neither.
appWindow.getMonitors()window:manageReturns 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:

murasaki.config.ts
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:

murasaki.config.ts
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.

Next

Improve this page on GitHub

On this page