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 +47 -0
- package/package.json +43 -0
- package/src/DragStrip.tsx +36 -0
- package/src/ResizeFrame.tsx +40 -0
- package/src/TitleBar.tsx +137 -0
- package/src/app.tsx +155 -0
- package/src/bridge.ts +94 -0
- package/src/gostate.ts +29 -0
- package/src/hooks.ts +31 -0
- package/src/index.ts +20 -0
- package/src/paired.ts +57 -0
- package/src/router.tsx +154 -0
- package/src/service.ts +82 -0
- package/src/socket.ts +187 -0
- package/src/styles.css +309 -0
- package/src/tea/Runtime.tsx +195 -0
- package/src/tea/index.ts +5 -0
- package/src/vite/index.js +203 -0
- package/src/zoom.ts +21 -0
- package/types/index.d.ts +36 -0
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
|
+
}
|
package/src/TitleBar.tsx
ADDED
|
@@ -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";
|