pulsar
EN

App API (backend / TypeScript)

import {
  App,
  Store,
  WindowHandle,
  fs,
  net,
  paths,
  shell,
  storage,
  secureStorage,
  dialog,
  notify,
  screen,
  system,
  theme,
  tray,
  clipboard,
  device,
  media,
  permissions,
  share,
  callCapability,
  registerCapability,
} from "pulsar"

App

Member Description
new App(config?) Create the app. Config comes from pulsar.json; config overrides it.
app.store Reactive state shared with all windows (see Store).
app.on(event, fn) Subscribe to an event from QML (Pulsar.emit), the host or app.send.
app.window(label?) Get a WindowHandle (defaults to the first window).
app.createWindow(config) Create a window at runtime; returns a WindowHandle.
app.closeWindow(label?) Close a window (defaults to the first).
app.send(event, payload?, target?) Send an event to the UI (target label, or *).
app.windows All open windows (start windows plus created ones).
app.config The first window's config.
app.manifest The parsed pulsar.json (or null).
app.quit() Quit the application.
app.run() No-op marker; the host owns the event loop.

Store

A JSON-safe reactive map. Every change is pushed to QML as Store.<key>.

app.store.set("count", 0)
app.store.get("count") // unknown -> cast
app.store.has("count")
app.store.delete("count")
app.store.keys()
app.store.set("items", [{ id: 1 }, { id: 2 }])

Only JSON-serializable values are allowed (objects, arrays, strings, finite numbers, booleans, null). Everything else throws a descriptive error; use delete instead of setting undefined. Circular references are rejected.

Setting a value that is deeply equal to the current one does nothing: no listener runs, nothing is pushed and the UI is not re-rendered. This keeps a polling sync loop from rebuilding a list when nothing changed. Keys added after the window is open are pushed as usual; bind them directly (Store.<key>) so QML tracks them.

Windows

const panel = app.window("panel")

await panel.show()
await panel.hide()
await panel.close()
await panel.focus()
await panel.minimize()
await panel.maximize()
await panel.restore()
await panel.setTitle("Panel")
await panel.setSize(360, 260)
await panel.setPosition(100, 100)
await panel.setFullscreen(true)
await panel.setAlwaysOnTop(true)
await panel.destroy() // close and drop the window

Each method returns a Promise<boolean>. Windows declared in pulsar.json are just start windows — create more at runtime:

const settings = app.createWindow({ label: "settings", ui: "ui/settings.qml", width: 420, height: 320 })
await settings.show()

Lifecycle events

The host reports window and tray activity back to the app:

app.on("window-close", (params) => console.log("closed", params.label))
app.on("window-state", (params) => console.log(params.label, params.state)) // normal|minimized|maximized|fullscreen
app.on("window-resize", (params) => console.log(params.label, params.width, params.height))
app.on("window-move", (params) => console.log(params.label, params.x, params.y))
app.on("window-focus", () => {})
app.on("window-blur", () => {})
app.on("window-all-closed", () => app.quit())

On Android the app also receives foreground and back-button events:

app.on("app-pause", (params) => saveDraft(params.state)) // "inactive" | "suspended"
app.on("app-resume", () => refresh())
app.on("app-stop", () => {}) // went to background; Android may kill the process
app.on("back-pressed", () => closeDialog()) // no listener: back quits the app

app-pause with state: "inactive" also fires for dialogs and the notification shade, so persist durable state there (or on app-stop): Android can kill a background process without further callbacks. While the software keyboard is open, the first back press only hides it. These events never fire on desktop.

Screens and the router

createRouter(app) keeps a screen stack for TypeScript-rendered apps. Push and pop publish the current route to Store (under route by default), so the UI re-renders, and the system back button pops the stack:

import { createRouter } from "pulsar"

const router = createRouter(app)

router.push("settings", { tab: "general" })
router.replace("settings")
router.pop() // false at the root

app.window("main").render(() =>
  router.view({
    home: () => HomeScreen(),
    settings: (params) => SettingsScreen({ tab: params.tab as string }),
  }),
)

Router methods: current(), entries(), canGoBack(), depth(), push(name, params?), replace(name, params?), pop(), reset(name?, params?), subscribe(fn) and view(screens). Back pops the stack and quits the app when the stack is empty; pass handleBack: false to disable that. storeKey renames the published store key, and initial sets the first route (default "home").

