gantry-web 0.3.5 → 0.4.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/src/bridge.ts CHANGED
@@ -1,94 +1,95 @@
1
- // The native bridge: typed access to the window.<prefix>* functions the
2
- // Go appshell binds. In a plain browser tab none of them exist, so
3
- // every method is safe to call anywhere - it just does nothing outside
4
- // the native window - and `available` tells you which world you are in.
5
-
6
- export interface ShellCaps {
7
- minimize: boolean;
8
- maximize: boolean;
9
- close: boolean;
10
- alwaysOnTop: boolean;
11
- /** "windows" | "linux" - which native host is running. */
12
- platform?: string;
13
- /** True when the window is frameless (custom chrome active). */
14
- frameless?: boolean;
15
- }
16
-
17
- export interface ShellBridge {
18
- /** True when running inside a Gantry native window. */
19
- available: boolean;
20
- close(): void;
21
- minimize(): void;
22
- /** Start the native window drag loop (call on mousedown). */
23
- drag(): void;
24
- /** System notification sound + taskbar flash. */
25
- attention(): void;
26
- maximize(): void;
27
- restore(): void;
28
- isMaximized(): Promise<boolean>;
29
- /** Which window buttons the Go side enabled (all false in a browser). */
30
- caps(): Promise<ShellCaps>;
31
- /** Widgets and popups: show or hide the native window. */
32
- setVisible(show: boolean): void;
33
- /** Widgets: resize the native window in place. */
34
- resize(width: number, height: number): void;
35
- setAlwaysOnTop(on: boolean): void;
36
- /** Open a URL in the user's default browser (never in the app). */
37
- openExternal(url: string): void;
38
- /**
39
- * Linux frameless windows: start an interactive resize from an edge
40
- * ("n","s","e","w","ne","nw","se","sw"). No-op on Windows, where the
41
- * native hit-test handles edges.
42
- */
43
- resizeEdge(edge: string): void;
44
- }
45
-
46
- type AnyFn = (...args: unknown[]) => unknown;
47
-
48
- function fn(prefix: string, name: string): AnyFn | undefined {
49
- const w = window as unknown as Record<string, unknown>;
50
- const f = w[prefix + name];
51
- return typeof f === "function" ? (f as AnyFn) : undefined;
52
- }
53
-
54
- const NO_CAPS: ShellCaps = {
55
- minimize: false,
56
- maximize: false,
57
- close: false,
58
- alwaysOnTop: false,
59
- };
60
-
61
- /**
62
- * getShell returns the bridge for the given binding prefix. The prefix
63
- * must match the Go side's BindingPrefix; both default to "gantry".
64
- */
65
- export function getShell(prefix = "gantry"): ShellBridge {
66
- const call = (name: string, ...args: unknown[]) => {
67
- const f = fn(prefix, name);
68
- if (f) void f(...args);
69
- };
70
- return {
71
- // Minimize is bound on every main window unless disabled, and Close
72
- // exists on every window kind, so either marks a native host.
73
- available: !!(fn(prefix, "Minimize") ?? fn(prefix, "Close")),
74
- close: () => call("Close"),
75
- minimize: () => call("Minimize"),
76
- drag: () => call("Drag"),
77
- attention: () => call("Attention"),
78
- maximize: () => call("Maximize"),
79
- restore: () => call("Restore"),
80
- isMaximized: async () => {
81
- const f = fn(prefix, "IsMaximized");
82
- return f ? ((await f()) as boolean) : false;
83
- },
84
- caps: async () => {
85
- const f = fn(prefix, "Caps");
86
- return f ? ((await f()) as ShellCaps) : NO_CAPS;
87
- },
88
- setVisible: (show) => call("Visible", show),
89
- resize: (w, h) => call("Resize", w, h),
90
- setAlwaysOnTop: (on) => call("SetAlwaysOnTop", on),
91
- openExternal: (url) => call("OpenExternal", url),
92
- resizeEdge: (edge) => call("ResizeEdge", edge),
93
- };
94
- }
1
+ // The native bridge: typed access to the window.<prefix>* functions the
2
+ // Go appshell binds. In a plain browser tab none of them exist, so
3
+ // every method is safe to call anywhere - it just does nothing outside
4
+ // the native window - and `available` tells you which world you are in.
5
+
6
+ export interface ShellCaps {
7
+ minimize: boolean;
8
+ maximize: boolean;
9
+ close: boolean;
10
+ alwaysOnTop: boolean;
11
+ /** "windows" | "linux" - which native host is running. */
12
+ platform?: string;
13
+ /** True when the window is frameless (custom chrome active). */
14
+ frameless?: boolean;
15
+ }
16
+
17
+ export interface ShellBridge {
18
+ /** True when running inside a Gantry native window. */
19
+ available: boolean;
20
+ close(): void;
21
+ minimize(): void;
22
+ /** Start the native window drag loop (call on mousedown). */
23
+ drag(): void;
24
+ /** System notification sound + taskbar flash. */
25
+ attention(): void;
26
+ maximize(): void;
27
+ restore(): void;
28
+ isMaximized(): Promise<boolean>;
29
+ /** Which window buttons the Go side enabled (all false in a browser). */
30
+ caps(): Promise<ShellCaps>;
31
+ /** Widgets and popups: show or hide the native window. */
32
+ setVisible(show: boolean): void;
33
+ /** Widgets: resize the native window in place. */
34
+ resize(width: number, height: number): void;
35
+ setAlwaysOnTop(on: boolean): void;
36
+ /** Open a URL in the user's default browser (never in the app). */
37
+ openExternal(url: string): void;
38
+ /**
39
+ * Frameless windows: start an interactive resize from an edge
40
+ * ("n","s","e","w","ne","nw","se","sw"). Bound on both Windows and
41
+ * Linux; on Windows it posts WM_NCLBUTTONDOWN (the native hit-test
42
+ * margin remains only as a backstop).
43
+ */
44
+ resizeEdge(edge: string): void;
45
+ }
46
+
47
+ type AnyFn = (...args: unknown[]) => unknown;
48
+
49
+ function fn(prefix: string, name: string): AnyFn | undefined {
50
+ const w = window as unknown as Record<string, unknown>;
51
+ const f = w[prefix + name];
52
+ return typeof f === "function" ? (f as AnyFn) : undefined;
53
+ }
54
+
55
+ const NO_CAPS: ShellCaps = {
56
+ minimize: false,
57
+ maximize: false,
58
+ close: false,
59
+ alwaysOnTop: false,
60
+ };
61
+
62
+ /**
63
+ * getShell returns the bridge for the given binding prefix. The prefix
64
+ * must match the Go side's BindingPrefix; both default to "gantry".
65
+ */
66
+ export function getShell(prefix = "gantry"): ShellBridge {
67
+ const call = (name: string, ...args: unknown[]) => {
68
+ const f = fn(prefix, name);
69
+ if (f) void f(...args);
70
+ };
71
+ return {
72
+ // Minimize is bound on every main window unless disabled, and Close
73
+ // exists on every window kind, so either marks a native host.
74
+ available: !!(fn(prefix, "Minimize") ?? fn(prefix, "Close")),
75
+ close: () => call("Close"),
76
+ minimize: () => call("Minimize"),
77
+ drag: () => call("Drag"),
78
+ attention: () => call("Attention"),
79
+ maximize: () => call("Maximize"),
80
+ restore: () => call("Restore"),
81
+ isMaximized: async () => {
82
+ const f = fn(prefix, "IsMaximized");
83
+ return f ? ((await f()) as boolean) : false;
84
+ },
85
+ caps: async () => {
86
+ const f = fn(prefix, "Caps");
87
+ return f ? ((await f()) as ShellCaps) : NO_CAPS;
88
+ },
89
+ setVisible: (show) => call("Visible", show),
90
+ resize: (w, h) => call("Resize", w, h),
91
+ setAlwaysOnTop: (on) => call("SetAlwaysOnTop", on),
92
+ openExternal: (url) => call("OpenExternal", url),
93
+ resizeEdge: (edge) => call("ResizeEdge", edge),
94
+ };
95
+ }
package/src/env.ts ADDED
@@ -0,0 +1,71 @@
1
+ // The app's mode and declared args, served by the built-in "gantry"
2
+ // service the Go side registers in gantry.Run. Mode is "development"
3
+ // under gantry dev and "production" in a built binary; args are the
4
+ // gantry.json "args" declarations resolved from their environment
5
+ // variables. Use them to gate pages and features.
6
+
7
+ import { useEffect, useState } from "react";
8
+ import { callGo } from "./socket";
9
+
10
+ export type AppMode = "development" | "production";
11
+
12
+ export interface AppEnv {
13
+ /** "development" under gantry dev, "production" in a built binary. */
14
+ mode: AppMode;
15
+ /** Every declared arg (gantry.json "args"), resolved: env > default. */
16
+ args: Record<string, string | number | boolean>;
17
+ }
18
+
19
+ let cached: AppEnv | null = null;
20
+ let pending: Promise<AppEnv> | null = null;
21
+
22
+ /** fetchEnv resolves the mode and declared args once and caches them -
23
+ * the values cannot change while the app runs. */
24
+ export function fetchEnv(): Promise<AppEnv> {
25
+ if (cached) return Promise.resolve(cached);
26
+ pending ??= callGo("gantry", "env")
27
+ .then((v) => (cached = v as AppEnv))
28
+ .catch((err) => {
29
+ pending = null; // retry on the next call
30
+ throw err;
31
+ });
32
+ return pending;
33
+ }
34
+
35
+ /**
36
+ * useEnv returns { mode, args } - null until the first fetch resolves.
37
+ *
38
+ * const env = useEnv();
39
+ * if (env?.args["mock-data"]) ...
40
+ */
41
+ export function useEnv(): AppEnv | null {
42
+ const [env, setEnv] = useState(cached);
43
+ useEffect(() => {
44
+ if (env) return;
45
+ let alive = true;
46
+ fetchEnv()
47
+ .then((v) => {
48
+ if (alive) setEnv(v);
49
+ })
50
+ .catch(() => {});
51
+ return () => {
52
+ alive = false;
53
+ };
54
+ }, [env]);
55
+ return env;
56
+ }
57
+
58
+ /** useMode returns the app mode - null until the first fetch resolves. */
59
+ export function useMode(): AppMode | null {
60
+ return useEnv()?.mode ?? null;
61
+ }
62
+
63
+ /**
64
+ * useArg returns one declared arg's value - undefined until the first
65
+ * fetch resolves (and for undeclared names).
66
+ *
67
+ * const host = useArg<string>("api-host");
68
+ */
69
+ export function useArg<T extends string | number | boolean = string | number | boolean>(name: string): T | undefined {
70
+ return useEnv()?.args[name] as T | undefined;
71
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,214 @@
1
+ // The frontend half of Gantry's error pipeline. createApp installs the
2
+ // window-level handlers and the ErrorBoundary reports render crashes
3
+ // here; Go-side errors arrive over the websocket as {"t":"error"}
4
+ // frames. Every error lands in one store that drives the (default or
5
+ // custom) error UI, and JS-side errors are reported back to Go so the
6
+ // gantry dev terminal, the ring buffer and the app's OnError hook see
7
+ // them too.
8
+
9
+ import { useSyncExternalStore } from "react";
10
+ import { callGo, onServerError } from "./socket";
11
+
12
+ /** One breadcrumb in an error's trail (recorded by the Go server). */
13
+ export interface GantryCrumb {
14
+ time: string;
15
+ type: "navigate" | "event" | "call" | "state" | "custom";
16
+ detail: string;
17
+ ok: boolean;
18
+ }
19
+
20
+ /** One captured error, from either side of the app. */
21
+ export interface GantryErrorInfo {
22
+ /** "react-render" | "js-error" | "js-rejection" | the Go kinds
23
+ * ("call-panic", "process-crash", ...). */
24
+ kind: string;
25
+ /** gerr code: "panic.call", "js.error", ... */
26
+ code?: string;
27
+ /** Where it happened: "pages/index.increment", a component stack head. */
28
+ source?: string;
29
+ message: string;
30
+ stack?: string;
31
+ /** React component stack, for render crashes. */
32
+ componentStack?: string;
33
+ time: string;
34
+ /** Active page when it fired. */
35
+ page?: string;
36
+ /** Recent user actions leading up to it, oldest first. */
37
+ trail?: GantryCrumb[];
38
+ /** Which side captured it. */
39
+ origin: "go" | "js";
40
+ }
41
+
42
+ export interface ErrorHandlingOptions {
43
+ /** false turns the whole frontend pipeline off (default true). */
44
+ enabled?: boolean;
45
+ /** Replace the built-in error screen (see ErrorScreenProps). */
46
+ screen?: unknown; // typed as FC<ErrorScreenProps> in app.tsx to avoid a cycle
47
+ /** Intercept every error; return false to suppress the default UI. */
48
+ onError?: (e: GantryErrorInfo) => boolean | void;
49
+ /** Report JS-side errors to Go (terminal log, ring buffer, OnError
50
+ * hook). Default true. */
51
+ reportToGo?: boolean;
52
+ /** Show Go-side errors (panics) as dismissible notices: true, false,
53
+ * or only in development mode (the default). */
54
+ showGoErrors?: boolean | "development";
55
+ }
56
+
57
+ interface ErrorStore {
58
+ /** A render crash: the page is down, the overlay owns the screen. */
59
+ fatal: GantryErrorInfo | null;
60
+ /** Non-fatal errors (Go panics, unhandled rejections): banners. */
61
+ notices: GantryErrorInfo[];
62
+ }
63
+
64
+ let store: ErrorStore = { fatal: null, notices: [] };
65
+ const listeners = new Set<() => void>();
66
+ let opts: ErrorHandlingOptions = {};
67
+ let installed = false;
68
+ let devMode: boolean | null = null; // resolved from the env service
69
+
70
+ // StrictMode and error bubbling can fire the same error twice within a
71
+ // tick; dedupe by message+stack in a short window.
72
+ let lastSig = "";
73
+ let lastSigAt = 0;
74
+
75
+ function emit(): void {
76
+ listeners.forEach((fn) => fn());
77
+ }
78
+
79
+ function setStore(next: ErrorStore): void {
80
+ store = next;
81
+ emit();
82
+ }
83
+
84
+ /** setDevMode feeds the resolved mode in (createApp does this); the
85
+ * default UI shows full detail only in development. */
86
+ export function setDevMode(dev: boolean): void {
87
+ devMode = dev;
88
+ emit();
89
+ }
90
+
91
+ export function isDevMode(): boolean | null {
92
+ return devMode;
93
+ }
94
+
95
+ function shouldShowGoError(): boolean {
96
+ const pref = opts.showGoErrors ?? "development";
97
+ if (pref === "development") return devMode !== false; // unknown mode: show
98
+ return pref;
99
+ }
100
+
101
+ /**
102
+ * reportError feeds one error into the pipeline: dedupe, the app's
103
+ * onError hook, the store (driving the error UI), and - for JS-side
104
+ * errors - a best-effort report to Go.
105
+ */
106
+ export function reportError(e: GantryErrorInfo): void {
107
+ if (opts.enabled === false) return;
108
+ const sig = e.message + "\n" + (e.stack ?? "");
109
+ const now = Date.now();
110
+ if (sig === lastSig && now - lastSigAt < 500) return;
111
+ lastSig = sig;
112
+ lastSigAt = now;
113
+
114
+ if (!e.page && e.origin === "js") e.page = location.pathname;
115
+ if (!e.time) e.time = new Date().toISOString();
116
+
117
+ if (e.origin === "js" && opts.reportToGo !== false) {
118
+ // Best-effort: the socket may be down; the console always has it.
119
+ callGo("gantry", "reportError", e).catch(() => {});
120
+ }
121
+
122
+ const suppress = opts.onError?.(e) === false;
123
+ if (suppress) return;
124
+
125
+ if (e.kind === "react-render") {
126
+ setStore({ ...store, fatal: e });
127
+ return;
128
+ }
129
+ if (e.origin === "go" && !shouldShowGoError()) {
130
+ console.warn(`gantry: go error [${e.kind}] ${e.source ?? ""}: ${e.message}`);
131
+ return;
132
+ }
133
+ if (e.origin === "js" && devMode === false) {
134
+ // Production: non-fatal JS errors stay in the console.
135
+ console.warn(`gantry: error [${e.kind}]: ${e.message}`);
136
+ return;
137
+ }
138
+ setStore({ ...store, notices: [...store.notices, e] });
139
+ }
140
+
141
+ /** dismissNotice removes one banner (or all, with no index). */
142
+ export function dismissNotice(index?: number): void {
143
+ if (index === undefined) {
144
+ setStore({ ...store, notices: [] });
145
+ } else {
146
+ setStore({ ...store, notices: store.notices.filter((_, i) => i !== index) });
147
+ }
148
+ }
149
+
150
+ /** clearFatal drops the fatal overlay (the Reload button uses a real
151
+ * reload instead; this is for custom screens that can recover). */
152
+ export function clearFatal(): void {
153
+ setStore({ ...store, fatal: null });
154
+ }
155
+
156
+ /** useGantryErrors subscribes the error UI to the store. */
157
+ export function useGantryErrors(): ErrorStore {
158
+ return useSyncExternalStore(
159
+ (fn) => {
160
+ listeners.add(fn);
161
+ return () => listeners.delete(fn);
162
+ },
163
+ () => store,
164
+ );
165
+ }
166
+
167
+ /**
168
+ * addBreadcrumb records an app-specific action in the Go-side error
169
+ * trail ("sync started"), alongside the automatic navigate/event/call
170
+ * crumbs. Fire-and-forget.
171
+ */
172
+ export function addBreadcrumb(detail: string): void {
173
+ callGo("gantry", "breadcrumb", { detail }).catch(() => {});
174
+ }
175
+
176
+ /**
177
+ * installErrorHandlers wires the window-level capture: uncaught JS
178
+ * errors and unhandled promise rejections. createApp calls this once.
179
+ */
180
+ export function installErrorHandlers(options: ErrorHandlingOptions): void {
181
+ opts = options;
182
+ if (installed || options.enabled === false) return;
183
+ installed = true;
184
+
185
+ // Go-side errors (panics, last-run crashes) arrive as {"t":"error"}
186
+ // frames.
187
+ onServerError((p) => {
188
+ const e = p as Omit<GantryErrorInfo, "origin">;
189
+ if (e && e.message) reportError({ ...e, origin: "go" });
190
+ });
191
+
192
+ window.addEventListener("error", (ev) => {
193
+ reportError({
194
+ kind: "js-error",
195
+ code: "js.error",
196
+ message: ev.message || String(ev.error ?? "unknown error"),
197
+ stack: ev.error instanceof Error ? ev.error.stack : undefined,
198
+ source: ev.filename ? `${ev.filename}:${ev.lineno}` : undefined,
199
+ time: new Date().toISOString(),
200
+ origin: "js",
201
+ });
202
+ });
203
+ window.addEventListener("unhandledrejection", (ev) => {
204
+ const reason = ev.reason;
205
+ reportError({
206
+ kind: "js-rejection",
207
+ code: "js.rejection",
208
+ message: reason instanceof Error ? reason.message : String(reason),
209
+ stack: reason instanceof Error ? reason.stack : undefined,
210
+ time: new Date().toISOString(),
211
+ origin: "js",
212
+ });
213
+ });
214
+ }
package/src/index.ts CHANGED
@@ -1,22 +1,32 @@
1
- export { getShell } from "./bridge";
2
- export type { ShellBridge, ShellCaps } from "./bridge";
3
- export { useShell, useShellCaps } from "./hooks";
4
- export { installZoomGuard } from "./zoom";
5
- export { TitleBar } from "./TitleBar";
6
- export type { TitleBarProps } from "./TitleBar";
7
- export { DragStrip } from "./DragStrip";
8
- export type { DragStripProps } from "./DragStrip";
9
- export { ResizeFrame } from "./ResizeFrame";
10
- export { createApp } from "./app";
11
- export type { CreateAppOptions, GantryAppModule, GantryPage, GantryPageModule } from "./app";
12
- export { navigate, redirect, goBack, goForward, useRoute, isActive, Link, ExternalLink } from "./router";
13
- export type { LinkProps, ExternalLinkProps } from "./router";
14
- export { usePaired } from "./paired";
15
- export type { Paired } from "./paired";
16
- export { service, useService, useCall } from "./service";
17
- export type { Service, CallResult } from "./service";
18
- export { useGoState } from "./gostate";
19
- export { useAppInfo, fetchAppInfo } from "./appinfo";
20
- export type { AppInfo } from "./appinfo";
21
- export { TeaView } from "./tea/Runtime";
22
- export type { TeaComponentProps, ComponentRegistry } from "./tea/Runtime";
1
+ export { getShell } from "./bridge";
2
+ export type { ShellBridge, ShellCaps } from "./bridge";
3
+ export { useShell, useShellCaps } from "./hooks";
4
+ export { installZoomGuard } from "./zoom";
5
+ export { TitleBar } from "./TitleBar";
6
+ export type { TitleBarProps } from "./TitleBar";
7
+ export { DragStrip } from "./DragStrip";
8
+ export type { DragStripProps } from "./DragStrip";
9
+ export { ResizeFrame } from "./ResizeFrame";
10
+ export { createApp } from "./app";
11
+ export type { CreateAppOptions, GantryAppModule, GantryPage, GantryPageModule } from "./app";
12
+ export { navigate, redirect, goBack, goForward, useRoute, isActive, useParams, Link, ExternalLink } from "./router";
13
+ export type { LinkProps, ExternalLinkProps, RouteParams } from "./router";
14
+ export { usePaired } from "./paired";
15
+ export type { Paired } from "./paired";
16
+ export { service, useService, useCall } from "./service";
17
+ export type { Service, CallResult } from "./service";
18
+ export { useGoState } from "./gostate";
19
+ export { resourceURL } from "./resources";
20
+ export { useAppInfo, fetchAppInfo } from "./appinfo";
21
+ export type { AppInfo } from "./appinfo";
22
+ export { useEnv, useMode, useArg, fetchEnv } from "./env";
23
+ export type { AppEnv, AppMode } from "./env";
24
+ export { reportError, addBreadcrumb, useGantryErrors, dismissNotice, clearFatal } from "./errors";
25
+ export type { GantryErrorInfo, GantryCrumb, ErrorHandlingOptions } from "./errors";
26
+ export { ErrorScreen } from "./ErrorScreen";
27
+ export type { ErrorScreenProps } from "./ErrorScreen";
28
+ export { GantryCallError } from "./socket";
29
+ export { Await, Skeleton } from "./Await";
30
+ export type { AwaitProps, SkeletonProps } from "./Await";
31
+ export { TeaView } from "./tea/Runtime";
32
+ export type { TeaComponentProps, ComponentRegistry } from "./tea/Runtime";
@@ -0,0 +1,17 @@
1
+ // Embedded app resources. Files placed in the app's resources/ directory
2
+ // are embedded into the binary once and served by the Go side at
3
+ // /resources/<path> (in gantry dev, the Vite plugin serves the same
4
+ // folder live off disk). Reference them by URL:
5
+ //
6
+ // import { resourceURL } from "gantry-web";
7
+ // <img src={resourceURL("img/logo.png")} />
8
+ // const cfg = await fetch(resourceURL("cfg.json")).then((r) => r.json());
9
+ // // fonts: `url(${resourceURL("fonts/inter.woff2")})`
10
+ //
11
+ // The same file is reachable from the paired .go code via
12
+ // gantry.Resource("img/logo.png") / gantry.Resources().
13
+
14
+ /** URL of an embedded resource, e.g. resourceURL("img/logo.png") -> "/resources/img/logo.png". */
15
+ export function resourceURL(name: string): string {
16
+ return "/resources/" + String(name).replace(/^\/+/, "");
17
+ }