@briancray/belte 0.3.1 → 0.5.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/bin/belte.ts +22 -13
- package/package.json +1 -1
- package/src/appEntry.ts +24 -8
- package/src/buildDisconnected.ts +3 -0
- package/src/bundleApp.ts +24 -2
- package/src/controlServerWorker.ts +205 -7
- package/src/discoveryEntry.ts +58 -11
- package/src/lib/browser/cache.ts +29 -6
- package/src/lib/browser/startClient.ts +24 -1
- package/src/lib/bundle/BundleWindow.ts +11 -0
- package/src/lib/bundle/disconnected.svelte +238 -42
- package/src/lib/bundle/onMenu.ts +20 -5
- package/src/lib/bundle/openWebview.ts +9 -2
- package/src/lib/bundle/signMacApp.ts +35 -0
- package/src/lib/cli/createClient.ts +65 -27
- package/src/lib/cli/loadEnvFromBinaryDir.ts +8 -38
- package/src/lib/cli/runCli.ts +37 -15
- package/src/lib/cli/types/CliManifestEntry.ts +7 -2
- package/src/lib/mcp/annotationsForMethod.ts +29 -0
- package/src/lib/mcp/createMcpServer.ts +10 -8
- package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
- package/src/lib/mcp/toolResultFromResponse.ts +66 -0
- package/src/lib/server/jsonl.ts +2 -1
- package/src/lib/server/rpc/defineVerb.ts +30 -17
- package/src/lib/server/rpc/parseArgs.ts +2 -1
- package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
- package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
- package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
- package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
- package/src/lib/server/runtime/createServer.ts +57 -21
- package/src/lib/server/runtime/globToPathSet.ts +29 -0
- package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
- package/src/lib/server/runtime/parsePort.ts +16 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
- package/src/lib/server/sockets/defineSocket.ts +7 -1
- package/src/lib/server/sockets/recentHistory.ts +11 -0
- package/src/lib/server/sockets/socketOperations.ts +35 -0
- package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
- package/src/lib/server/sse.ts +2 -1
- package/src/lib/shared/appDataDir.ts +22 -0
- package/src/lib/shared/buildRpcRequest.ts +2 -1
- package/src/lib/shared/carriesBodyArgs.ts +13 -0
- package/src/lib/shared/isReadOnlyMethod.ts +14 -0
- package/src/lib/shared/isStreamingResponse.ts +11 -0
- package/src/lib/shared/jsonlErrorFrame.ts +24 -0
- package/src/lib/shared/keyForRemoteCall.ts +2 -1
- package/src/lib/shared/loadEnvFile.ts +17 -0
- package/src/lib/shared/loadEnvFromDataDir.ts +15 -0
- package/src/lib/shared/parseEnv.ts +30 -0
- package/src/lib/shared/readEnvFile.ts +15 -0
- package/src/lib/shared/resolveClientFlags.ts +8 -6
- package/src/lib/shared/responseErrorText.ts +9 -0
- package/src/lib/shared/serializeEnv.ts +18 -0
- package/src/lib/shared/sseErrorFrame.ts +29 -0
- package/src/lib/shared/streamResponse.ts +168 -0
- package/src/lib/shared/subscribableFromResponse.ts +1 -172
- package/src/lib/shared/types/CacheEntry.ts +6 -0
- package/src/serverEntry.ts +12 -0
- package/template/src/bundle/icon.png +0 -0
- package/template/src/server/rpc/getHello.ts +5 -3
- package/src/lib/shared/belteImportName.test.ts +0 -58
|
@@ -25,10 +25,11 @@ pass finds the data via cache() without issuing a network round-trip.
|
|
|
25
25
|
*/
|
|
26
26
|
function hydrateCacheFromSnapshot(store: CacheStore, snapshot: CacheSnapshotEntry[]): void {
|
|
27
27
|
for (const entry of snapshot) {
|
|
28
|
+
const headers = new Headers(entry.headers)
|
|
28
29
|
const response = new Response(entry.body, {
|
|
29
30
|
status: entry.status,
|
|
30
31
|
statusText: entry.statusText,
|
|
31
|
-
headers
|
|
32
|
+
headers,
|
|
32
33
|
})
|
|
33
34
|
store.entries.set(entry.key, {
|
|
34
35
|
key: entry.key,
|
|
@@ -36,10 +37,32 @@ function hydrateCacheFromSnapshot(store: CacheStore, snapshot: CacheSnapshotEntr
|
|
|
36
37
|
request: new Request(entry.url, { method: entry.method }),
|
|
37
38
|
ttl: undefined,
|
|
38
39
|
expiresAt: undefined,
|
|
40
|
+
value: warmValueFromSnapshot(entry.status, headers, entry.body),
|
|
39
41
|
})
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/*
|
|
46
|
+
Synchronously decodes a snapshot body so the warm entry reads without a
|
|
47
|
+
microtask hop on first render. Mirrors decodeResponse for the textual cases
|
|
48
|
+
the snapshot ships; non-2xx and 204 yield no warm value and fall back to the
|
|
49
|
+
async path, which throws HttpError / returns undefined exactly as a live call
|
|
50
|
+
would. Binary/xml bodies also skip the warm path and decode asynchronously.
|
|
51
|
+
*/
|
|
52
|
+
function warmValueFromSnapshot(status: number, headers: Headers, body: string): unknown {
|
|
53
|
+
if (status === 204 || status < 200 || status >= 300) {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
const contentType = (headers.get('content-type') ?? '').toLowerCase()
|
|
57
|
+
if (contentType.includes('json')) {
|
|
58
|
+
return JSON.parse(body)
|
|
59
|
+
}
|
|
60
|
+
if (contentType.startsWith('text/')) {
|
|
61
|
+
return body
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
function isInternalLinkEvent(event: MouseEvent): HTMLAnchorElement | undefined {
|
|
44
67
|
if (event.defaultPrevented) {
|
|
45
68
|
return undefined
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '../server/rpc/types/StandardSchemaV1.ts'
|
|
1
2
|
import type { BundleMenu } from './BundleMenu.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
@@ -17,4 +18,14 @@ export type BundleWindow = {
|
|
|
17
18
|
width?: number
|
|
18
19
|
height?: number
|
|
19
20
|
menu?: BundleMenu[]
|
|
21
|
+
/*
|
|
22
|
+
Config the embedded server needs before it can run, as a Standard Schema (the
|
|
23
|
+
same kind belte accepts for RPC/MCP). Its JSON Schema drives the connect
|
|
24
|
+
screen's first-run form, shown as a modal when Start is clicked with a required
|
|
25
|
+
key still unset; the user's answers persist to the data-dir `.env` the server
|
|
26
|
+
loads at boot. Each property maps to one env var of the same name; `title` is
|
|
27
|
+
the field label, `description` the hint, `format: 'password'` masks the input,
|
|
28
|
+
and `default` pre-fills it.
|
|
29
|
+
*/
|
|
30
|
+
config?: StandardSchemaV1
|
|
20
31
|
}
|
|
@@ -11,20 +11,9 @@ connection so the native File menu's enabled state stays authoritative.
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
port each launch — so we persist the intent and re-run start() instead.
|
|
18
|
-
Disconnect clears it so a relaunch never auto-retries a forgotten server.
|
|
19
|
-
*/
|
|
20
|
-
const STORAGE_KEY = 'belte:server-url'
|
|
21
|
-
const START_EMBEDDED = 'belte:start-embedded'
|
|
22
|
-
|
|
23
|
-
/*
|
|
24
|
-
The last remote URL that successfully connected, kept separate from STORAGE_KEY
|
|
25
|
-
so it survives disconnect (and the app quitting): it only prefills the form, it
|
|
26
|
-
never drives an auto-reconnect, so reconnecting to the same server stays one
|
|
27
|
-
click away even after an explicit disconnect.
|
|
14
|
+
The last remote URL that successfully connected — only prefills the form's input
|
|
15
|
+
on a later visit; it never drives a reconnect (the launcher owns auto-resume now,
|
|
16
|
+
deciding before the window even opens). Survives disconnect and quitting.
|
|
28
17
|
*/
|
|
29
18
|
const LAST_URL_KEY = 'belte:last-server-url'
|
|
30
19
|
|
|
@@ -36,9 +25,51 @@ const placeholder = 'https://example.com'
|
|
|
36
25
|
|
|
37
26
|
// Prefill the form with the last server we connected to, from any prior launch.
|
|
38
27
|
let url = $state(localStorage.getItem(LAST_URL_KEY) ?? '')
|
|
39
|
-
let starting = $state(false)
|
|
40
28
|
let error = $state<string | undefined>(undefined)
|
|
41
29
|
|
|
30
|
+
/*
|
|
31
|
+
`?action=` set by the File menu (Start/Disconnect) or the launcher when a live
|
|
32
|
+
connection dies (`lost`). Auto-resume of a saved connection now happens in the
|
|
33
|
+
launcher before the window opens, so this screen only ever loads as a real
|
|
34
|
+
destination — there's no no-action auto-resume left to handle here.
|
|
35
|
+
*/
|
|
36
|
+
const launchAction = new URLSearchParams(location.search).get('action')
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
Two phases so the screen never flashes before a redirect. A menu Start may boot
|
|
40
|
+
straight through, so it opens on a neutral splash; every other entry is a genuine
|
|
41
|
+
destination, so the connect screen shows immediately. Boot/connect re-enter the
|
|
42
|
+
splash so a redirect (including after saving config) never flashes the form.
|
|
43
|
+
*/
|
|
44
|
+
let phase = $state<'splash' | 'connect'>(launchAction === 'start' ? 'splash' : 'connect')
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
First-run config form, surfaced as a modal only when Start is clicked (or
|
|
48
|
+
auto-start fires) with a required key still unset. Fields are derived from the
|
|
49
|
+
app's config JSON Schema served by the launcher; answers post back to the
|
|
50
|
+
data-dir `.env` the embedded server loads at boot.
|
|
51
|
+
*/
|
|
52
|
+
type ConfigField = {
|
|
53
|
+
key: string
|
|
54
|
+
label: string
|
|
55
|
+
description?: string
|
|
56
|
+
inputType: 'text' | 'password' | 'number' | 'checkbox'
|
|
57
|
+
required: boolean
|
|
58
|
+
}
|
|
59
|
+
let configFields = $state<ConfigField[]>([])
|
|
60
|
+
let configValues = $state<Record<string, string>>({})
|
|
61
|
+
let showConfig = $state(false)
|
|
62
|
+
let savingConfig = $state(false)
|
|
63
|
+
// Every required field has a value — gates the modal's Save button.
|
|
64
|
+
const canSaveConfig = $derived(
|
|
65
|
+
configFields.every(
|
|
66
|
+
(field) =>
|
|
67
|
+
!field.required ||
|
|
68
|
+
field.inputType === 'checkbox' ||
|
|
69
|
+
(configValues[field.key] ?? '').trim() !== '',
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
42
73
|
/*
|
|
43
74
|
Interpret the boot intent once on load. `?action=` is set by the native File
|
|
44
75
|
menu's navigate items (or the launcher when a live connection dies); absent it, a
|
|
@@ -51,30 +82,19 @@ remembered server reconnects automatically:
|
|
|
51
82
|
- (none) → reconnect to the saved server if there is one.
|
|
52
83
|
*/
|
|
53
84
|
$effect(() => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (action === 'start') {
|
|
85
|
+
if (launchAction === 'start') {
|
|
86
|
+
// File-menu Start Server is an explicit click → run the Start flow.
|
|
57
87
|
void start()
|
|
58
88
|
return
|
|
59
89
|
}
|
|
60
|
-
if (
|
|
90
|
+
if (launchAction === 'lost') {
|
|
61
91
|
error = 'The server stopped responding.'
|
|
62
92
|
return
|
|
63
93
|
}
|
|
64
|
-
if (
|
|
65
|
-
//
|
|
66
|
-
// stays
|
|
67
|
-
localStorage.removeItem(STORAGE_KEY)
|
|
94
|
+
if (launchAction === 'disconnect') {
|
|
95
|
+
// Have the launcher forget the auto-resume choice and reap any embedded
|
|
96
|
+
// server; LAST_URL_KEY stays so the form is still prefilled to reconnect.
|
|
68
97
|
void fetch('/__belte/disconnect').catch(() => {})
|
|
69
|
-
return
|
|
70
|
-
}
|
|
71
|
-
// No action: repeat the last choice — re-boot the embedded server, or reconnect.
|
|
72
|
-
if (saved === START_EMBEDDED) {
|
|
73
|
-
void start()
|
|
74
|
-
return
|
|
75
|
-
}
|
|
76
|
-
if (saved) {
|
|
77
|
-
void connect(saved)
|
|
78
98
|
}
|
|
79
99
|
})
|
|
80
100
|
|
|
@@ -92,6 +112,8 @@ async function connect(target: string = url.trim()): Promise<void> {
|
|
|
92
112
|
return
|
|
93
113
|
}
|
|
94
114
|
error = undefined
|
|
115
|
+
// Hide the form while connecting so a successful redirect doesn't flash it.
|
|
116
|
+
phase = 'splash'
|
|
95
117
|
try {
|
|
96
118
|
const response = await fetch('/connect', {
|
|
97
119
|
method: 'POST',
|
|
@@ -103,21 +125,44 @@ async function connect(target: string = url.trim()): Promise<void> {
|
|
|
103
125
|
throw new Error(body.error ?? `connect failed (${response.status})`)
|
|
104
126
|
}
|
|
105
127
|
const { redirect } = (await response.json()) as { redirect: string }
|
|
106
|
-
|
|
107
|
-
//
|
|
128
|
+
// Prefill the form with this server on a later visit (the launcher records
|
|
129
|
+
// the auto-resume choice itself, on the /connect it just handled).
|
|
108
130
|
localStorage.setItem(LAST_URL_KEY, cleaned)
|
|
109
131
|
location.href = redirect
|
|
110
132
|
} catch (cause) {
|
|
111
133
|
error = `Could not connect: ${String(cause)}`
|
|
134
|
+
// Failed — bring the form back so the error and a retry are visible.
|
|
135
|
+
phase = 'connect'
|
|
112
136
|
}
|
|
113
137
|
}
|
|
114
138
|
|
|
115
|
-
|
|
139
|
+
/*
|
|
140
|
+
Start, always an explicit click (button or File-menu) — auto-resume happens in
|
|
141
|
+
the launcher before the window opens, so this is never a launch path. Asks the
|
|
142
|
+
launcher what config the app needs: if it declares any, open the modal (prefilled
|
|
143
|
+
with the last-used values) so the user can review or change settings before
|
|
144
|
+
booting — re-running Start after a disconnect is how you reconfigure. With no
|
|
145
|
+
config schema, boot straight through. The modal's save path resumes the boot.
|
|
146
|
+
*/
|
|
116
147
|
async function start(): Promise<void> {
|
|
117
148
|
error = undefined
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
149
|
+
const config = await loadConfig().catch(() => undefined)
|
|
150
|
+
if (config) {
|
|
151
|
+
configFields = config.fields
|
|
152
|
+
configValues = { ...config.values }
|
|
153
|
+
// Reveal the connect screen as the modal's backdrop.
|
|
154
|
+
phase = 'connect'
|
|
155
|
+
showConfig = true
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
await boot()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Boot the embedded server via the launcher, then follow it once it answers.
|
|
162
|
+
async function boot(): Promise<void> {
|
|
163
|
+
// Splash while booting so the connect screen doesn't flash before the redirect
|
|
164
|
+
// (including straight after saving config).
|
|
165
|
+
phase = 'splash'
|
|
121
166
|
try {
|
|
122
167
|
const response = await fetch('/start', { method: 'POST' })
|
|
123
168
|
if (!response.ok) {
|
|
@@ -128,11 +173,92 @@ async function start(): Promise<void> {
|
|
|
128
173
|
location.href = redirect
|
|
129
174
|
} catch (cause) {
|
|
130
175
|
error = `Could not start the server: ${String(cause)}`
|
|
131
|
-
|
|
176
|
+
// Boot failed — bring the connect screen back to show the error.
|
|
177
|
+
phase = 'connect'
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/*
|
|
182
|
+
Fetches the app's config schema + resolved current values from the launcher and
|
|
183
|
+
turns the JSON Schema into render-ready fields. Returns undefined when no schema
|
|
184
|
+
is declared, so Start never gates.
|
|
185
|
+
*/
|
|
186
|
+
async function loadConfig(): Promise<
|
|
187
|
+
{ fields: ConfigField[]; values: Record<string, string> } | undefined
|
|
188
|
+
> {
|
|
189
|
+
const response = await fetch('/__belte/config')
|
|
190
|
+
const { schema, values } = (await response.json()) as {
|
|
191
|
+
schema: Record<string, unknown> | null
|
|
192
|
+
values: Record<string, string>
|
|
193
|
+
}
|
|
194
|
+
if (!schema) {
|
|
195
|
+
return undefined
|
|
196
|
+
}
|
|
197
|
+
return { fields: fieldsFromSchema(schema), values: values ?? {} }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Derives one render-ready field per JSON Schema property, reusing the standard
|
|
201
|
+
// slots: `title` → label, `description` → hint, `format`/`type` → input kind.
|
|
202
|
+
function fieldsFromSchema(schema: Record<string, unknown>): ConfigField[] {
|
|
203
|
+
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>
|
|
204
|
+
const required = new Set((schema.required as string[]) ?? [])
|
|
205
|
+
return Object.entries(properties).map(([key, property]) => ({
|
|
206
|
+
key,
|
|
207
|
+
label: (property.title as string) ?? key,
|
|
208
|
+
description: property.description as string | undefined,
|
|
209
|
+
inputType: inputType(property),
|
|
210
|
+
required: required.has(key),
|
|
211
|
+
}))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Maps a JSON Schema property to an HTML input kind (directory falls back to text
|
|
215
|
+
// until a native picker exists).
|
|
216
|
+
function inputType(property: Record<string, unknown>): ConfigField['inputType'] {
|
|
217
|
+
if (property.type === 'boolean') {
|
|
218
|
+
return 'checkbox'
|
|
219
|
+
}
|
|
220
|
+
if (property.type === 'number' || property.type === 'integer') {
|
|
221
|
+
return 'number'
|
|
222
|
+
}
|
|
223
|
+
if (property.format === 'password') {
|
|
224
|
+
return 'password'
|
|
225
|
+
}
|
|
226
|
+
return 'text'
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Persist the form's answers to the data-dir `.env`, then resume the boot.
|
|
230
|
+
async function saveConfig(): Promise<void> {
|
|
231
|
+
error = undefined
|
|
232
|
+
savingConfig = true
|
|
233
|
+
try {
|
|
234
|
+
const response = await fetch('/__belte/config', {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'content-type': 'application/json' },
|
|
237
|
+
body: JSON.stringify({ values: configValues }),
|
|
238
|
+
})
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
throw new Error(`save failed (${response.status})`)
|
|
241
|
+
}
|
|
242
|
+
showConfig = false
|
|
243
|
+
savingConfig = false
|
|
244
|
+
await boot()
|
|
245
|
+
} catch (cause) {
|
|
246
|
+
error = `Could not save settings: ${String(cause)}`
|
|
247
|
+
savingConfig = false
|
|
132
248
|
}
|
|
133
249
|
}
|
|
134
250
|
</script>
|
|
135
251
|
|
|
252
|
+
{#if phase === 'splash'}
|
|
253
|
+
<!-- Neutral splash shown while an auto-start/auto-reconnect resolves, so the
|
|
254
|
+
connect screen never flashes before it redirects. Same background as the card. -->
|
|
255
|
+
<div
|
|
256
|
+
class="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-950">
|
|
257
|
+
{#if logo}
|
|
258
|
+
<img src={logo} alt="" class="h-16 w-16 rounded-xl object-contain opacity-90">
|
|
259
|
+
{/if}
|
|
260
|
+
</div>
|
|
261
|
+
{:else}
|
|
136
262
|
<main
|
|
137
263
|
class="flex min-h-screen items-center justify-center bg-gray-50 p-6 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
|
|
138
264
|
<div
|
|
@@ -170,9 +296,8 @@ async function start(): Promise<void> {
|
|
|
170
296
|
<button
|
|
171
297
|
type="button"
|
|
172
298
|
onclick={() => void start()}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
{starting ? 'Starting…' : 'Start server'}
|
|
299
|
+
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
|
|
300
|
+
Start server
|
|
176
301
|
</button>
|
|
177
302
|
|
|
178
303
|
{#if error}
|
|
@@ -189,3 +314,74 @@ async function start(): Promise<void> {
|
|
|
189
314
|
</p>
|
|
190
315
|
</div>
|
|
191
316
|
</main>
|
|
317
|
+
{/if}
|
|
318
|
+
|
|
319
|
+
{#if showConfig}
|
|
320
|
+
<!-- First-run config modal — shown only when Start needs settings the app lacks. -->
|
|
321
|
+
<div
|
|
322
|
+
class="fixed inset-0 z-10 flex items-center justify-center bg-black/40 p-6 text-gray-900 dark:text-gray-100">
|
|
323
|
+
<div
|
|
324
|
+
class="w-full max-w-sm rounded-2xl bg-white p-8 shadow-lg ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
|
|
325
|
+
<h2 class="mb-5 text-lg font-semibold tracking-tight">Set up {heading}</h2>
|
|
326
|
+
|
|
327
|
+
<form
|
|
328
|
+
class="flex flex-col gap-4"
|
|
329
|
+
onsubmit={(event) => {
|
|
330
|
+
event.preventDefault()
|
|
331
|
+
void saveConfig()
|
|
332
|
+
}}>
|
|
333
|
+
{#each configFields as field (field.key)}
|
|
334
|
+
<label class="flex flex-col gap-1 text-sm">
|
|
335
|
+
<span class="font-medium">
|
|
336
|
+
{field.label}
|
|
337
|
+
{#if field.required}
|
|
338
|
+
<span class="text-red-500">*</span>
|
|
339
|
+
{/if}
|
|
340
|
+
</span>
|
|
341
|
+
{#if field.inputType === 'checkbox'}
|
|
342
|
+
<input
|
|
343
|
+
type="checkbox"
|
|
344
|
+
checked={configValues[field.key] === 'true'}
|
|
345
|
+
onchange={(event) =>
|
|
346
|
+
(configValues[field.key] = event.currentTarget.checked
|
|
347
|
+
? 'true'
|
|
348
|
+
: 'false')}
|
|
349
|
+
class="mt-1 size-4 self-start rounded border-gray-300 dark:border-gray-700">
|
|
350
|
+
{:else}
|
|
351
|
+
<input
|
|
352
|
+
type={field.inputType}
|
|
353
|
+
value={configValues[field.key] ?? ''}
|
|
354
|
+
oninput={(event) =>
|
|
355
|
+
(configValues[field.key] = event.currentTarget.value)}
|
|
356
|
+
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900 focus:ring-1 focus:ring-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:focus:border-gray-100 dark:focus:ring-gray-100">
|
|
357
|
+
{/if}
|
|
358
|
+
{#if field.description}
|
|
359
|
+
<span class="text-xs text-gray-400 dark:text-gray-500">
|
|
360
|
+
{field.description}
|
|
361
|
+
</span>
|
|
362
|
+
{/if}
|
|
363
|
+
</label>
|
|
364
|
+
{/each}
|
|
365
|
+
|
|
366
|
+
<div class="mt-1 flex gap-3">
|
|
367
|
+
<button
|
|
368
|
+
type="button"
|
|
369
|
+
onclick={() => (showConfig = false)}
|
|
370
|
+
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
|
|
371
|
+
Cancel
|
|
372
|
+
</button>
|
|
373
|
+
<button
|
|
374
|
+
type="submit"
|
|
375
|
+
disabled={!canSaveConfig || savingConfig}
|
|
376
|
+
class="flex-1 rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300">
|
|
377
|
+
{savingConfig ? 'Saving…' : 'Save & start'}
|
|
378
|
+
</button>
|
|
379
|
+
</div>
|
|
380
|
+
</form>
|
|
381
|
+
|
|
382
|
+
{#if error}
|
|
383
|
+
<p class="mt-4 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
384
|
+
{/if}
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
{/if}
|
package/src/lib/bundle/onMenu.ts
CHANGED
|
@@ -1,25 +1,40 @@
|
|
|
1
1
|
/*
|
|
2
2
|
Subscribes to bundle menu clicks. Each custom menu item declared in the bundle
|
|
3
|
-
window config dispatches a `belte:menu` CustomEvent into the page when clicked
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
window config dispatches a `belte:menu` CustomEvent into the page when clicked.
|
|
4
|
+
Two forms, both returning an unsubscribe so they drop straight into a Svelte
|
|
5
|
+
`$effect`:
|
|
6
6
|
|
|
7
|
+
// catch-all — every emit name flows through one handler
|
|
7
8
|
$effect(() =>
|
|
8
9
|
onMenu((name) => {
|
|
9
10
|
if (name === 'reload') location.reload()
|
|
10
11
|
}),
|
|
11
12
|
)
|
|
12
13
|
|
|
14
|
+
// filtered — handler fires only for the named item
|
|
15
|
+
$effect(() => onMenu('reload', () => location.reload()))
|
|
16
|
+
|
|
13
17
|
Inert during SSR and in a plain browser tab — `$effect` only runs client-side,
|
|
14
18
|
the native menu that fires the event exists only in the bundled desktop app,
|
|
15
19
|
and `window` is guarded so importing the module never assumes a DOM.
|
|
16
20
|
*/
|
|
17
|
-
export function onMenu(handler: (name: string) => void): () => void
|
|
21
|
+
export function onMenu(handler: (name: string) => void): () => void
|
|
22
|
+
export function onMenu(name: string, handler: () => void): () => void
|
|
23
|
+
export function onMenu(
|
|
24
|
+
nameOrHandler: string | ((name: string) => void),
|
|
25
|
+
maybeHandler?: () => void,
|
|
26
|
+
): () => void {
|
|
18
27
|
if (typeof window === 'undefined') {
|
|
19
28
|
return () => {}
|
|
20
29
|
}
|
|
30
|
+
// String first arg = filter to that emit name; otherwise a catch-all handler.
|
|
31
|
+
const filter = typeof nameOrHandler === 'string' ? nameOrHandler : undefined
|
|
32
|
+
const handler = typeof nameOrHandler === 'string' ? maybeHandler : nameOrHandler
|
|
21
33
|
function listener(event: Event) {
|
|
22
|
-
|
|
34
|
+
const name = (event as CustomEvent<{ name: string }>).detail.name
|
|
35
|
+
if (filter === undefined || filter === name) {
|
|
36
|
+
handler?.(name)
|
|
37
|
+
}
|
|
23
38
|
}
|
|
24
39
|
window.addEventListener('belte:menu', listener)
|
|
25
40
|
return () => window.removeEventListener('belte:menu', listener)
|
|
@@ -62,8 +62,15 @@ export async function openWebview({
|
|
|
62
62
|
webview_destroy: { args: [FFIType.ptr], returns: FFIType.void },
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
/*
|
|
66
|
+
First arg is the webview's `debug` flag: 1 enables the native inspector
|
|
67
|
+
(WKWebView's Web Inspector, WebView2 DevTools, WebKitGTK inspector) so a JS
|
|
68
|
+
error on the loaded page — otherwise silent in a bare bundle window — can be
|
|
69
|
+
read via right-click → Inspect. Gated behind BELTE_INSPECT so release bundles
|
|
70
|
+
ship without it. The second arg is an optional parent handle; null = fresh window.
|
|
71
|
+
*/
|
|
72
|
+
const debug = process.env.BELTE_INSPECT ? 1 : 0
|
|
73
|
+
const handle = symbols.webview_create(debug, null)
|
|
67
74
|
symbols.webview_set_title(handle, cString(title))
|
|
68
75
|
symbols.webview_set_size(handle, width, height, WEBVIEW_HINT_NONE)
|
|
69
76
|
/*
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { log } from '../shared/log.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Ad-hoc code-signs an assembled macOS `.app` so it launches on other Macs.
|
|
5
|
+
|
|
6
|
+
Apple Silicon mandates a valid code signature for every executable. `bun
|
|
7
|
+
build --compile` emits an ad-hoc, linker-signed binary, but assembling the
|
|
8
|
+
`.app` around it (writing Info.plist, dropping in the lib) leaves the bundle
|
|
9
|
+
unsealed — `codesign --verify` then reports the signature as modified, and a
|
|
10
|
+
copy that picks up a quarantine flag (AirDrop, USB, download) gets silently
|
|
11
|
+
killed by Gatekeeper/AMFI: the icon bounces once and nothing opens.
|
|
12
|
+
|
|
13
|
+
Re-signing inside-out fixes that. Nested Mach-O code (the webview dylib, the
|
|
14
|
+
embedded server binary, the launcher) is signed first, then the bundle as a
|
|
15
|
+
whole, which seals Resources and binds Info.plist. The identity is `-`,
|
|
16
|
+
ad-hoc: no certificate, no Developer account, no network — as far as signing
|
|
17
|
+
goes without a paid Developer ID. Recipients copying a quarantined bundle
|
|
18
|
+
still need `xattr -cr <app>` once, but the app no longer fails to launch.
|
|
19
|
+
|
|
20
|
+
Best-effort: if `codesign` is missing or fails, warn and return rather than
|
|
21
|
+
abort the bundle, which is otherwise complete and usable on the build host.
|
|
22
|
+
*/
|
|
23
|
+
export async function signMacApp(bundleRoot: string, innerPaths: string[]): Promise<void> {
|
|
24
|
+
try {
|
|
25
|
+
// Inner Mach-O code inside-out, then the bundle, which re-signs the
|
|
26
|
+
// main executable as part of sealing — order matters for nested seals.
|
|
27
|
+
for (const path of innerPaths) {
|
|
28
|
+
await Bun.$`codesign --force --sign - ${path}`.quiet()
|
|
29
|
+
}
|
|
30
|
+
await Bun.$`codesign --force --sign - ${bundleRoot}`.quiet()
|
|
31
|
+
} catch (error) {
|
|
32
|
+
log.warn(`could not code-sign ${bundleRoot} — it may not launch when copied to another Mac`)
|
|
33
|
+
log.error(error)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -5,7 +5,21 @@ import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
|
|
|
5
5
|
import { decodeResponse } from '../shared/decodeResponse.ts'
|
|
6
6
|
import type { CliManifest } from './types/CliManifest.ts'
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/*
|
|
9
|
+
Each property of the client is a callable: invoking it decodes the body
|
|
10
|
+
(plain call), while `.raw(args)` returns the underlying Response without
|
|
11
|
+
decoding or throwing on non-2xx — the escape hatch the CLI uses to sniff
|
|
12
|
+
the Content-Type and stream sse/jsonl bodies frame-by-frame instead of
|
|
13
|
+
buffering through decodeResponse.
|
|
14
|
+
*/
|
|
15
|
+
type ClientInvoker = ((args?: unknown) => Promise<unknown>) & {
|
|
16
|
+
raw: (args?: unknown) => Promise<Response>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type AnyApi = Record<string, ClientInvoker>
|
|
20
|
+
|
|
21
|
+
// A command resolved to its HTTP shape — the manifest/registry lookup result.
|
|
22
|
+
type ResolvedCommand = { method: HttpVerb; url: string; accept?: string }
|
|
9
23
|
|
|
10
24
|
/*
|
|
11
25
|
Builds a typed proxy over the project's RPCs for use in scripts, tests,
|
|
@@ -42,35 +56,56 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
|
|
|
42
56
|
baked-in source of truth); registry is the in-process fallback for
|
|
43
57
|
use in same-project code where defineVerb has run.
|
|
44
58
|
*/
|
|
45
|
-
function resolve(name: string):
|
|
59
|
+
function resolve(name: string): ResolvedCommand | undefined {
|
|
46
60
|
const entry = manifest?.[name]
|
|
47
61
|
if (entry) {
|
|
48
|
-
return { method: entry.method, url: entry.url }
|
|
62
|
+
return { method: entry.method, url: entry.url, accept: entry.accept }
|
|
49
63
|
}
|
|
50
64
|
const found = findVerbByCommandName(name)
|
|
51
65
|
return found ? { method: found.remote.method, url: found.remote.url } : undefined
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
/*
|
|
55
|
-
Single
|
|
56
|
-
is
|
|
57
|
-
mode looks the verb up in the registry and runs verb.fetch
|
|
69
|
+
Single dispatch path for both modes — only the base URL and how the
|
|
70
|
+
Request is sent differ. Remote mode fetches over the network;
|
|
71
|
+
in-process mode looks the verb up in the registry and runs verb.fetch
|
|
72
|
+
(no hop). Returns the raw Response; callers decode or stream it.
|
|
58
73
|
*/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
path: string,
|
|
74
|
+
function send(
|
|
75
|
+
resolved: ResolvedCommand,
|
|
62
76
|
args: unknown,
|
|
63
77
|
baseUrl: string,
|
|
64
78
|
dispatch: (request: Request) => Promise<Response>,
|
|
65
|
-
): Promise<
|
|
79
|
+
): Promise<Response> {
|
|
66
80
|
const headers = new Headers()
|
|
67
81
|
if (token) {
|
|
68
82
|
headers.set('authorization', `Bearer ${token}`)
|
|
69
83
|
}
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
if (resolved.accept) {
|
|
85
|
+
headers.set('accept', resolved.accept)
|
|
86
|
+
}
|
|
87
|
+
const request = buildRpcRequest({
|
|
88
|
+
method: resolved.method,
|
|
89
|
+
url: resolved.url,
|
|
90
|
+
args,
|
|
91
|
+
baseUrl,
|
|
92
|
+
headers,
|
|
93
|
+
})
|
|
94
|
+
return dispatch(request)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Decoding plain-call path: throws on non-2xx, returns the decoded body.
|
|
98
|
+
async function call(
|
|
99
|
+
resolved: ResolvedCommand,
|
|
100
|
+
args: unknown,
|
|
101
|
+
baseUrl: string,
|
|
102
|
+
dispatch: (request: Request) => Promise<Response>,
|
|
103
|
+
): Promise<unknown> {
|
|
104
|
+
const response = await send(resolved, args, baseUrl, dispatch)
|
|
72
105
|
if (!response.ok) {
|
|
73
|
-
throw new Error(
|
|
106
|
+
throw new Error(
|
|
107
|
+
`${resolved.method} ${resolved.url} failed: ${response.status} ${response.statusText}`,
|
|
108
|
+
)
|
|
74
109
|
}
|
|
75
110
|
return decodeResponse(response)
|
|
76
111
|
}
|
|
@@ -94,10 +129,24 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
|
|
|
94
129
|
manifest + registry are fixed for a client's lifetime, so a resolved
|
|
95
130
|
invoker (or its absence) never changes.
|
|
96
131
|
*/
|
|
97
|
-
const invokerCache = new Map<string,
|
|
132
|
+
const invokerCache = new Map<string, ClientInvoker | undefined>()
|
|
133
|
+
|
|
134
|
+
/*
|
|
135
|
+
Build a memoised invoker for a resolved command. The plain call and
|
|
136
|
+
`.raw` share one dispatch — remote mode hits the network, in-process
|
|
137
|
+
mode runs verb.fetch — so the two can't diverge on URL/headers.
|
|
138
|
+
*/
|
|
139
|
+
function buildInvoker(resolved: ResolvedCommand): ClientInvoker {
|
|
140
|
+
const baseUrl = url ?? 'http://localhost/'
|
|
141
|
+
const dispatch = url ? fetch : inProcessDispatch(resolved.url)
|
|
142
|
+
const invoker = ((args?: unknown) =>
|
|
143
|
+
call(resolved, args, baseUrl, dispatch)) as ClientInvoker
|
|
144
|
+
invoker.raw = (args?: unknown) => send(resolved, args, baseUrl, dispatch)
|
|
145
|
+
return invoker
|
|
146
|
+
}
|
|
98
147
|
|
|
99
148
|
return new Proxy({} as Api, {
|
|
100
|
-
get(_target, prop):
|
|
149
|
+
get(_target, prop): ClientInvoker | undefined {
|
|
101
150
|
if (typeof prop !== 'string') {
|
|
102
151
|
return undefined
|
|
103
152
|
}
|
|
@@ -105,18 +154,7 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
|
|
|
105
154
|
return invokerCache.get(prop)
|
|
106
155
|
}
|
|
107
156
|
const resolved = resolve(prop)
|
|
108
|
-
const invoker = resolved
|
|
109
|
-
? (args?: unknown) =>
|
|
110
|
-
url
|
|
111
|
-
? call(resolved.method, resolved.url, args, url, fetch)
|
|
112
|
-
: call(
|
|
113
|
-
resolved.method,
|
|
114
|
-
resolved.url,
|
|
115
|
-
args,
|
|
116
|
-
'http://localhost/',
|
|
117
|
-
inProcessDispatch(resolved.url),
|
|
118
|
-
)
|
|
119
|
-
: undefined
|
|
157
|
+
const invoker = resolved ? buildInvoker(resolved) : undefined
|
|
120
158
|
invokerCache.set(prop, invoker)
|
|
121
159
|
return invoker
|
|
122
160
|
},
|