@zappdev/runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/menu.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { Events } from "./events";
2
+
3
+ /** Definition of a single menu item, including separators, checkboxes, and submenus. */
4
+ export interface MenuItemDef {
5
+ /** Unique ID for click events. Auto-generated if action is provided without id. */
6
+ id?: string;
7
+ /** Display text. Required unless type is "separator" or role is set. */
8
+ label?: string;
9
+ /** Item type. Default: "normal" */
10
+ type?: "normal" | "separator" | "checkbox";
11
+ /** Whether the item is clickable. Default: true */
12
+ enabled?: boolean;
13
+ /** For checkbox items. Default: false */
14
+ checked?: boolean;
15
+ /** Keyboard shortcut. Use "CmdOrCtrl" for cross-platform Cmd/Ctrl. */
16
+ accelerator?: string;
17
+ /** Built-in menu role. Overrides label/submenu with standard items. */
18
+ role?: "editMenu" | "windowMenu" | "appMenu";
19
+ /** Click handler. Sugar for menu.on(id, handler). */
20
+ action?: () => void;
21
+ /** Nested submenu items. */
22
+ submenu?: MenuItemDef[];
23
+ }
24
+
25
+ /** Handle returned after building a menu, used to listen for item clicks. */
26
+ export interface MenuHandle {
27
+ /** Listen for a menu item click by its ID */
28
+ on(itemId: string, handler: () => void): () => void;
29
+ /** The raw menu definition (for re-serialization) */
30
+ readonly items: MenuItemDef[];
31
+ }
32
+
33
+ /** API for building and installing native menus. */
34
+ export interface MenuAPI {
35
+ /** Build a menu from a definition array */
36
+ build(items: MenuItemDef[]): MenuHandle;
37
+ }
38
+
39
+ let menuIdSeq = 0;
40
+
41
+ type ActionEntry = { id: string; handler: () => void };
42
+
43
+ function collectActions(items: MenuItemDef[], actions: ActionEntry[]): MenuItemDef[] {
44
+ return items.map((item) => {
45
+ const copy = { ...item };
46
+ if (copy.action && !copy.id) {
47
+ copy.id = `__menu_${++menuIdSeq}`;
48
+ }
49
+ if (copy.action && copy.id) {
50
+ actions.push({ id: copy.id, handler: copy.action });
51
+ }
52
+ // Strip action from the serialized def (not JSON-serializable)
53
+ delete copy.action;
54
+ if (copy.submenu) {
55
+ copy.submenu = collectActions(copy.submenu, actions);
56
+ }
57
+ return copy;
58
+ });
59
+ }
60
+
61
+ function postMenu(items: MenuItemDef[]): void {
62
+ const handler = (globalThis as unknown as Record<string, Record<string, Record<string, { postMessage?: (m: string) => void }>>>)
63
+ .webkit?.messageHandlers?.zapp;
64
+ const chromeWebview = (globalThis as unknown as Record<string, Record<string, { postMessage?: (m: string) => void }>>)
65
+ .chrome?.webview;
66
+
67
+ const msg = `app\nsetMenu\n${JSON.stringify({ items })}`;
68
+
69
+ if (handler?.postMessage) {
70
+ handler.postMessage(msg);
71
+ } else if (chromeWebview?.postMessage) {
72
+ chromeWebview.postMessage(msg);
73
+ }
74
+ }
75
+
76
+ /** The singleton menu API instance. */
77
+ export const Menu: MenuAPI = {
78
+ build(items: MenuItemDef[]): MenuHandle {
79
+ const actions: ActionEntry[] = [];
80
+ const cleanItems = collectActions(items, actions);
81
+
82
+ // Post the cleaned (no functions) menu to native
83
+ postMenu(cleanItems);
84
+
85
+ // Wire up inline action handlers
86
+ const offs: (() => void)[] = [];
87
+ for (const { id, handler } of actions) {
88
+ offs.push(Events.on(`menu:${id}`, handler as () => void));
89
+ }
90
+
91
+ return {
92
+ get items() { return cleanItems; },
93
+ on(itemId: string, handler: () => void): () => void {
94
+ return Events.on(`menu:${itemId}`, handler as () => void);
95
+ },
96
+ };
97
+ },
98
+ };
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@zappdev/runtime",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Frontend runtime API for Zapp desktop apps",
6
+ "main": "index.ts",
7
+ "types": "index.ts",
8
+ "files": [
9
+ "*.ts",
10
+ "src/"
11
+ ],
12
+ "license": "MIT"
13
+ }
package/protocol.ts ADDED
@@ -0,0 +1,94 @@
1
+ /** Current version of the worker wire protocol. */
2
+ export const ZAPP_WORKER_PROTOCOL_VERSION = 1;
3
+ /** Current version of the service invocation protocol. */
4
+ export const ZAPP_SERVICE_PROTOCOL_VERSION = 1;
5
+
6
+ /** Discriminator strings for worker control messages. */
7
+ export type ZappWorkerControlType =
8
+ | "zapp:worker:init"
9
+ | "zapp:worker:post"
10
+ | "zapp:worker:message"
11
+ | "zapp:worker:error"
12
+ | "zapp:worker:terminate";
13
+
14
+ /** Envelope wrapping all worker control messages on the wire. */
15
+ export interface ZappWorkerEnvelope<T = unknown> {
16
+ v: typeof ZAPP_WORKER_PROTOCOL_VERSION;
17
+ t: ZappWorkerControlType;
18
+ workerId: string;
19
+ payload?: T;
20
+ }
21
+
22
+ /** Payload for the worker init control message. */
23
+ export interface ZappWorkerInitPayload {
24
+ scriptUrl: string;
25
+ shared?: boolean;
26
+ }
27
+
28
+ /** Payload for posting data to a worker. */
29
+ export interface ZappWorkerPostPayload {
30
+ data: unknown;
31
+ }
32
+
33
+ /** Payload for a message received from a worker. */
34
+ export interface ZappWorkerMessagePayload {
35
+ data: unknown;
36
+ }
37
+
38
+ /** Payload describing a worker error. */
39
+ export interface ZappWorkerErrorPayload {
40
+ message: string;
41
+ stack?: string;
42
+ filename?: string;
43
+ lineno?: number;
44
+ colno?: number;
45
+ }
46
+
47
+ /** Host-side bridge interface for managing workers from the native layer. */
48
+ export type ZappWorkerHostBridge = {
49
+ createWorker(scriptUrl: string, options?: { shared?: boolean }): string;
50
+ postToWorker(workerId: string, data: unknown): void;
51
+ terminateWorker(workerId: string): void;
52
+ subscribe(
53
+ workerId: string,
54
+ onMessage: (data: unknown) => void,
55
+ onError: (error: ZappWorkerErrorPayload) => void,
56
+ ): () => void;
57
+ };
58
+
59
+ /** Standard error codes returned by service invocations. */
60
+ export type ZappServiceErrorCode =
61
+ | "BAD_REQUEST"
62
+ | "INVALID_METHOD"
63
+ | "UNAUTHORIZED"
64
+ | "NOT_FOUND"
65
+ | "INTERNAL_ERROR"
66
+ | "TIMEOUT";
67
+
68
+ /** A request to invoke a named service method. */
69
+ export interface ZappServiceInvokeRequest {
70
+ v: typeof ZAPP_SERVICE_PROTOCOL_VERSION;
71
+ id: string;
72
+ method: string;
73
+ args: unknown;
74
+ meta: {
75
+ sourceCtxId: string;
76
+ capability?: string;
77
+ };
78
+ }
79
+
80
+ /** Structured error returned from a failed service invocation. */
81
+ export interface ZappServiceInvokeError {
82
+ code: ZappServiceErrorCode;
83
+ message: string;
84
+ details?: unknown;
85
+ }
86
+
87
+ /** Response envelope from a service invocation. */
88
+ export interface ZappServiceInvokeResponse {
89
+ v: typeof ZAPP_SERVICE_PROTOCOL_VERSION;
90
+ id: string;
91
+ ok: boolean;
92
+ result?: unknown;
93
+ error?: ZappServiceInvokeError;
94
+ }
package/services.ts ADDED
@@ -0,0 +1,91 @@
1
+ import {
2
+ ZAPP_SERVICE_PROTOCOL_VERSION,
3
+ type ZappServiceInvokeError,
4
+ type ZappServiceInvokeRequest,
5
+ type ZappServiceInvokeResponse,
6
+ } from "./protocol";
7
+
8
+ type Bridge = {
9
+ invoke?: (req: ZappServiceInvokeRequest) => Promise<ZappServiceInvokeResponse>;
10
+ getServiceBindings?: () => string | null;
11
+ };
12
+
13
+ const getBridge = (): Bridge | null =>
14
+ ((globalThis as unknown as Record<symbol, unknown>)[
15
+ Symbol.for("zapp.bridge")
16
+ ] as Bridge | undefined) ?? null;
17
+
18
+ const normalizeError = (error: unknown): ZappServiceInvokeError => {
19
+ if (error && typeof error === "object") {
20
+ const errObj = error as Record<string, unknown>;
21
+ if (typeof errObj.code === "string" && typeof errObj.message === "string") {
22
+ return {
23
+ code: errObj.code as ZappServiceInvokeError["code"],
24
+ message: errObj.message,
25
+ details: errObj.details,
26
+ };
27
+ }
28
+ }
29
+ if (error instanceof Error) {
30
+ return { code: "INTERNAL_ERROR", message: error.message };
31
+ }
32
+ return { code: "INTERNAL_ERROR", message: "Service invocation failed" };
33
+ };
34
+
35
+ const makeRequest = (method: string, args: unknown): ZappServiceInvokeRequest => ({
36
+ v: ZAPP_SERVICE_PROTOCOL_VERSION,
37
+ id: `svc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
38
+ method,
39
+ args,
40
+ meta: {
41
+ sourceCtxId: `ctx-${Math.random().toString(36).slice(2)}`,
42
+ },
43
+ });
44
+
45
+ /** API for invoking native services defined in the Zapp backend. */
46
+ export interface ServicesAPI {
47
+ /** Call a named service method and return the result. */
48
+ invoke<T = unknown>(method: string, args?: unknown): Promise<T>;
49
+ /** Retrieve the parsed bindings manifest describing available services, or null. */
50
+ getBindingsManifest(): unknown;
51
+ }
52
+
53
+ /** The singleton services API instance. */
54
+ export const Services: ServicesAPI = {
55
+ async invoke<T = unknown>(method: string, args?: unknown): Promise<T> {
56
+ if (typeof method !== "string" || method.length === 0) {
57
+ throw new Error("Service method must be a non-empty string.");
58
+ }
59
+
60
+ const bridge = getBridge();
61
+ if (!bridge?.invoke) {
62
+ throw new Error("Native invoke bridge is unavailable.");
63
+ }
64
+
65
+ const req = makeRequest(method, args ?? {});
66
+ let response: ZappServiceInvokeResponse;
67
+ try {
68
+ response = await bridge.invoke(req);
69
+ } catch (error) {
70
+ const normalized = normalizeError(error);
71
+ throw new Error(`${normalized.code}: ${normalized.message}`);
72
+ }
73
+
74
+ if (!response.ok) {
75
+ const err = normalizeError(response.error);
76
+ throw new Error(`${err.code}: ${err.message}`);
77
+ }
78
+
79
+ return response.result as T;
80
+ },
81
+
82
+ getBindingsManifest(): unknown {
83
+ const raw = getBridge()?.getServiceBindings?.();
84
+ if (!raw) return null;
85
+ try {
86
+ return JSON.parse(raw);
87
+ } catch {
88
+ return null;
89
+ }
90
+ },
91
+ };
package/sync.ts ADDED
@@ -0,0 +1,62 @@
1
+ type Bridge = {
2
+ syncWait?: (request: {
3
+ key: string;
4
+ timeoutMs: number | null;
5
+ signal?: AbortSignal;
6
+ }) => Promise<"notified" | "timed-out">;
7
+ syncNotify?: (request: { key: string; count: number }) => boolean;
8
+ syncCancel?: (request: { id: string }) => boolean;
9
+ };
10
+
11
+ /** Options for {@link SyncAPI.wait}. */
12
+ export type SyncWaitOptions = {
13
+ /** Timeout in milliseconds, or null to wait indefinitely. */
14
+ timeoutMs?: number | null;
15
+ /** An AbortSignal to cancel the wait. */
16
+ signal?: AbortSignal;
17
+ };
18
+
19
+ const getBridge = (): Bridge | null =>
20
+ ((globalThis as unknown as Record<symbol, unknown>)[
21
+ Symbol.for("zapp.bridge")
22
+ ] as Bridge | undefined) ?? null;
23
+
24
+ /** API for cross-context synchronization primitives (wait/notify). */
25
+ export interface SyncAPI {
26
+ /** Block until the given key is notified or the timeout elapses. */
27
+ wait(key: string, timeoutOrOptions?: number | SyncWaitOptions | null): Promise<"notified" | "timed-out">;
28
+ /** Wake up to `count` waiters blocked on the given key. */
29
+ notify(key: string, count?: number): boolean;
30
+ }
31
+
32
+ /** The singleton synchronization API instance. */
33
+ export const Sync: SyncAPI = {
34
+ async wait(
35
+ key: string,
36
+ timeoutOrOptions: number | SyncWaitOptions | null = 30000
37
+ ): Promise<"notified" | "timed-out"> {
38
+ const bridge = getBridge();
39
+ if (!bridge?.syncWait) {
40
+ throw new Error("Sync bridge is unavailable.");
41
+ }
42
+ if (typeof key !== "string" || key.trim().length === 0) {
43
+ throw new Error("Sync key must be a non-empty string.");
44
+ }
45
+ const options: SyncWaitOptions =
46
+ typeof timeoutOrOptions === "number" || timeoutOrOptions == null
47
+ ? { timeoutMs: timeoutOrOptions }
48
+ : timeoutOrOptions;
49
+ return await bridge.syncWait({
50
+ key: key.trim(),
51
+ timeoutMs: options.timeoutMs ?? null,
52
+ signal: options.signal,
53
+ });
54
+ },
55
+
56
+ notify(key: string, count = 1): boolean {
57
+ const bridge = getBridge();
58
+ if (!bridge?.syncNotify) return false;
59
+ if (typeof key !== "string" || key.trim().length === 0) return false;
60
+ return bridge.syncNotify({ key: key.trim(), count });
61
+ },
62
+ };
package/windows.ts ADDED
@@ -0,0 +1,222 @@
1
+ import { Events, WindowEvent, getWindowEventName, type WindowEventPayload, type WindowSizeEventPayload } from "./events";
2
+
3
+ /** Options for creating a new window. */
4
+ export interface WindowOptions {
5
+ /** Window title text. */
6
+ title?: string;
7
+ /** Initial width in logical pixels. */
8
+ width?: number;
9
+ /** Initial height in logical pixels. */
10
+ height?: number;
11
+ /** Initial x position on screen. */
12
+ x?: number;
13
+ /** Initial y position on screen. */
14
+ y?: number;
15
+ /** URL to load in the window. */
16
+ url?: string;
17
+ /** Whether the window can be resized by the user. */
18
+ resizable?: boolean;
19
+ /** Whether the window shows a close button. */
20
+ closable?: boolean;
21
+ /** Whether the window can be minimized. */
22
+ minimizable?: boolean;
23
+ /** Whether the window can be maximized. */
24
+ maximizable?: boolean;
25
+ /** Whether the window starts in fullscreen. */
26
+ fullscreen?: boolean;
27
+ /** Whether the window floats above other windows. */
28
+ alwaysOnTop?: boolean;
29
+ /** Title bar appearance style. */
30
+ titleBarStyle?: "default" | "hidden" | "hiddenInset";
31
+ /** Whether the window is visible on creation. */
32
+ visible?: boolean;
33
+ }
34
+
35
+ /** Window events that carry size + position */
36
+ type SizeEvent =
37
+ | WindowEvent.RESIZE
38
+ | WindowEvent.MOVE
39
+ | WindowEvent.MAXIMIZE
40
+ | WindowEvent.RESTORE;
41
+
42
+ /** Handle for controlling an individual window instance. */
43
+ export type WindowHandle = {
44
+ /** Unique identifier for this window. */
45
+ readonly id: string;
46
+ /** Show the window. */
47
+ show(): void;
48
+ /** Hide the window. */
49
+ hide(): void;
50
+ /** Minimize the window. */
51
+ minimize(): void;
52
+ /** Maximize the window. */
53
+ maximize(): void;
54
+ /** Restore the window from minimized state. */
55
+ unminimize(): void;
56
+ /** Restore the window from maximized state. */
57
+ unmaximize(): void;
58
+ /** Toggle between minimized and restored. */
59
+ toggleMinimize(): void;
60
+ /** Toggle between maximized and restored. */
61
+ toggleMaximize(): void;
62
+ /** Request the window to close (may be intercepted by close guards). */
63
+ close(): void;
64
+ /** Set the window title. */
65
+ setTitle(title: string): void;
66
+ /** Set the window size in logical pixels. */
67
+ setSize(width: number, height: number): void;
68
+ /** Set the window position on screen. */
69
+ setPosition(x: number, y: number): void;
70
+ /** Enter or exit fullscreen mode. */
71
+ setFullscreen(on: boolean): void;
72
+ /** Set whether the window floats above other windows. */
73
+ setAlwaysOnTop(on: boolean): void;
74
+ /** Force-close the window, bypassing all close guards */
75
+ destroy(): void;
76
+ /** Typed event listener — size events get a payload with size + position */
77
+ on(event: SizeEvent, handler: (payload: WindowSizeEventPayload) => void): () => void;
78
+ /** Typed event listener — other window events get base payload */
79
+ on(event: WindowEvent, handler: (payload: WindowEventPayload) => void): () => void;
80
+ /** Typed one-time listener — size events */
81
+ once(event: SizeEvent, handler: (payload: WindowSizeEventPayload) => void): () => void;
82
+ /** Typed one-time listener — other window events */
83
+ once(event: WindowEvent, handler: (payload: WindowEventPayload) => void): () => void;
84
+ /** Remove all listeners for an event */
85
+ off(event: WindowEvent): void;
86
+ };
87
+
88
+ type WindowBridge = {
89
+ windowCreate?: (options: WindowOptions) => Promise<{ id: string }>;
90
+ windowAction?: (windowId: string, action: string, params?: Record<string, unknown>) => void;
91
+ };
92
+
93
+ const getBridge = (): WindowBridge | null =>
94
+ ((globalThis as unknown as Record<symbol, unknown>)[
95
+ Symbol.for("zapp.bridge")
96
+ ] as WindowBridge | undefined) ?? null;
97
+
98
+ function makeHandle(windowId: string): WindowHandle {
99
+ const action = (name: string, params?: Record<string, unknown>) => {
100
+ const bridge = getBridge();
101
+ bridge?.windowAction?.(windowId, name, params);
102
+ };
103
+
104
+ const isWindowReady = (): boolean => {
105
+ const ss = globalThis as unknown as Record<symbol, unknown>;
106
+ return ss[Symbol.for("zapp.windowReady")] === true;
107
+ };
108
+
109
+ let closeListenerCount = 0;
110
+
111
+ const handleOn = (event: WindowEvent, handler: (payload: WindowEventPayload) => void): (() => void) => {
112
+ const eventName = getWindowEventName(event);
113
+ const off = Events.on(`window:${eventName}`, (payload) => {
114
+ const p = payload as WindowEventPayload | undefined;
115
+ if (p?.windowId === windowId) {
116
+ handler(p);
117
+ }
118
+ });
119
+
120
+ // Auto-guard: registering a CLOSE listener enables the native close guard
121
+ if (event === WindowEvent.CLOSE) {
122
+ closeListenerCount++;
123
+ if (closeListenerCount === 1) {
124
+ action("setCloseGuard", { guard: true });
125
+ }
126
+ }
127
+
128
+ if (event === WindowEvent.READY && isWindowReady()) {
129
+ queueMicrotask(() => handler({ windowId, timestamp: Date.now() }));
130
+ }
131
+
132
+ // Return unsubscribe that also manages the close guard
133
+ return () => {
134
+ off();
135
+ if (event === WindowEvent.CLOSE) {
136
+ closeListenerCount--;
137
+ if (closeListenerCount <= 0) {
138
+ closeListenerCount = 0;
139
+ action("setCloseGuard", { guard: false });
140
+ }
141
+ }
142
+ };
143
+ };
144
+
145
+ const handleOnce = (event: WindowEvent, handler: (payload: WindowEventPayload) => void): (() => void) => {
146
+ if (event === WindowEvent.READY && isWindowReady()) {
147
+ queueMicrotask(() => handler({ windowId, timestamp: Date.now() }));
148
+ return () => {};
149
+ }
150
+ const eventName = getWindowEventName(event);
151
+ return Events.once(`window:${eventName}`, (payload) => {
152
+ const p = payload as WindowEventPayload | undefined;
153
+ if (p?.windowId === windowId) {
154
+ handler(p);
155
+ }
156
+ });
157
+ };
158
+
159
+ const handleOff: WindowHandle["off"] = (event: WindowEvent) => {
160
+ const eventName = getWindowEventName(event);
161
+ Events.off(`window:${eventName}`);
162
+ };
163
+
164
+ return {
165
+ get id() { return windowId; },
166
+ show() { action("show"); },
167
+ hide() { action("hide"); },
168
+ minimize() { action("minimize"); },
169
+ maximize() { action("maximize"); },
170
+ unminimize() { action("unminimize"); },
171
+ unmaximize() { action("unmaximize"); },
172
+ toggleMinimize() { action("toggle_minimize"); },
173
+ toggleMaximize() { action("toggle_maximize"); },
174
+ close() { action("close"); },
175
+ destroy() { action("destroy"); },
176
+ setTitle(title: string) { action("set_title", { title }); },
177
+ setSize(width: number, height: number) { action("set_size", { width, height }); },
178
+ setPosition(x: number, y: number) { action("set_position", { x, y }); },
179
+ setFullscreen(on: boolean) { action("set_fullscreen", { on }); },
180
+ setAlwaysOnTop(on: boolean) { action("set_always_on_top", { on }); },
181
+ on: handleOn as WindowHandle["on"],
182
+ once: handleOnce as WindowHandle["once"],
183
+ off: handleOff,
184
+ };
185
+ }
186
+
187
+ /** API for creating and accessing windows. */
188
+ export interface WindowAPI {
189
+ /** Create a new window with the given options. */
190
+ create(options?: WindowOptions): Promise<WindowHandle>;
191
+ /** Get a handle to the current webview's window. Throws in worker contexts. */
192
+ current(): WindowHandle;
193
+ }
194
+
195
+ /** The singleton window management API instance. */
196
+ export const Window: WindowAPI = {
197
+ async create(options: WindowOptions = {}): Promise<WindowHandle> {
198
+ const bridge = getBridge();
199
+ if (!bridge?.windowCreate) {
200
+ throw new Error("Window bridge unavailable. Is the Zapp runtime loaded?");
201
+ }
202
+ const result = await bridge.windowCreate(options);
203
+ return makeHandle(result.id);
204
+ },
205
+
206
+ current(): WindowHandle {
207
+ const symbolStore = globalThis as unknown as Record<symbol, unknown>;
208
+ if (symbolStore[Symbol.for("zapp.context")] === "worker") {
209
+ throw new Error("Window.current() is not available in a worker context. Use Window.create() instead.");
210
+ }
211
+ const windowId = symbolStore[Symbol.for("zapp.windowId")] as string | undefined;
212
+ const ownerId = symbolStore[Symbol.for("zapp.ownerId")] as string | undefined;
213
+ const contextWindowId = symbolStore[Symbol.for("zapp.currentWindowId")] as string | undefined;
214
+ const id = windowId ?? contextWindowId ?? ownerId;
215
+ if (!id) {
216
+ throw new Error(
217
+ "Window.current() is only available in a webview context with an associated window.",
218
+ );
219
+ }
220
+ return makeHandle(id);
221
+ },
222
+ };