Тур по API
API импортируется из пакета pulsar. У него две точки входа: App для самого
приложения и набор модулей для нативных capability. Одни и те же модули работают
в main.ts и в обработчиках событий UI.
import { App, fs, net, storage } from "pulsar"
Полные списки опций — в справочнике App API. Эта страница объясняет, как всё складывается вместе, и паттерны, которые предотвращают ошибки.
App
const app = new App()
app.store.set("ready", true)
app.on("window-close", () => console.log("closed"))
app.window("main").render(() => screen())
app.run()
| Член | Назначение |
|---|---|
new App(config?) |
Создать приложение; config берётся из pulsar.json. |
app.store |
Реактивный JSON-store, общий для всех окон. |
app.window(label?) |
Получить дескриптор окна (по умолчанию первое окно). |
app.createWindow(config) |
Создать окно во время выполнения. |
app.closeWindow(label?) |
Закрыть окно. |
app.on(event, handler) |
Подписаться на события хоста, UI и capability. |
app.send(event, payload, target?) |
Отправить разовое событие в окно (* — всем). |
app.windows |
Все открытые окна. |
app.manifest |
Разобранный pulsar.json. |
app.quit() |
Завершить приложение. |
app.run() |
Маркер-пустышка; циклом событий владеет хост. |
app.window(label).render(() => ...) запускает функцию при каждом изменении
store. render также может принимать статическое дерево.
Store
Store — реактивная карта, безопасная для JSON. Каждый ключ публикуется в UI как
Store.<key>.
app.store.set("count", 0)
app.store.get("count") // unknown
app.store.has("count")
app.store.delete("count")
app.store.keys()
Правила:
- Принимаются только простые объекты, массивы, строки, конечные числа, булевы
значения и
null. Функции, экземпляры классов,undefined,bigint,NaNиInfinityвызывают исключение. - Циклические ссылки отклоняются.
setс глубоко равным значением ничего не делает. Опрос одних и тех же данных не вызывает повторный render.- Изменение объекта или массива на месте и вызов
setс той же ссылкой всё равно создаёт новый JSON-снимок, но сравнение со старым значением может удивить. Создавайте новые значения явно:
const next = tasks().map((task) => (task.id === id ? { ...task, done } : task))
app.store.set("tasks", next)
Производные значения — это функции, а не дополнительные ключи:
const tasks = (): Task[] => app.store.get("tasks") as Task[]
const remaining = (): number => tasks().filter((task) => !task.done).length
Окна
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()
Если опустить ui, окно рендерится из TypeScript через render.
События
События окон и приложения:
app.on("window-close", (params) => console.log(params.label))
app.on("window-state", (params) => console.log(params.label, params.state))
app.on("window-resize", (params) => console.log(params.width, params.height))
app.on("window-move", (params) => console.log(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))
app.on("app-resume", () => refresh())
app.on("app-stop", () => {})
app.on("back-pressed", () => closeDialog())
app-pause срабатывает со значением "inactive" для диалогов и панели
уведомлений и со значением "suspended", когда приложение уходит в фон.
Сохраняйте долговечное состояние по app-pause или app-stop: Android может
убить фоновый процесс. Пока клавиатура открыта, первое нажатие «назад» только
скрывает её.
События desktop и системы:
app.on("open-file", (params) => openDocument(params.path))
app.on("open-url", (params) => routeDeepLink(params.url))
app.on("second-instance", (params) => app.window().focus())
app.on("menu", (params) => runMenuAction(params.id))
app.on("tray-menu", (params) => runTrayAction(params.event))
app.on("tray-activated", () => showWindow())
app.on("theme-changed", (params) => console.log(params.scheme))
app.on("power-suspend", () => saveNow())
app.on("power-resume", () => refresh())
app.on("power-source", (params) => console.log(params.onBattery))
app.on("notification-click", (params) => focusByToken(params.token))
app.on("notification-action", (params) => handleAction(params.token, params.id))
app.on("share", (params) => importShared(params))
app.on("log", (params) => console.log(params.level, params.message))
Из UI в бэкенд и из бэкенда в UI:
// backend -> UI
app.send("update-ready", { version: "1.2.0" })
app.window("main").emit("refresh")
// UI -> backend is the same app: call a function from an event handler
Button({ onClick: () => refresh() }, "Refresh")
Для окон QML подписывайтесь через синглтон Pulsar и фильтруйте по
PulsarWindowLabel.
Capability и разрешения
Capability — это нативные функции, стоящие за модулями. Манифест определяет, какие из них может использовать приложение:
{
"permissions": ["fs", "net", "dialog", "notify", "storage"]
}
Если permissions отсутствует, разрешено всё. Если присутствует — только
перечисленные capability.
| Разрешение | Что покрывает |
|---|---|
fs |
чтение/запись файлов, stat, rename, trash, watch |
net |
net.fetch, net.request, net.websocket, net.download |
paths |
каталоги data/config/cache приложения |
shell |
открытие URL в приложении по умолчанию |
notify |
нативные уведомления и каналы Android |
dialog |
нативные диалоги открытия/сохранения/сообщения, toast |
screen |
список мониторов |
theme |
системная цветовая схема, переопределения темы |
system |
локаль, часовой пояс, состояние питания |
storage |
JSON-файл хранилища |
secure |
связка ключей ОС / защищённое хранилище |
clipboard |
чтение/запись буфера обмена |
share |
системное меню «Поделиться» |
device |
блокировка ориентации и режим бодрствования |
permissions |
запросы системных разрешений во время выполнения |
media |
проигрыватель медиа на стороне хоста |
tray |
значок и меню в трее |
Управление окнами — это ядро, и оно никогда не ограничивается. Пользовательские capability, зарегистрированные приложением, также никогда не ограничиваются.
Разрешения ОС во время выполнения — отдельная история:
{ "osPermissions": ["camera", "microphone", "location", "notifications", "storage"] }
if (await permissions.request("camera")) startScanner()
const allowed = await permissions.check("microphone")
На Android они попадают в манифест, а request показывает системный запрос. На
desktop оба вызова возвращают true.
Файлы, пути и хранилище
import { fs, paths, secureStorage, storage } from "pulsar"
const text = await fs.readFile("notes.txt") // utf8
const bytes = await fs.readFile("cover.png", "base64")
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", true)
await fs.moveToTrash("b.txt")
const stop = fs.watch("notes.txt", (path) => console.log("changed", path))
stop()
const dirs = await paths.get() // data, config, cache, temp, home
Используйте paths.get().data для долговечных файлов приложения, .config —
для настроек, .cache — для временных данных и .temp — для рабочих файлов.
await storage.set("settings", { theme: "dark" })
const settings = await storage.get("settings")
await secureStorage.set("api-token", token)
const saved = await secureStorage.get("api-token")
storage — это JSON-файл в каталоге данных приложения. secureStorage хранит
строки в связке ключей ОС (Keychain, DPAPI или libsecret) и привязан к id
приложения. Используйте его для токенов и паролей, а не для больших данных.
fs.open(path) передаёт файл ОС. Видеофайлы открываются во встроенном
проигрывателе, когда доступен QtMultimedia. fs.exportTo(path, name?) копирует
файл в Downloads; на Android открывает диалог сохранения SAF.
Сеть
import { net, NetError } from "pulsar"
try {
const response = await net.fetch("https://api.example.com/items", {
method: "GET",
headers: { Accept: "application/json" },
timeoutMs: 15000,
})
const items = JSON.parse(response.text)
} catch (error) {
if (error instanceof NetError) {
console.error(error.code, error.message)
}
}
net.fetch выполняется в нативном хосте, а не в движке JS. Опции: method,
headers, body (строка или Uint8Array), timeoutMs, redirect
(follow/manual/error), maxRedirects, responseType
(text/binary/auto) и tlsFingerprint.
Стриминг:
const request = net.request("https://example.com/big.bin")
request.on("response", (info) => console.log(info.status))
request.on("data", (chunk) => {
const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk
total += bytes.byteLength
})
request.on("end", () => console.log("done", total))
request.on("error", (error) => console.error(error.code))
request.abort()
Скачивание на диск с прогрессом и возобновлением:
const file = await net.download("https://example.com/big.zip", "big.zip", {
resume: true,
onProgress: (received, total) => console.log(received, total),
})
console.log(file.path, file.bytes, file.resumed)
WebSocket:
const socket = net.websocket("wss://example.com/ws", { protocols: ["chat"] })
socket.on("open", (info) => console.log(info.protocol))
socket.on("message", (data) => console.log(data))
socket.on("close", (info) => console.log(info.code, info.reason))
socket.on("error", (error) => console.error(error.code))
socket.send("hello")
socket.send(new Uint8Array([1, 2, 3]))
socket.close(1000, "bye")
net.websocket требует модуль Qt WebSockets; текущий пакет Qt для Android его не
включает, поэтому вызовы там возвращают ERR_UNSUPPORTED.
Диалоги, уведомления и трей
import { dialog, notify, tray } from "pulsar"
const file = await dialog.openFile({ title: "Open", filter: "Text (*.txt);;All files (*)" })
const many = await dialog.openFiles()
const dir = await dialog.openDirectory()
const saveTo = await dialog.saveFile()
await dialog.message({ title: "Pulsar", text: "Done" })
await dialog.toast("Saved")
const token = await notify.show({ title: "Build", body: "Finished" })
await notify.show({
title: "Task due",
body: "Buy milk",
actions: [{ id: "snooze", title: "Snooze" }],
})
app.on("notification-click", (params) => focusByToken(params.token))
app.on("notification-action", (params) => runAction(params.token, params.id))
await notify.channel({ id: "reminders", name: "Reminders", importance: 3 })
Действия уведомлений используют демон freedesktop в Linux. На macOS и Windows
уведомление показывается без кнопок. На Android создайте канал один раз, а
события нажатия приходят как notification-click.
await tray.setIcon("assets/icon.png")
await tray.setTooltip("My App")
await tray.setMenu([{ label: "Show", event: "show" }, { separator: true }, { label: "Quit", event: "quit" }])
События трея приходят как tray-menu ({ event }) и tray-activated
({ reason }).
Устройства, медиа и система
import { clipboard, device, media, screen, share, system, theme } from "pulsar"
await clipboard.write("copied")
const text = await clipboard.read()
await share.text("Look at this")
await share.url("https://pulsarjs.org")
await share.file("photo.jpg", { mime: "image/jpeg" })
await device.setOrientation("landscape")
await device.setKeepAwake(true)
const screens = await screen.list()
const primary = await screen.primary()
const { locale, language, timezone } = await system.locale()
const power = await system.power()
const { scheme } = await theme.current()
Проигрыватель медиа:
media.load([
{ source: "first.mp3", title: "First", artist: "Demo" },
{ source: "second.mp3", title: "Second" },
])
media.play()
media.seek(42)
media.setVolume(0.8)
media.next()
const unsubscribe = media.on((snapshot) => {
app.store.set("player", snapshot)
})
У снимка есть state (idle, loading, playing, paused, stopped,
ended, error), position, duration, rate, volume, index, source,
title, artist и playing. На Android проигрыватель запускает foreground
service с MediaSession и управлением с экрана блокировки.
Меню и недавние документы
app.setMenu([
{
label: "File",
items: [
{ id: "new", label: "New", shortcut: "CmdOrCtrl+N" },
{ separator: true },
{ id: "hide", label: "Hide done", checked: app.store.get("hideDone") === true },
],
},
])
app.on("menu", (params) => runMenuAction(params.id))
app.addRecentDocument("/home/me/notes.md")
app.clearRecentDocuments()
Нативное меню пока поддерживается только на macOS; Linux и Windows игнорируют
setMenu. Недавние документы интегрируются со списками недавних документов ОС.
Пользовательские capability
Зарегистрируйте capability, чтобы вызывать свой код через тот же механизм разрешений и событий:
import { callCapability, registerCapability } from "pulsar"
registerCapability("notes.count", async () => notes().length)
const count = (await callCapability("notes.count")) as number
Пользовательские capability не ограничиваются манифестом. Используйте их, чтобы дать окнам QML стабильную поверхность или изолировать платформозависимый код за одной функцией.
Асинхронность, ошибки и логирование
- Каждая capability возвращает promise; ожидайте его или добавляйте
.catch. - Оборачивайте IO в
try/catchи кладите ошибку в store, когда UI должен её показать. NetErrorнесёт стабильныйcode; ветвитесь по коду, а не по сообщению.- Неперехваченная ошибка в dev-сервере показывает оверлей и сопоставляет кадры стека с исходниками TypeScript. В production обрабатывайте её сами.
console.log/info/debug/warn/errorпишет в stderr и пересылается в QML. Логи UI приходят как событиеlog.
app.store.set("saving", true)
try {
await storage.set("draft", draft())
app.store.set("error", "")
} catch (error) {
app.store.set("error", error instanceof Error ? error.message : String(error))
} finally {
app.store.set("saving", false)
}
Замечания по Android
- «Назад» сначала скрывает клавиатуру, затем доходит до
back-pressed; без обработчика приложение закрывается. - Сохраняйте состояние по
app-pauseилиapp-stop; процесс может быть убит в фоне. net,fsи медиа выполняются в нативном хосте, а не в UI-потоке.net.websocketнедоступен в текущем пакете Qt для Android.- Используйте
permissions.requestдля камеры, микрофона, геолокации, уведомлений и хранилища; объявляйте их вosPermissions. - Средства выбора файлов возвращают копии в песочнице; используйте
fs.exportTo, чтобы отправить файл наружу. - Dev-сервер поддерживает
pulsar dev --android,pulsar ui,pulsar state,pulsar tap,pulsar type,pulsar expect,pulsar shotиpulsar eval.
Производительность и безопасность
- Предпочитайте
VirtualListрендерингу тысяч строк. - Добавляйте debounce для ввода в поиске и сетевых вызовов.
- Держите store небольшим; большие массивы сериализуются и сравниваются.
- Не опрашивайте чаще, чем нужно UI; no-op при глубоком равенстве помогают, но не делают опрос бесплатным.
- Храните секреты в
secureStorage, никогда вstorageили исходном коде. - Запрашивайте минимум разрешений манифеста и разрешений ОС.
- Считайте файлы и URL, пришедшие от ОС, недоверенным вводом.
- Разрешайте пути внутри
paths.get()вместо записи в произвольные места.
Полный пример
Экран заметок, который загружает данные из хранилища, сохраняет черновик, ищет и удаляет:
import {
App,
bind,
Button,
Card,
Column,
EmptyState,
For,
Header,
IconButton,
Input,
Row,
SearchField,
Spacer,
Text,
storage,
} from "pulsar"
interface Note {
id: string
title: string
body: string
}
const app = new App()
app.store.set("notes", [] as Note[])
app.store.set("query", "")
app.store.set("title", "")
const notes = (): Note[] => app.store.get("notes") as Note[]
const visible = (): Note[] => {
const query = String(app.store.get("query")).toLowerCase()
if (!query) return notes()
return notes().filter((note) => `${note.title} ${note.body}`.toLowerCase().includes(query))
}
const saveAll = async (next: Note[]): Promise<void> => {
app.store.set("notes", next)
await storage.set("notes", next)
}
const load = async (): Promise<void> => {
const saved = await storage.get("notes")
app.store.set("notes", Array.isArray(saved) ? (saved as Note[]) : [])
}
const add = async (): Promise<void> => {
const title = String(app.store.get("title")).trim()
if (!title) return
await saveAll([{ id: String(Date.now()), title, body: "" }, ...notes()])
app.store.set("title", "")
}
app.on("app-resume", () => void load())
app.window("main").render(() =>
Column(
{ fill: true, padding: 16, gap: 12 },
Header({ title: "Notes" }),
SearchField({
placeholder: "Search",
value: bind(() => String(app.store.get("query"))),
onInput: (query) => app.store.set("query", query),
}),
Row(
{ gap: 8, fillWidth: true },
Input({
placeholder: "Note title",
value: bind(() => String(app.store.get("title"))),
onInput: (title) => app.store.set("title", title),
onSubmitted: () => void add(),
expand: true,
}),
Button({ icon: "add", onClick: () => void add() }, "Add"),
),
EmptyState({ visible: visible().length === 0, icon: "description", title: "No notes" }),
For({
each: visible(),
keyOf: (note) => note.id,
spacing: 8,
render: (note) =>
Card(
{ padding: 12 },
Row(
{ gap: 8, fillWidth: true },
Column(
{ gap: 2, expand: true },
Text({ weight: "medium" }, note.title),
Text({ size: 12, color: "gray" }, note.body),
),
Spacer({ fillWidth: true }),
IconButton({
icon: "delete",
a11yLabel: `Delete ${note.title}`,
onClick: () => void saveAll(notes().filter((item) => item.id !== note.id)),
}),
),
),
}),
),
)
void load()
app.run()
Указатель API
| Область | С чего начать |
|---|---|
| App | new App, app.store, app.on, app.window, app.send |
| Окна | WindowHandle, createWindow, setFullscreen |
| Файлы | fs, paths, fs.watch, fs.open, fs.exportTo |
| Хранилище | storage, secureStorage |
| Сеть | net.fetch, net.request, net.websocket, net.download |
| Диалоги | dialog.openFile, dialog.saveFile, dialog.message |
| Уведомления | notify.show, notify.channel |
| Трей и меню | tray, app.setMenu |
| Устройства | clipboard, share, device, permissions |
| Медиа | media.load, media.play, media.on |
| Система | screen, system.locale, system.power, theme |
| Пользовательское | registerCapability, callCapability |