Files and URLs the OS hands the app (command-line arguments, or a macOS file open) arrive as events:

app.on("open-file", (params) => console.log("open", params.path))
app.on("open-url", (params) => console.log("deep link", params.url))

With singleInstance: true in pulsar.json only one instance runs: a second launch forwards its arguments to the running app and exits. The running app gets a second-instance event so it can raise its window:

app.on("second-instance", (params) => {
  app.window().focus()
  console.log("relaunched with", params.argv)
})

Recent documents

Tell the OS which files the user opened; they show up in the system recent lists (Jump List on Windows, the app's recents on macOS, recently-used.xbel on Linux):

app.addRecentDocument("/home/me/notes.md")
app.clearRecentDocuments()

Paths are resolved to absolute. Clearing empties the list for this app (on Linux the shared recently-used.xbel is reset).

File associations

Declare the file types the app opens in pulsar.json:

"fileAssociations": [
  { "ext": "md", "mime": "text/markdown", "name": "Markdown document" }
]

On launch the host registers them idempotently: on Linux it writes a desktop entry to $XDG_DATA_HOME/applications and a default in $XDG_CONFIG_HOME/mimeapps.list (so mime is required there); on Windows it adds the app to OpenWithProgids under HKCU\Software\Classes. macOS file types belong to app bundles, so single-file builds ignore this field — use protocols for deep links there.

Backend → UI events

app.send(event, payload, target?) pushes a one-off event to the UI (target is a window label or * for every window). app.window(label).emit(...) targets a single window. In QML subscribe through the Pulsar singleton and filter by the PulsarWindowLabel context property:

app.send("update-ready", { version: "0.2.0" })
Connections {
    target: Pulsar
    function onAppEvent(event, payload, target) {
        if (target === "*" || target === PulsarWindowLabel)
            if (event === "update-ready") banner.text = "Update " + payload.version
    }
}

Dialogs

Native, implemented by the Qt host.

import { dialog } from "pulsar"

const path = await dialog.openFile({ title: "Open", filter: "Images (*.png *.jpg);;All files (*)" })
if (path) app.store.set("picked", path)

const saveTo = await dialog.saveFile({ title: "Save as" })
await dialog.message({ title: "Pulsar", text: "Done!" })

Tray

import { tray } from "pulsar"

await tray.setIcon((process.env.PULSAR_APP ?? ".") + "/assets/icon.png")
await tray.setTooltip("My App")
await tray.setMenu([{ label: "Show", event: "show" }, { separator: true }, { label: "Quit", event: "quit" }])

app.on("tray-menu", (data: { event?: string }) => {
  if (data.event === "show") app.window().show()
  if (data.event === "quit") app.quit()
})

Tray emits tray-menu ({ event }) when an item is clicked and tray-activated ({ reason }) when the icon is activated.

Media player

media drives one host-backed player with a JS playlist. On Android it runs a foreground service with a MediaSession and lockscreen controls.

import { media } from "pulsar"

media.load([
  { source: "/path/first.mp3", title: "First", artist: "Demo" },
  { source: "https://example.com/second.mp3", title: "Second" },
])
media.play()

const unsubscribe = media.on((snapshot) => {
  app.store.set("player", snapshot)
})

media.seek(42) // seconds
media.setRate(1.25)
media.setVolume(0.8)
media.next()

The snapshot carries state (idle, loading, playing, paused, stopped, ended or error), position, duration, rate, volume, index, source, title, artist and playing. Other methods: pause(), toggle(), stop(), previous(), setMetadata(title, artist?), state() and playlist().

File system, paths, network, shell, storage

import { fs, paths, net, storage } from "pulsar"

const text = await fs.readFile("notes.txt") // utf8 by default
await fs.writeFile("notes.txt", "hello")
await fs.appendFile("notes.txt", " world")
const entries = await fs.listDir(".")
const exists = await fs.exists("notes.txt")
const info = await fs.stat("notes.txt") // { size, isFile, isDirectory, mtimeMs, createdAtMs }

await fs.mkdir("data/sub", true)
await fs.rename("a.txt", "b.txt")
await fs.copy("b.txt", "copy.txt")
await fs.remove("copy.txt") // recursive: fs.remove(path, true)
await fs.moveToTrash("b.txt")

const stop = fs.watch("notes.txt", (path) => console.log("changed", path))
stop() // unsubscribe; watches are reference-counted per path

await fs.open("report.pdf") // open with the OS default app
await fs.exportTo("report.pdf", "report.pdf") // copy out to Downloads

const bytes = await fs.readFile("logo.png", "base64") // binary as base64
await fs.writeFile("logo.png", bytes, "base64")

const dirs = await paths.get() // { data, config, cache, temp, home }

const res = await net.fetch("https://api.example.com/items") // { status, statusText, url, headers, text }
const created = await net.fetch("https://api.example.com/items", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "demo" }),
})
const logo = await net.fetch("https://example.com/logo.png", { responseType: "binary" })
logo.bytes // Uint8Array

