Application Service
8.18 ApplicationService — Frontmost metadata + app-presence push events
Runs in: both worker and view. Push subscriptions
(onApplicationLaunched, onApplicationTerminated,
onFrontmostApplicationChanged, onApplicationsChanged) must register
from the worker — the view is Dormant-eligible and would silently miss
events while the panel is closed.
Permissions required:
application:read— for the query surface (getFrontmostApplication,listApplications,syncApplicationIndex,isRunning) and theonApplicationsChangedindex-watch subscription (same data class — index events carry the same informationlistApplicationsreturns)app:frontmost-watch— for the three app-presenceon*push subscriptions (onApplicationLaunched,onApplicationTerminated,onFrontmostApplicationChanged)
ApplicationService combines three related surfaces:
- Query surface (
application:*IPC namespace) — one-shot request/response for frontmost metadata, the installed-app index, and a synchronousisRunningpresence check. - Presence push surface (
appEvents:*IPC namespace) — subscribe to launch / terminate / frontmost-changed events that Raycast's built-in Application API does not expose at all. Subscriptions are ref-counted on the SDK side: multiple listeners for the same kind share a single backend subscription; the last disposer fires anappEvents:unsubscribe. - Index push surface (
applicationIndex:*IPC namespace) — subscribe to filesystem-driven changes to the installed-application index. Fires when an app is installed or removed from a watched default scan directory (e.g./Applications) or when the user editssettings.search.additionalScanPaths. Same ref-counted shape as the presence surface.
interface IApplicationService {
// ── query surface (permission: application:read) ──
getFrontmostApplication(): Promise<FrontmostApplication>;
syncApplicationIndex(extraPaths?: string[]): Promise<{ added: number; removed: number; total: number }>;
listApplications(extraPaths?: string[]): Promise<InstalledApplication[]>;
isRunning(bundleId: string): Promise<boolean>;
// ── presence push surface (permission: app:frontmost-watch) ──
onApplicationLaunched(cb: (e: Extract<AppPresenceEvent, {type: 'launched'}>) => void): Disposer;
onApplicationTerminated(cb: (e: Extract<AppPresenceEvent, {type: 'terminated'}>) => void): Disposer;
onFrontmostApplicationChanged(cb: (e: Extract<AppPresenceEvent, {type: 'frontmost-changed'}>) => void): Disposer;
// ── index push surface (permission: application:read) ──
onApplicationsChanged(cb: (e: ApplicationIndexEvent) => void): Disposer;
}
type AppPresenceEvent =
| { type: 'launched'; pid: number; bundleId?: string; name: string; path?: string }
| { type: 'terminated'; pid: number; bundleId?: string; name: string }
| { type: 'frontmost-changed'; pid: number; bundleId?: string; name: string };
type ApplicationIndexEvent = {
type: 'applications-changed';
added: number; // apps newly added since last scan
removed: number; // apps removed since last scan
total: number; // current absolute count
};
type Disposer = () => void;
interface FrontmostApplication {
name: string;
bundleId?: string;
path?: string;
windowTitle?: string;
}
Capability note — what Raycast doesn't have
Raycast's getFrontmostApplication is strictly pull-based; there is no way to subscribe to launch, terminate, or frontmost-changed events without polling. Asyar exposes those three events through ApplicationService.on*. Extensions that want to react when the user switches apps (rather than asking Asyar every second) should use the push surface; those that just need the current state can keep using getFrontmostApplication() or isRunning().
Usage — query surface
const app = context.services.application;
const frontmost = await app.getFrontmostApplication();
console.log(frontmost.name, frontmost.bundleId, frontmost.windowTitle);
if (await app.isRunning('com.apple.Safari')) {
console.log('Safari is running');
}
Usage — push surface
const app = context.services.application;
const dispose = app.onFrontmostApplicationChanged((e) => {
console.log(`Now frontmost: ${e.name} (${e.bundleId ?? 'no bundle id'})`);
});
// Later, when the extension unloads:
dispose();
Disposers are idempotent — calling twice is a safe no-op. Subscriptions are also automatically released by the host when the extension uninstalls.
Usage — index push surface
const app = context.services.application;
// React every time an app is installed, uninstalled, or the user edits
// a directory in settings.search.additionalScanPaths. The host debounces
// filesystem events (default 500ms) and suppresses no-op rescans, so
// every callback invocation represents a real change.
const dispose = app.onApplicationsChanged((e) => {
console.log(`index changed: +${e.added} / -${e.removed} (total ${e.total})`);
// Typical reaction: refresh any UI that depends on the installed-app list,
// e.g. call `app.listApplications()` again to get the fresh set.
});
// Later, when the extension unloads:
dispose();
When to use onApplicationsChanged vs. periodic listApplications
Use the subscription when your extension's UI mirrors the installed-app set and needs to stay accurate without user action — for example, a launcher list, an "installed apps" picker, or an integration that indexes new apps. Keep a one-shot listApplications() call if you only need the list once at extension startup and a stale view is acceptable.
The event is driven by a notify filesystem watcher that arms on /Applications, /System/Applications, and each path in settings.search.additionalScanPaths. Paths added to settings arm the watcher within a few hundred milliseconds; paths removed from settings are unwatched immediately. The watcher is not a general-purpose filesystem subscription — it only tracks changes that affect the application index.
Platform coverage matrix
| Event | macOS | Windows | Linux (X11) | Linux (Wayland) |
|---|---|---|---|---|
launched |
NSWorkspaceDidLaunchApplicationNotification |
WMI __InstanceCreationEvent on Win32_Process |
/proc 1s poll + DBus NameOwnerChanged (GUI heuristic) |
same as X11 |
terminated |
NSWorkspaceDidTerminateApplicationNotification |
WMI __InstanceDeletionEvent on Win32_Process |
/proc 1s poll + DBus NameOwnerChanged |
same as X11 |
frontmost-changed |
NSWorkspaceDidActivateApplicationNotification |
SetWinEventHook(EVENT_SYSTEM_FOREGROUND) on a message-pump thread |
_NET_ACTIVE_WINDOW property changes via x11rb |
not emitted — no Wayland equivalent; one warning logged at startup |
Linux limitations
The procfs poller sees every process transition, not just GUI apps — the Linux launched/terminated stream is therefore noisier than macOS/Windows. The DBus NameOwnerChanged path augments it with explicit GUI-app registrations (e.g. com.spotify.Client, com.slack.Slack) filtered by the dbus_name_looks_like_gui_app heuristic.
On Wayland there is no portable equivalent of _NET_ACTIVE_WINDOW, so onFrontmostApplicationChanged never fires. The watcher logs a single warning at startup and continues — the other two events still work.
isRunning semantics per platform
| Platform | Interpretation of bundleId argument |
|---|---|
| macOS | Real bundle identifier; matched via NSWorkspace.runningApplications |
| Windows | Process name (with or without .exe); scanned via CreateToolhelp32Snapshot |
| Linux | /proc/<pid>/status Name or a DBus well-known name (falls back to NameHasOwner) |
Platform notes — query surface
macOS Accessibility permissions
Retrieving the window title via getFrontmostApplication on macOS requires Accessibility Permissions. If Asyar lacks them, windowTitle is returned as an empty string. On the first call Asyar checks and will guide the user to System Settings > Privacy & Security > Accessibility if needed.
Windows
On Windows, bundleId in FrontmostApplication returns the executable name (e.g. chrome.exe). The name field returns the localized description from file version info where available, otherwise the file name.
Uninstall actions — Tier 1 only
The launcher action panel exposes a built-in Uninstall Application action. It is intentionally not surfaced on IApplicationService:
- The
application:*namespace today is read-only (frontmost metadata, installed-app listing, presence queries). Adding a destructive file-system write to it would be a capability jump that every extension using the namespace would inherit. - The UX belongs in the launcher shell. Confirmation, trash/uninstaller feedback, and the action-panel gating are all shell responsibilities.
- The backing Tauri command rejects any Tier 2 caller with
AppError::Permission. Evenasyar:api:invokepass-through can't reach it.
Extensions that need to react to uninstalls — for example to invalidate cached bundle metadata — should subscribe to onApplicationsChanged; the index watcher fires automatically when the bundle disappears from a scanned directory.
Platform behaviour:
| Platform | Action visibility | Behaviour on confirm |
|---|---|---|
| macOS | Shown for type: 'application' results whose path does not start with /System/ |
Before the confirm sheet, Asyar scans ~/Library/* for user data keyed by the app's CFBundleIdentifier (Application Support, Caches, Logs, Containers, HTTPStorages, WebKit, Application Scripts, Preferences/*.plist, ByHost preferences, Saved Application State, LaunchAgents, Cookies) plus two name-keyed fallbacks. The confirm sheet shows the total size. On confirm, the .app bundle is moved to Trash via the trash crate, followed by each user-data path. All items remain reversible from Finder's Trash. |
| Windows | Shown for type: 'application' results with a .lnk path |
The shortcut's display-name is matched case-insensitively against HKLM/HKCU\…\CurrentVersion\Uninstall\*; the discovered UninstallString is launched via cmd /C. The vendor's own uninstaller UI takes over (including any UAC prompt). Asyar does not scan user data — the vendor uninstaller is responsible for that cleanup. |
| Linux | Hidden | Not supported — package-manager fragmentation (apt/dnf/pacman/flatpak/snap/AppImage) makes a single first-party implementation impractical. |
macOS data-scan scope — the scanner is intentionally conservative:
- Included:
~/Library/Application Support/<bundle-id>,~/Library/Caches/<bundle-id>,~/Library/Logs/<bundle-id>,~/Library/Containers/<bundle-id>,~/Library/HTTPStorages/<bundle-id>,~/Library/WebKit/<bundle-id>,~/Library/Application Scripts/<bundle-id>,~/Library/Preferences/<bundle-id>.plist,~/Library/Preferences/ByHost/<bundle-id>.*.plist,~/Library/Saved Application State/<bundle-id>.savedState,~/Library/LaunchAgents/<bundle-id>.plist,~/Library/Cookies/<bundle-id>.binarycookies, plus name-keyed variants of Application Support and Caches. - Excluded (by design):
~/Library/Group Containers/*(shared across multiple apps),/Library/LaunchDaemons/*and/Library/PrivilegedHelperTools/*(require admin),~/Library/Keychains/*(requires the user's password), any path that is a symlink. - Safety gate: every data path is independently validated in Rust before it's passed to
trash::delete— must be absolute, exist, live under$HOME/Library, and not be a symlink. A bogus path in the TS-supplied list is logged and skipped; the primary.appuninstall still succeeds.
Windows safety gates (enforced in Rust):
- Empty or missing
UninstallString→AppError::Validation. - Entry flagged
SystemComponent = 1→AppError::Permission(these are Windows updates/components and never user-uninstallable). - Entry's
DisplayNamematches Asyar itself (case-insensitive) →AppError::Permission(no self-uninstall). - No matching registry entry →
AppError::NotFound(the UI surfaces this as a "Uninstall failed: …" HUD).