pulsar
RU

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").

Файлы и 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" для сообщений из интерфейса
})