gantry-web 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/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # gantry-web
2
+
3
+ The frontend half of [Gantry](https://github.com/B-Commissions/Gantry),
4
+ a Go framework for building native desktop apps with React interfaces.
5
+ This package provides the window chrome, the bridge to the native
6
+ window, the Tea runtime, the router, and the Vite plugin that powers
7
+ Gantry's paired-file convention.
8
+
9
+ You normally do not install this by hand - `gantry new` scaffolds an
10
+ app with it wired up. See the
11
+ [Gantry documentation](https://github.com/B-Commissions/Gantry/tree/main/docs)
12
+ or run `gantry docs` for the full story.
13
+
14
+ ## What it exports
15
+
16
+ From `gantry-web`:
17
+
18
+ - `TitleBar`, `DragStrip`, `ResizeFrame` - the custom window chrome
19
+ - `getShell` / `useShell` / `useShellCaps` - the typed native bridge
20
+ (close, minimize, maximize, drag, attention, always-on-top), with
21
+ feature detection so the same app runs in a plain browser tab
22
+ - `createApp` - boots a Gantry frontend (router, layouts, registry)
23
+ - `Link`, `ExternalLink`, `navigate`, `goBack`, `goForward`,
24
+ `useRoute`, `isActive` - navigation with active-state styling
25
+ - `usePaired` - the channel between a .tsx file and the .go file next
26
+ to it (send events, await calls, receive pushes)
27
+ - `useService`, `useCall`, `service` - awaited calls into Go services
28
+ - `useGoState` - useState whose value lives in Go, synced both ways
29
+ - `installZoomGuard` - suppress browser zoom gestures
30
+
31
+ From `gantry-web/tea`:
32
+
33
+ - `TeaView` - renders a page whose UI logic lives in Go
34
+ (Model/Update/View)
35
+ - `TeaComponentProps`, `setRegistry` - custom component integration
36
+
37
+ From `gantry-web/vite`:
38
+
39
+ - `gantry()` - the Vite plugin: discovers pages/, components/ and
40
+ layouts/ folders, auto-imports colocated css, injects pairing keys,
41
+ and proxies to the Go server during development
42
+
43
+ ## Peer dependencies
44
+
45
+ react and react-dom (18+), lucide-react for the chrome icons, and vite
46
+ (only when using the plugin). The package ships TypeScript source
47
+ directly; Vite consumes it as-is.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "gantry-web",
3
+ "version": "0.1.0",
4
+ "description": "Frontend half of the Gantry desktop app framework: window chrome, native bridge, Tea runtime, Vite plugin",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/B-Commissions/Gantry.git",
9
+ "directory": "web"
10
+ },
11
+ "homepage": "https://github.com/B-Commissions/Gantry",
12
+ "keywords": [
13
+ "gantry",
14
+ "desktop",
15
+ "webview",
16
+ "go",
17
+ "react",
18
+ "vite-plugin"
19
+ ],
20
+ "type": "module",
21
+ "exports": {
22
+ ".": "./src/index.ts",
23
+ "./tea": "./src/tea/index.ts",
24
+ "./vite": "./src/vite/index.js",
25
+ "./styles.css": "./src/styles.css",
26
+ "./types": "./types/index.d.ts"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "types"
31
+ ],
32
+ "peerDependencies": {
33
+ "lucide-react": "*",
34
+ "react": ">=18",
35
+ "react-dom": ">=18",
36
+ "vite": ">=5"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "vite": {
40
+ "optional": true
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,36 @@
1
+ import { useShell } from "./hooks";
2
+
3
+ export interface DragStripProps {
4
+ /** Height in px; must match the Go window's CaptionHeight (default 40). */
5
+ height?: number;
6
+ /** Dead zone on the left; match CaptionLeftReserve (default 8). */
7
+ leftInset?: number;
8
+ /** Dead zone on the right for the window buttons; match CaptionRightReserve (default 150). */
9
+ rightInset?: number;
10
+ prefix?: string;
11
+ }
12
+
13
+ /**
14
+ * DragStrip is the invisible band along the top of the window that
15
+ * starts a native drag on mousedown - the frameless window's "title
16
+ * bar surface". TitleBar renders one for you; use this directly only
17
+ * for fully custom chrome.
18
+ *
19
+ * Ignores double-clicks so a quick double-tap cannot trigger the OS
20
+ * caption double-click maximize.
21
+ */
22
+ export function DragStrip({ height = 40, leftInset = 8, rightInset = 150, prefix }: DragStripProps) {
23
+ const shell = useShell(prefix);
24
+ if (!shell.available) return null;
25
+ return (
26
+ <div
27
+ className="gantry-dragstrip"
28
+ style={{ height, left: leftInset, right: rightInset }}
29
+ onMouseDown={(e) => {
30
+ if (e.button !== 0 || e.detail > 1) return;
31
+ e.preventDefault();
32
+ shell.drag();
33
+ }}
34
+ />
35
+ );
36
+ }
@@ -0,0 +1,40 @@
1
+ import { useShell, useShellCaps } from "./hooks";
2
+
3
+ const EDGES = [
4
+ { edge: "n", style: { top: 0, left: 8, right: 8, height: 5, cursor: "ns-resize" } },
5
+ { edge: "s", style: { bottom: 0, left: 8, right: 8, height: 5, cursor: "ns-resize" } },
6
+ { edge: "w", style: { left: 0, top: 8, bottom: 8, width: 5, cursor: "ew-resize" } },
7
+ { edge: "e", style: { right: 0, top: 8, bottom: 8, width: 5, cursor: "ew-resize" } },
8
+ { edge: "nw", style: { top: 0, left: 0, width: 10, height: 10, cursor: "nwse-resize" } },
9
+ { edge: "ne", style: { top: 0, right: 0, width: 10, height: 10, cursor: "nesw-resize" } },
10
+ { edge: "sw", style: { bottom: 0, left: 0, width: 10, height: 10, cursor: "nesw-resize" } },
11
+ { edge: "se", style: { bottom: 0, right: 0, width: 10, height: 10, cursor: "nwse-resize" } },
12
+ ] as const;
13
+
14
+ /**
15
+ * ResizeFrame renders invisible edge strips that start a native
16
+ * interactive resize - needed on Linux, where a frameless window has
17
+ * no OS hit-test to re-implement edges (Windows handles this natively,
18
+ * so there the component renders nothing). createApp adds one
19
+ * automatically on frameless Linux windows.
20
+ */
21
+ export function ResizeFrame({ prefix }: { prefix?: string }) {
22
+ const shell = useShell(prefix);
23
+ const caps = useShellCaps(prefix);
24
+ if (caps?.platform !== "linux" || !caps.frameless) return null;
25
+ return (
26
+ <>
27
+ {EDGES.map(({ edge, style }) => (
28
+ <div
29
+ key={edge}
30
+ style={{ position: "fixed", zIndex: 40, ...style }}
31
+ onMouseDown={(e) => {
32
+ if (e.button !== 0) return;
33
+ e.preventDefault();
34
+ shell.resizeEdge(edge);
35
+ }}
36
+ />
37
+ ))}
38
+ </>
39
+ );
40
+ }
@@ -0,0 +1,137 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import { Minus, Square, Copy, X } from "lucide-react";
3
+ import { useShell, useShellCaps } from "./hooks";
4
+ import { DragStrip } from "./DragStrip";
5
+
6
+ export interface TitleBarProps {
7
+ /** Title content. Pointer-transparent so the bar stays draggable. */
8
+ title?: ReactNode;
9
+ /** Where the title sits: "center" (default), "left" or "right". */
10
+ titleAlign?: "left" | "center" | "right";
11
+ /** Extra content on the left (back/forward buttons, a menu, an icon). */
12
+ left?: ReactNode;
13
+ /** Extra content on the right, before the window buttons. */
14
+ right?: ReactNode;
15
+ /** Bar height in px; MUST match the Go window's CaptionHeight (default 40). */
16
+ height?: number;
17
+ /**
18
+ * Width reserved for the button cluster; MUST match the Go window's
19
+ * CaptionRightReserve (default 150). The Go side keeps this zone
20
+ * clickable instead of draggable.
21
+ */
22
+ rightReserve?: number;
23
+ /**
24
+ * Width of the clickable zone on the left; MUST match the Go window's
25
+ * CaptionLeftReserve when you put buttons in the left slot (default
26
+ * 8 - bump it, e.g. 90, so left-slot buttons receive clicks).
27
+ */
28
+ leftReserve?: number;
29
+ /** Override the Caps()-driven visibility per button. */
30
+ showMinimize?: boolean;
31
+ showMaximize?: boolean;
32
+ showClose?: boolean;
33
+ /** Tooltip for the close button, e.g. "Keeps running in the tray". */
34
+ closeHint?: string;
35
+ /** Replace the default close action (shell.close). */
36
+ onClose?: () => void;
37
+ prefix?: string;
38
+ className?: string;
39
+ }
40
+
41
+ /**
42
+ * TitleBar draws the custom window chrome: a drag strip, a title
43
+ * (left, center or right), your own controls in the left/right slots,
44
+ * and the window buttons. Which window buttons appear comes from the
45
+ * Go window's Caps() - configure buttons once, in Go. In a plain
46
+ * browser the window buttons hide themselves.
47
+ *
48
+ * Apps customize it app-wide from app.tsx:
49
+ *
50
+ * export default {
51
+ * title: "Myapp",
52
+ * titleBar: { titleAlign: "left", left: <NavButtons />, leftReserve: 90 },
53
+ * } satisfies CreateAppOptions;
54
+ */
55
+ export function TitleBar(props: TitleBarProps) {
56
+ const {
57
+ title,
58
+ titleAlign = "center",
59
+ left,
60
+ right,
61
+ height = 40,
62
+ rightReserve = 150,
63
+ leftReserve = 8,
64
+ closeHint,
65
+ onClose,
66
+ prefix,
67
+ className,
68
+ } = props;
69
+ const shell = useShell(prefix);
70
+ const caps = useShellCaps(prefix);
71
+ const [maximized, setMaximized] = useState(false);
72
+
73
+ const showMin = props.showMinimize ?? caps?.minimize ?? false;
74
+ const showMax = props.showMaximize ?? caps?.maximize ?? false;
75
+ const showClose = props.showClose ?? caps?.close ?? false;
76
+
77
+ useEffect(() => {
78
+ if (showMax) void shell.isMaximized().then(setMaximized);
79
+ }, [shell, showMax]);
80
+
81
+ const titleEl =
82
+ title != null ? <div className="gantry-titlebar-text">{title}</div> : null;
83
+
84
+ return (
85
+ <div className={"gantry-titlebar " + (className ?? "")} style={{ height }}>
86
+ <DragStrip height={height} leftInset={leftReserve} rightInset={rightReserve} prefix={prefix} />
87
+ <div className="gantry-titlebar-left">
88
+ {left}
89
+ {titleAlign === "left" && titleEl}
90
+ </div>
91
+ {titleAlign === "center" && title != null && (
92
+ <div className="gantry-titlebar-title" style={{ lineHeight: height + "px" }}>
93
+ {title}
94
+ </div>
95
+ )}
96
+ <div className="gantry-titlebar-end">
97
+ {titleAlign === "right" && titleEl}
98
+ {right != null && <div className="gantry-titlebar-right">{right}</div>}
99
+ {showMin && (
100
+ <button
101
+ type="button"
102
+ className="gantry-winbtn"
103
+ aria-label="Minimize"
104
+ onClick={() => shell.minimize()}
105
+ >
106
+ <Minus size={16} />
107
+ </button>
108
+ )}
109
+ {showMax && (
110
+ <button
111
+ type="button"
112
+ className="gantry-winbtn"
113
+ aria-label={maximized ? "Restore" : "Maximize"}
114
+ onClick={() => {
115
+ if (maximized) shell.restore();
116
+ else shell.maximize();
117
+ setMaximized(!maximized);
118
+ }}
119
+ >
120
+ {maximized ? <Copy size={14} /> : <Square size={14} />}
121
+ </button>
122
+ )}
123
+ {showClose && (
124
+ <button
125
+ type="button"
126
+ className="gantry-winbtn gantry-winbtn-close"
127
+ aria-label="Close"
128
+ title={closeHint}
129
+ onClick={() => (onClose ? onClose() : shell.close())}
130
+ >
131
+ <X size={16} />
132
+ </button>
133
+ )}
134
+ </div>
135
+ </div>
136
+ );
137
+ }
package/src/app.tsx ADDED
@@ -0,0 +1,155 @@
1
+ /// <reference path="../types/index.d.ts" />
2
+ import { StrictMode, useEffect, type FC, type ReactNode } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { pages, components as pairedComponents, layouts, appConfig, type GantryPage } from "virtual:gantry-app";
5
+ import { TitleBar, type TitleBarProps } from "./TitleBar";
6
+ import { ResizeFrame } from "./ResizeFrame";
7
+ import { installZoomGuard } from "./zoom";
8
+ import { connect, ready } from "./socket";
9
+ import { useRoute, redirect } from "./router";
10
+ import { setRegistry, type ComponentRegistry, type TeaComponentProps } from "./tea/Runtime";
11
+ import "./styles.css";
12
+
13
+ export interface CreateAppOptions {
14
+ /** Title shown in the TitleBar (default: none). */
15
+ title?: ReactNode;
16
+ /**
17
+ * TitleBar customization: title placement (titleAlign), your own
18
+ * controls in the left/right slots (back/forward buttons, menus),
19
+ * heights and reserves. The usual home for this is the optional
20
+ * app.tsx at the app root, default-exporting CreateAppOptions.
21
+ */
22
+ titleBar?: Partial<TitleBarProps>;
23
+ /** Extra Tea components beyond the paired components/ folders. */
24
+ components?: ComponentRegistry;
25
+ /** Binding prefix when the Go side changed it from "gantry". */
26
+ prefix?: string;
27
+ /** Websocket URL override (default ws://<host>/gantry/ws). */
28
+ socketURL?: string;
29
+ /** Hide the TitleBar on every page (pages can also export chrome = false). */
30
+ chrome?: boolean;
31
+ }
32
+
33
+ function keyClass(key: string): string {
34
+ return "gantry-" + key.replace(/[^a-zA-Z0-9]+/g, "-");
35
+ }
36
+
37
+ function routeOf(p: GantryPage): string {
38
+ return p.mod.route ?? p.route;
39
+ }
40
+
41
+ function match(path: string): GantryPage | undefined {
42
+ const clean = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
43
+ return pages.find((p) => routeOf(p) === clean) ?? pages.find((p) => routeOf(p) === "/");
44
+ }
45
+
46
+ function PageHost({ page }: { page: GantryPage }) {
47
+ useEffect(() => {
48
+ ready(page.key);
49
+ }, [page.key]);
50
+ const Page = page.mod.default;
51
+ return (
52
+ <div className={"gantry-page " + keyClass(page.key)}>
53
+ <Page />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ // layoutsFor resolves a page's layout selection to component functions,
59
+ // outermost first.
60
+ //
61
+ // (nothing) -> "main" if it exists (chromeless pages skip)
62
+ // layout = false -> none
63
+ // layout = "compact" -> that one
64
+ // layout = ["main","compact"] -> nested: <Main><Compact><Page/>...
65
+ function layoutsFor(page: GantryPage, chrome: boolean): FC<{ children?: ReactNode }>[] {
66
+ const sel = page.mod.layout;
67
+ let names: string[];
68
+ if (sel === false) {
69
+ names = [];
70
+ } else if (typeof sel === "string") {
71
+ names = [sel];
72
+ } else if (Array.isArray(sel)) {
73
+ names = sel;
74
+ } else if (sel === true) {
75
+ names = layouts.main ? ["main"] : [];
76
+ } else {
77
+ // Default: the "main" layout on normal pages; chromeless pages
78
+ // (widgets, popups) are their own surfaces and skip it.
79
+ names = chrome && layouts.main ? ["main"] : [];
80
+ }
81
+ const out: FC<{ children?: ReactNode }>[] = [];
82
+ for (const n of names) {
83
+ const mod = layouts[n];
84
+ if (mod?.default) {
85
+ out.push(mod.default);
86
+ } else {
87
+ console.warn(`gantry: page ${page.key} wants unknown layout "${n}" (have: ${Object.keys(layouts).join(", ") || "none"})`);
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function AppRoot({ options }: { options: CreateAppOptions }) {
94
+ const path = useRoute();
95
+ const page = match(path);
96
+ // A URL whose page no longer exists (deleted during dev, stale
97
+ // bookmark) falls back to the index page - normalize the address so
98
+ // Links light up correctly.
99
+ const target = page ? routeOf(page) : null;
100
+ const clean = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
101
+ useEffect(() => {
102
+ if (target !== null && target !== clean) {
103
+ redirect(target);
104
+ }
105
+ }, [target, clean]);
106
+ if (!page) {
107
+ return <div className="gantry-app">No pages found - add pages/index/index.tsx</div>;
108
+ }
109
+ const chrome = options.chrome !== false && page.mod.chrome !== false;
110
+ let content = <PageHost page={page} />;
111
+ for (const Layout of layoutsFor(page, chrome).reverse()) {
112
+ content = <Layout>{content}</Layout>;
113
+ }
114
+ return (
115
+ <div className="gantry-app">
116
+ <ResizeFrame prefix={options.prefix} />
117
+ {chrome && <TitleBar title={options.title} prefix={options.prefix} {...options.titleBar} />}
118
+ {content}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ /**
124
+ * createApp boots a Gantry frontend: zoom guard, websocket, component
125
+ * registry, TitleBar, the root layout (layout.tsx at the app root, if
126
+ * present), and the page router (single-page mode when only pages/index
127
+ * exists). The synthesized .gantry/main.tsx calls this; an app only
128
+ * touches it to pass options.
129
+ */
130
+ export function createApp(options: CreateAppOptions = {}): void {
131
+ // The optional root app.tsx wins over the synthesized defaults, so
132
+ // apps customize everything without owning the entry file.
133
+ if (appConfig?.default) {
134
+ options = { ...options, ...appConfig.default };
135
+ }
136
+ installZoomGuard();
137
+ connect(options.socketURL);
138
+
139
+ const registry: ComponentRegistry = {};
140
+ for (const [key, mod] of Object.entries(pairedComponents)) {
141
+ if (typeof mod.default === "function") {
142
+ registry[key] = mod.default as FC<TeaComponentProps>;
143
+ }
144
+ }
145
+ Object.assign(registry, options.components);
146
+ setRegistry(registry);
147
+
148
+ const container = document.getElementById("root");
149
+ if (!container) throw new Error("gantry: no #root element");
150
+ createRoot(container).render(
151
+ <StrictMode>
152
+ <AppRoot options={options} />
153
+ </StrictMode>,
154
+ );
155
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,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
+ * 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
+ }
package/src/gostate.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ import { connect, getGoState, hasGoState, onGoState, setGoState } from "./socket";
3
+
4
+ /**
5
+ * useGoState is useState whose value lives in Go. Declare the variable
6
+ * on the Go side once:
7
+ *
8
+ * volume := ui.NewState(app, "volume", 0.5)
9
+ *
10
+ * then use it anywhere in React exactly like useState:
11
+ *
12
+ * const [volume, setVolume] = useGoState("volume", 0.5);
13
+ *
14
+ * Every component using the same key shares the value. setVolume
15
+ * updates locally at once and writes through to Go; Go-side Set()
16
+ * calls re-render every subscriber instantly. Until the first sync
17
+ * arrives the hook returns `fallback`.
18
+ */
19
+ export function useGoState<T>(key: string, fallback: T): [T, (v: T) => void] {
20
+ const subscribe = useCallback((fn: () => void) => {
21
+ connect();
22
+ return onGoState(key, fn);
23
+ }, [key]);
24
+ const value = useSyncExternalStore(subscribe, () =>
25
+ hasGoState(key) ? (getGoState(key) as T) : fallback,
26
+ );
27
+ const set = useCallback((v: T) => setGoState(key, v), [key]);
28
+ return [value, set];
29
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { getShell, type ShellBridge, type ShellCaps } from "./bridge";
3
+
4
+ /** useShell memoizes the native bridge for components. */
5
+ export function useShell(prefix = "gantry"): ShellBridge {
6
+ return useMemo(() => getShell(prefix), [prefix]);
7
+ }
8
+
9
+ /**
10
+ * useShellCaps resolves which window buttons the Go window enabled.
11
+ * Returns null while resolving (render nothing yet to avoid a button
12
+ * flicker), then the caps - all false in a plain browser.
13
+ */
14
+ export function useShellCaps(prefix = "gantry"): ShellCaps | null {
15
+ const shell = useShell(prefix);
16
+ const [caps, setCaps] = useState<ShellCaps | null>(null);
17
+ useEffect(() => {
18
+ let alive = true;
19
+ if (!shell.available) {
20
+ setCaps({ minimize: false, maximize: false, close: false, alwaysOnTop: false });
21
+ return;
22
+ }
23
+ void shell.caps().then((c) => {
24
+ if (alive) setCaps(c);
25
+ });
26
+ return () => {
27
+ alive = false;
28
+ };
29
+ }, [shell]);
30
+ return caps;
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
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 } 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 { TeaView } from "./tea/Runtime";
20
+ export type { TeaComponentProps, ComponentRegistry } from "./tea/Runtime";