await shell.open("https://github.com/riclud/pulsar")

await storage.set("todos", [{ id: 1, text: "write docs" }])
const todos = await storage.get("todos")
await storage.remove("todos")

net.fetch(url, options) runs in the native host, not in the JS engine, so it works the same under Bun, Node, Hermes or QuickJS. Options: method, headers, body (string or Uint8Array), timeoutMs, redirect (follow / manual / error), maxRedirects, responseType (text / binary / auto) and tlsFingerprint. tlsFingerprint: true (or a profile such as "chrome120") is reserved for the impersonating backend; builds without it reject with ERR_FINGERPRINT_UNAVAILABLE.

Use net.request(url, options) for streaming: it emits response, data (string or Uint8Array), end and error, and has abort():

const request = net.request("https://example.com/big.bin")
request.on("data", (chunk) => console.log(chunk))
request.on("end", () => console.log("done"))
request.on("error", (error) => console.error(error.code, error.message))

net.websocket(url, options) opens a ws:// or wss:// connection in the native host. Options: headers, protocols (subprotocols) and timeoutMs (connect timeout, default 30s). Messages are strings or Uint8Arrays:

const socket = net.websocket("wss://example.com/ws", { protocols: ["chat"] })

socket.on("open", (info) => console.log("connected", info.protocol))
socket.on("message", (data) => console.log(typeof data === "string" ? data : data.byteLength))
socket.on("close", (info) => console.log(info.code, info.reason, info.wasClean))
socket.on("error", (error) => console.error(error.code, error.message))

socket.send("hello")
socket.send(new Uint8Array([1, 2, 3]))
socket.close(1000, "bye")

net.download(url, path, options) writes a response straight to disk: headers, timeoutMs, resume (continue a partial file with a Range request when the server allows it) and onProgress(received, total). It resolves with { path, bytes, resumed, status, statusText, url, headers }.

const file = await net.download("https://example.com/big.zip", "big.zip", {
  resume: true,
  onProgress: (received, total) => console.log(received, total),
})

fs.watch(path, listener) reports changes to a file or directory and returns an unsubscribe function; watches are reference-counted per path.

fs.open(path) hands a file to the OS. Videos play in a full-screen built-in player: on Android always, on desktop when the Qt install has QtMultimedia (the bundled toolchains do). Other files go to the default application on desktop (QDesktopServices) or ACTION_VIEW on Android. fs.exportTo(path, name?) copies a file out of the app sandbox into the user's Downloads folder; on Android it opens the SAF save dialog and returns true once the copy succeeded. Both return Promise<boolean>. dialog.toast(text) shows a short Android toast; on desktop it is a no-op that resolves true.

Native dialogs live in dialog:

import { dialog } from "pulsar"

const one = await dialog.openFile({ title: "Open", filter: "Images (*.png *.jpg);;All files (*)" })
const many = await dialog.openFiles({ title: "Open several" })
const dir = await dialog.openDirectory({ title: "Choose a folder" })
const saveTo = await dialog.saveFile({ title: "Save as" })
await dialog.message({ title: "Pulsar", text: "Done!" })
await dialog.toast("Saved")

Clipboard, sharing, device

import { clipboard, device, share } from "pulsar"

await clipboard.write("copied text")
const text = await clipboard.read()

await share.text("Look at this") // share sheet on Android, clipboard on desktop
await share.url("https://github.com/riclud/pulsar")
await share.file("/path/to/photo.jpg", { mime: "image/jpeg" })

await device.setOrientation("landscape") // "auto" unlocks; Android only
await device.setKeepAwake(true) // keep the screen on while foregrounded

