Extension
Type 1: Extension (type: "extension")
Asyar has a single Tier 2 extension type. The pre-split distinction between
"view", "result", and "logic" is gone. Every extension declares
type: "extension" (or omits it — "extension" is the default), and each
command independently picks mode: "view" (opens a panel) or
mode: "background" (runs headless in the worker iframe). One extension can
mix both freely.
Use this type when: you want to surface anything other than a pure CSS
theme — a search engine over your data, a UI panel, a headless background
worker, a tray icon, a scheduled tick, or any combination thereof. The only
other type is theme.
How it works
When the extension is enabled, Asyar materialises two iframes for it:
- A worker iframe — hidden, always-on while the extension is enabled,
loads
worker.html. Hosts long-lived work: push subscriptions, scheduled ticks, registered timers, tray-icon state writes, notification-action callbacks, and worker-side RPC handlers. - A view iframe — on-demand, mounted when the user opens a
mode: "view"command, evicted ~120 s after the last user interaction. Loadsview.html. Hosts UI rendering only.
Both iframes are sandboxed at asyar-extension://<id>/ (macOS/Linux) or
http://asyar-extension.localhost/<id>/ (Windows). They communicate with
each other through a launcher-brokered state mailbox and an RPC primitive.
For the lifecycle state machine, mailbox semantics, and the view↔worker RPC protocol, see the extension runtime explanation.
Manifest template
A pomodoro-style extension that exercises both contexts:
{
"id": "com.yourname.pomodoro",
"name": "Pomodoro",
"version": "2.1.0",
"description": "Focus timer with notifications.",
"author": "Your Name",
"icon": "🍅",
"type": "extension",
"asyarSdk": "^2.1.0",
"background": { "main": "dist/worker.js" },
"permissions": ["notifications:send", "timers:schedule", "timers:cancel"],
"commands": [
{ "id": "open", "name": "Open Pomodoro", "mode": "view", "component": "PomodoroView", "icon": "🍅" },
{ "id": "start", "name": "Start Focus", "mode": "background", "icon": "▶️" },
{ "id": "stop", "name": "Stop Focus", "mode": "background" },
{ "id": "tick", "name": "Tick", "mode": "background", "schedule": { "intervalSeconds": 60 } }
]
}
Per-command rules:
mode: "view"requires a non-emptycomponentstring. The value is the Svelte component yourview.tsexports under that name.mode: "background"forbidscomponent. The host dispatches the command to the worker iframe via the SDK'scommands.onCommand(id, handler)hook.- At least one
mode: "background"command (or a non-emptysearchablecontribution) requiresbackground.main— the path to your compiled worker bundle relative to the package root.
For the full schema, see manifest.md.
Worker entry — src/main.worker.ts
The worker bundle runs as a hidden iframe. It must import from
asyar-sdk/worker, never from asyar-sdk/view — the /worker entry asserts
window.__ASYAR_ROLE__ === "worker" at load time and exposes the
worker-safe proxy bag (no DOM-dependent helpers).
import { ExtensionContext, extensionBridge } from 'asyar-sdk/worker';
const extensionId = window.location.hostname || 'com.yourname.pomodoro';
const context = new ExtensionContext();
context.setExtensionId(extensionId);
// Dispatcher entry — fired for every `mode: "background"` command.
context.commands.onCommand('start', async () => {
await context.proxies.timers!.schedule({ id: 'pomodoro:end', delaySeconds: 25 * 60 });
await context.state.set('phase', 'focus');
});
context.commands.onCommand('stop', async () => {
await context.proxies.timers!.cancel('pomodoro:end');
await context.state.set('phase', 'idle');
});
context.commands.onCommand('tick', async () => {
// Scheduled every 60 s — update the tray label, push state to the view, etc.
});
// view → worker RPC handler. The view's `context.request('getStats', ...)`
// arrives here.
context.onRequest<{}, { rounds: number }>('getStats', async () => {
return { rounds: (await context.state.get<number>('rounds')) ?? 0 };
});
window.parent.postMessage({ type: 'asyar:extension:loaded', extensionId, role: 'worker' }, '*');
View entry — src/main.view.ts
The view bundle imports from asyar-sdk/view, which asserts
window.__ASYAR_ROLE__ === "view" and exposes the full proxy bag plus DOM
helpers (icon custom element, theme injector).
import { ExtensionContext, extensionBridge, registerIconElement } from 'asyar-sdk/view';
import PomodoroView from './PomodoroView.svelte';
import { mount } from 'svelte';
registerIconElement();
const extensionId = window.location.hostname || 'com.yourname.pomodoro';
const context = new ExtensionContext();
context.setExtensionId(extensionId);
extensionBridge.registerManifest(manifest);
extensionBridge.registerExtensionImplementation(extensionId, { /* … */ });
mount(PomodoroView, {
target: document.getElementById('app')!,
props: { context },
});
window.parent.postMessage({ type: 'asyar:extension:loaded', extensionId, role: 'view' }, '*');
Inside the view component, call worker handlers with context.request:
<script lang="ts">
import type { ExtensionContext } from 'asyar-sdk/view';
let { context }: { context: ExtensionContext } = $props();
let stats = $state<{ rounds: number } | null>(null);
$effect(async () => {
stats = await context.request<{}, { rounds: number }>('getStats', {});
});
</script>
Searchable extensions
searchable: true at the root makes the launcher forward global search
queries to your extension. With the worker/view split, the recommended home
for search() is the worker — it stays mounted while the user types, so
results are instant. Register the search handler in main.worker.ts:
import { ExtensionContext, extensionBridge } from 'asyar-sdk/worker';
extensionBridge.registerExtensionImplementation(extensionId, {
async search(query: string) {
// your search logic
return [/* ExtensionResult[] */];
},
// … other Extension methods
});
Selecting a result with viewPath set navigates the launcher to that view —
the host mounts the view iframe on demand. The action closure is still
ignored across the iframe boundary; control navigation via viewPath.
When NOT to need a worker
If every command is mode: "view" and you have no push subscriptions, no
scheduled ticks, no tray items, and no notification-action callbacks, you
can omit background.main entirely. The extension will load with a view
iframe only. The searchable flag also requires a worker — searchable
extensions must declare background.main.
See also
- Manifest reference — full schema with validation rules.
- Extension runtime — state machine, mailbox, RPC, state broker.
- IPC bridge — postMessage routing, permission gate, push events.
- In-view search — handling the global search bar while a view is open.
- Theme extensions — the only other top-level type.