App API (бэкенд / 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
| Член | Описание |
|---|---|
new App(config?) |
Создать приложение. Конфиг берётся из pulsar.json; config переопределяет. |
app.store |
Реактивное состояние, общее для всех окон (см. Store). |
app.on(event, fn) |
Подписаться на событие из QML (Pulsar.emit), хоста или app.send. |
app.window(label?) |
Получить WindowHandle (по умолчанию — первое окно). |
app.createWindow(config) |
Создать окно в рантайме; возвращает WindowHandle. |
app.closeWindow(label?) |
Закрыть окно (по умолчанию — первое). |
app.send(event, payload?, target?) |
Отправить событие в UI (target — метка или *). |
app.windows |
Все открытые окна (стартовые плюс созданные). |
app.config |
Конфиг первого окна. |
app.manifest |
Разобранный pulsar.json (или null). |
app.quit() |
Завершить приложение. |
app.run() |
Заглушка-маркер; цикл событий принадлежит хосту. |
Store
JSON-безопасная реактивная карта. Любое изменение отправляется в QML как
Store.<key>.
app.store.set("count", 0)
app.store.get("count") // unknown -> приведение типа
app.store.has("count")
app.store.delete("count")
app.store.keys()
app.store.set("items", [{ id: 1 }, { id: 2 }])
Разрешены только JSON-сериализуемые значения (объекты, массивы, строки,
конечные числа, булевы, null). Всё остальное бросает понятную ошибку;
вместо установки undefined используйте delete. Циклические ссылки
отклоняются.
Если новое значение глубоко равно текущему, set ничего не делает: слушатели
не вызываются, в UI ничего не отправляется и перерисовки нет. Поэтому цикл
синхронизации по таймеру не перестраивает список, когда данных не изменились.
Ключи, добавленные после открытия окна, доставляются как обычно; привязывайте
их напрямую (Store.<key>), чтобы QML отслеживал их.
Окна
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() // закрыть и освободить окно
Каждый метод возвращает Promise<boolean>. Окна из pulsar.json — лишь
стартовые; остальные можно создавать в рантайме:
const settings = app.createWindow({ label: "settings", ui: "ui/settings.qml", width: 420, height: 320 })
await settings.show()
События жизненного цикла
Хост сообщает приложению об активности окон и трея:
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())
На Android приложение также получает события переднего плана и кнопки «назад»:
app.on("app-pause", (params) => saveDraft(params.state)) // "inactive" | "suspended"
app.on("app-resume", () => refresh())
app.on("app-stop", () => {}) // ушли в фон; Android может убить процесс
app.on("back-pressed", () => closeDialog()) // без слушателя back закрывает приложение
app-pause со state: "inactive" приходит и на диалоги и шторку уведомлений,
поэтому сохраняйте состояние там (или на app-stop): Android может убить
процесс в фоне без дальнейших колбэков. Пока открыта экранная клавиатура,
первый back только прячет её. На desktop эти события не приходят.
Экраны и роутер
createRouter(app) хранит стек экранов для приложений, которые рендерятся из
TypeScript. push/pop публикуют текущий маршрут в Store (по умолчанию под
ключом route), поэтому UI перерисовывается, а системная кнопка «назад»
снимает экран со стека:
import { createRouter } from "pulsar"
const router = createRouter(app)
router.push("settings", { tab: "general" })
router.replace("settings")
router.pop() // false в корне
app.window("main").render(() =>
router.view({
home: () => HomeScreen(),
settings: (params) => SettingsScreen({ tab: params.tab as string }),
}),
)
Методы Router: current(), entries(), canGoBack(), depth(),
push(name, params?), replace(name, params?), pop(),
reset(name?, params?), subscribe(fn) и view(screens). Back снимает
экран, а на пустом стеке закрывает приложение; handleBack: false отключает
это поведение. storeKey переименовывает ключ в Store, initial задаёт
первый маршрут (по умолчанию "home").
Аргументы запуска и deep links
Файлы и URL, которые ОС передаёт приложению (аргументы командной строки или macOS-событие открытия файла), приходят событиями:
app.on("open-file", (params) => console.log("open", params.path))
app.on("open-url", (params) => console.log("deep link", params.url))
С singleInstance: true в pulsar.json работает только один экземпляр: второй
запуск передаёт свои аргументы работающему и выходит. Работающее приложение
получает событие second-instance, чтобы поднять окно:
app.on("second-instance", (params) => {
app.window().focus()
console.log("relaunched with", params.argv)
})
Недавние документы
Сообщите ОС, какие файлы открыл пользователь: они появятся в системных
списках недавних (Jump List в Windows, список приложения в macOS,
recently-used.xbel в Linux):
app.addRecentDocument("/home/me/notes.md")
app.clearRecentDocuments()
Пути приводятся к абсолютным. Очистка опустошает список приложения (в Linux
сбрасывается общий recently-used.xbel).
Ассоциации файлов
Объявите типы файлов, которые открывает приложение, в pulsar.json:
"fileAssociations": [
{ "ext": "md", "mime": "text/markdown", "name": "Markdown document" }
]
Хост регистрирует их при запуске идемпотентно: в Linux пишет desktop-файл в
$XDG_DATA_HOME/applications и значение по умолчанию в
$XDG_CONFIG_HOME/mimeapps.list (поэтому там обязателен mime); в Windows
добавляет приложение в OpenWithProgids под HKCU\Software\Classes. В macOS
типы файлов принадлежат app bundle, поэтому single-file сборки это поле
игнорируют — для deep links используйте protocols.
События из бэкенда в UI
app.send(event, payload, target?) отправляет разовое событие в UI (target —
метка окна или * для всех). app.window(label).emit(...) — в одно окно.
В QML подписка идёт через singleton Pulsar, а фильтр по окну — через
контекстное свойство PulsarWindowLabel:
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
}
}
Диалоги
Нативные, реализованы Qt-хостом.
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!" })
Трей
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-menu ({ event }) при клике по пункту и tray-activated
({ reason }) при активации иконки.
Медиаплеер
media управляет одним плеером в нативном хосте с плейлистом на JS. На Android
он работает как foreground service с MediaSession и управлением с экрана
блокировки.
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) // секунды
media.setRate(1.25)
media.setVolume(0.8)
media.next()
В снимке есть state (idle, loading, playing, paused, stopped,
ended или error), position, duration, rate, volume, index,
source, title, artist и playing. Другие методы: pause(), toggle(),
stop(), previous(), setMetadata(title, artist?), state() и
playlist().
Файловая система, пути, сеть, shell, хранилище
import { fs, paths, net, shell, storage } from "pulsar"
const text = await fs.readFile("notes.txt") // utf8 по умолчанию
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") // рекурсивно: fs.remove(path, true)
await fs.moveToTrash("b.txt")
const stop = fs.watch("notes.txt", (path) => console.log("changed", path))
stop() // отписка; счётчик наблюдателей ведётся по пути
await fs.open("report.pdf") // открыть приложением по умолчанию
await fs.exportTo("report.pdf", "report.pdf") // скопировать в Downloads
const bytes = await fs.readFile("logo.png", "base64") // бинарные данные как 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) выполняется в нативном хосте, а не в JS-движке,
поэтому одинаково работает под Bun, Node, Hermes и QuickJS. Опции: method,
headers, body (строка или Uint8Array), timeoutMs, redirect (follow /
manual / error), maxRedirects, responseType (text / binary / auto)
и tlsFingerprint. tlsFingerprint: true (или профиль вроде "chrome120")
зарезервирован под бэкенд с маскировкой; сборки без него отвечают
ERR_FINGERPRINT_UNAVAILABLE.
Для стриминга используйте net.request(url, options): события response,
data (строка или Uint8Array), end, error и метод 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) открывает соединение ws:// или wss:// в
нативном хосте. Опции: headers, protocols (сабпротоколы) и timeoutMs
(таймаут подключения, по умолчанию 30 с). Сообщения — строки или Uint8Array:
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) пишет ответ сразу на диск: headers,
timeoutMs, resume (продолжить частично скачанный файл Range-запросом, если
сервер разрешает) и onProgress(received, total). Возвращает { 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) сообщает об изменениях файла или каталога и
возвращает функцию отписки; наблюдатели считаются по пути.
fs.open(path) отдаёт файл ОС. Видео открывается во встроенном полноэкранном
плеере: на Android всегда, на десктопе — если в установленном Qt есть
QtMultimedia (в бандлах toolchain он есть). Остальные файлы уходят приложению
по умолчанию на десктопе (QDesktopServices) или через ACTION_VIEW на
Android. fs.exportTo(path, name?) копирует файл из
песочницы приложения в папку Downloads; на Android открывается диалог
сохранения SAF и возвращается true после успешного копирования. Оба метода
возвращают Promise<boolean>. dialog.toast(text) показывает короткий тост
на Android; на десктопе это no-op, который резолвится в true.
Нативные диалоги — в dialog:
import { dialog } from "pulsar"
const one = await dialog.openFile({ title: "Открыть", filter: "Images (*.png *.jpg);;All files (*)" })
const many = await dialog.openFiles({ title: "Выбрать несколько" })
const dir = await dialog.openDirectory({ title: "Выбрать папку" })
const saveTo = await dialog.saveFile({ title: "Сохранить как" })
await dialog.message({ title: "Pulsar", text: "Готово!" })
await dialog.toast("Сохранено")
Буфер обмена, шеринг, устройство
import { clipboard, device, share } from "pulsar"
await clipboard.write("скопированный текст")
const text = await clipboard.read()
await share.text("Посмотри") // share sheet на Android, буфер обмена на десктопе
await share.url("https://github.com/riclud/pulsar")
await share.file("/path/to/photo.jpg", { mime: "image/jpeg" })
await device.setOrientation("landscape") // "auto" снимает блокировку; только Android
await device.setKeepAwake(true) // не гасить экран, пока приложение на переднем плане
share.* резолвится в true, когда контент передан ОС. Вызовы device имеют
эффект только на Android.
Уведомления, экраны, тема, локаль
import { notify, screen, system, theme } from "pulsar"
const token = await notify.show({ title: "Pulsar", body: "Сборка завершена" })
app.on("notification-click", (data) => {
if (data.token === token) app.window().focus()
})
await notify.show({
title: "Задача",
body: "Купить молоко",
actions: [{ id: "snooze", title: "Отложить" }],
})
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("теперь", 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"))
Состояние и события питания берутся из logind и UPower на Linux (системный
D-Bus), с запасным чтением /sys/class/power_supply. На других платформах
system.power() возвращает source: "unknown", а события не приходят.
theme.set(options) переопределяет палитру, свои цветовые токены, плотность,
шрифты и анимации в рантайме:
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, // теперь `color: "brand"` проверяется типами
density: 0.9, // множитель отступов и радиусов
fontScale: 1.1,
fontFamily: "Inter",
iconFamily: "Material Symbols Rounded",
motion: { fast: 90, normal: 160, slow: 260 },
})
await theme.set(catppuccinTheme("latte")) // по умолчанию "frappe"
Ключи palette: background, surface, surfaceVariant, primary, text,
textSecondary, border, layer3, layer4, radius и spacing. Цвета
принимают именованные CSS-цвета, hex и строки rgb()/hsl(). Пресеты
Catppuccin экспортируются как catppuccin.frappe, catppuccin.latte и
catppuccinTheme(flavor). Встроенный UI-шрифт — Paper Mono; иконки по
умолчанию используют Material Symbols Rounded, если шрифт установлен.
Кнопки в уведомлениях работают через freedesktop-демон уведомлений на Linux;
на macOS и Windows уведомление показывается без кнопок, и
notification-action не приходит. На Android канал создаётся один раз через
notify.channel({ id, name, importance }); текст, которым с приложением
поделились другие приложения, приходит событием share ({ text?, url?, files? }).
Хранилище сохраняет JSON-безопасные значения в storage.json в каталоге
данных приложения. paths возвращает стандартные каталоги по ОС:
| ОС | 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 |
Секреты (системный keychain)
secureStorage хранит строки в системном хранилище секретов: Keychain в
macOS, DPAPI в Windows и libsecret (secret-tool) в Linux. Записи привязаны к
id приложения, поэтому приложения не видят чужие значения.
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 без libsecret: используйте обычное JSON-хранилище.
}
available() возвращает false, если бэкенда нет (обычно Linux без
secret-tool). Значения — строки UTF-8; объекты сначала сериализуйте в JSON.
В Windows данные шифруются DPAPI для текущего пользователя; в macOS и Linux
доступ контролирует системный keychain.
Логирование
console.log("hello") // -> stderr, а также пробрасывается в QML
console.error("oops") // уровни: log, info, debug, warn, error
Логи приложения также отправляются в QML через сигнал log. Сообщения
console.* из QML пробрасываются обратно в приложение как событие log:
app.on("log", (params: { level: string; source: "qml" | "js"; message: string; file?: string; line?: number }) => {
// source == "qml" для сообщений из интерфейса
})