share.* resolves true once the content was handed off. device calls only have an effect on Android.

Notifications, screens, theme, locale

import { notify, screen, system, theme } from "pulsar"

const token = await notify.show({ title: "Pulsar", body: "Build finished" })
app.on("notification-click", (data) => {
  if (data.token === token) app.window().focus()
})

await notify.show({
  title: "Task due",
  body: "Buy milk",
  actions: [{ id: "snooze", title: "Snooze" }],
})
app.on("notification-action", (data) => {
  if (data.id === "snooze") app.store.set("snoozed", data.token)
})

const screens = await screen.list() // [{ name, x, y, width, height, workArea, scale, primary }]
const mainScreen = await screen.primary()

const { scheme } = await theme.current() // "light" | "dark"
app.on("theme-changed", (data) => console.log("now", data.scheme))

const { locale, language, timezone } = await system.locale()

const power = await system.power() // { known, source: "ac" | "battery" | "unknown", onBattery, percent? }
app.on("power-suspend", () => saveNow())
app.on("power-resume", () => refresh())
app.on("power-source", ({ onBattery }) => console.log(onBattery ? "battery" : "ac"))

Power events and state come from logind and UPower on Linux (system D-Bus), with a /sys/class/power_supply fallback for the source. On other platforms system.power() reports source: "unknown" and the events do not fire.

theme.set(options) overrides the palette, custom color tokens, density, fonts and motion at runtime:

import { catppuccinTheme, defineTheme, theme } from "pulsar"

declare module "pulsar" {
  interface ThemeColors {
    brand: string
  }
}

const brand = defineTheme({ colors: { brand: "#ff5c8a" } })

await theme.set({
  palette: { primary: "#7c5cff", surface: "#16161a", text: "#f5f5f7" },
  colors: brand.colors, // now `color: "brand"` is type-checked
  density: 0.9, // spacing and radii multiplier
  fontScale: 1.1,
  fontFamily: "Inter",
  iconFamily: "Material Symbols Rounded",
  motion: { fast: 90, normal: 160, slow: 260 },
})

await theme.set(catppuccinTheme("latte")) // "frappe" is the default

palette keys: background, surface, surfaceVariant, primary, text, textSecondary, border, layer3, layer4, radius and spacing. Colors accept named CSS colors, hex and rgb()/hsl() strings. Catppuccin presets are exported as catppuccin.frappe, catppuccin.latte and catppuccinTheme(flavor). The bundled UI font is Paper Mono; icons default to Material Symbols Rounded when installed.

Notification action buttons use the freedesktop notification daemon on Linux; on macOS and Windows the notification is shown without buttons and notification-action never fires. On Android, create a channel once with notify.channel({ id, name, importance }); text shared into the app from other apps arrives as a share event ({ text?, url?, files? }).

Storage persists JSON-safe values to storage.json in the per-app data directory. paths returns the standard per-OS directories:

OS data/config cache
Linux $XDG_DATA_HOME/<id>, $XDG_CONFIG_HOME/<id> $XDG_CACHE_HOME/<id>
macOS ~/Library/Application Support/<id> ~/Library/Caches/<id>
Windows %APPDATA%\<id> %LOCALAPPDATA%\<id>\cache

Secrets (OS keychain)

secureStorage keeps strings in the OS secret store: Keychain on macOS, DPAPI on Windows and libsecret (secret-tool) on Linux. Entries are scoped to the app id, so apps never see each other's values.

import { secureStorage } from "pulsar"

if (await secureStorage.available()) {
  await secureStorage.set("api-token", token)
  const saved = await secureStorage.get("api-token") // string | null
  await secureStorage.remove("api-token")
} else {
  // Linux without libsecret: fall back to the plain JSON store.
}

available() is false when the platform backend is missing (typically Linux without secret-tool). Values are UTF-8 strings; serialize objects to JSON first. On Windows the blob is encrypted with DPAPI for the current user; on macOS and Linux access control is enforced by the OS keychain.

Logging

console.log("hello") // -> stderr, and forwarded to QML
console.error("oops") // levels: log, info, debug, warn, error

App logs are also emitted to QML through the log signal. QML console.* messages are forwarded back to the app as a log event:

app.on("log", (params: { level: string; source: "qml" | "js"; message: string; file?: string; line?: number }) => {
  // source is "qml" for messages from the UI
})