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").
Launch arguments & deep links
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
})