@briancray/belte 0.1.0 → 0.2.1
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 +25 -12
- package/package.json +2 -1
- package/src/appEntry.ts +124 -0
- package/src/belteResolverPlugin.ts +236 -202
- package/src/build.ts +6 -67
- package/src/buildCli.ts +36 -63
- package/src/buildDisconnected.ts +127 -0
- package/src/bundleApp.ts +123 -0
- package/src/bundleDisconnectedEntry.ts +17 -0
- package/src/cliEntry.ts +3 -9
- package/src/compile.ts +4 -15
- package/src/controlServerWorker.ts +261 -0
- package/src/dedupeSveltePlugin.ts +66 -0
- package/src/discoveryEntry.ts +12 -11
- package/src/lib/browser/cache.ts +3 -6
- package/src/lib/browser/page.svelte.ts +19 -21
- package/src/lib/browser/socketChannel.ts +11 -1
- package/src/lib/browser/types/Pages.ts +1 -1
- package/src/lib/bundle/BundleMenu.ts +11 -0
- package/src/lib/bundle/BundleMenuItem.ts +24 -0
- package/src/lib/bundle/BundleWindow.ts +20 -0
- package/src/lib/bundle/bindConnectedFlag.ts +29 -0
- package/src/lib/bundle/bindRequestNavigate.ts +31 -0
- package/src/lib/bundle/buildWebviewLib.ts +111 -0
- package/src/lib/bundle/disconnected.css +9 -0
- package/src/lib/bundle/disconnected.svelte +192 -0
- package/src/lib/bundle/ensureWebviewLib.ts +20 -0
- package/src/lib/bundle/exitWithParent.ts +28 -0
- package/src/lib/bundle/findFreePort.ts +14 -0
- package/src/lib/bundle/infoPlist.ts +46 -0
- package/src/lib/bundle/installMacMenu.ts +39 -0
- package/src/lib/bundle/listenLocalControlServer.ts +19 -0
- package/src/lib/bundle/native/belteMenu.mm +298 -0
- package/src/lib/bundle/native/webview.h +4557 -0
- package/src/lib/bundle/onMenu.ts +26 -0
- package/src/lib/bundle/openWebview.ts +81 -0
- package/src/lib/bundle/pngToIcns.ts +47 -0
- package/src/lib/bundle/probeBelteServer.ts +34 -0
- package/src/lib/bundle/resolveServerBinary.ts +12 -0
- package/src/lib/bundle/resolveWebviewLib.ts +51 -0
- package/src/lib/bundle/serverBinaryFilename.ts +8 -0
- package/src/lib/bundle/stableLocalPort.ts +19 -0
- package/src/lib/bundle/waitForServer.ts +23 -0
- package/src/lib/bundle/webviewBuildRevision.ts +9 -0
- package/src/lib/bundle/webviewCachePath.ts +23 -0
- package/src/lib/bundle/webviewLibName.ts +11 -0
- package/src/lib/bundle/webviewVersion.ts +7 -0
- package/src/lib/cli/createClient.ts +34 -36
- package/src/lib/cli/printHelp.ts +45 -2
- package/src/lib/cli/runCli.ts +12 -3
- package/src/lib/mcp/createMcpResourceServer.ts +1 -1
- package/src/lib/mcp/dispatchMcpRequest.ts +53 -73
- package/src/lib/server/AppModule.ts +2 -2
- package/src/lib/server/cli/handleCliDownload.ts +4 -5
- package/src/lib/server/cli/handleCliInstall.ts +17 -0
- package/src/lib/server/error.ts +23 -9
- package/src/lib/server/json.ts +5 -5
- package/src/lib/server/jsonl.ts +10 -5
- package/src/lib/server/prompts/definePrompt.ts +6 -6
- package/src/lib/server/prompts/renderPromptTemplate.ts +16 -0
- package/src/lib/server/prompts/types/Prompt.ts +8 -9
- package/src/lib/server/prompts/types/PromptOptions.ts +7 -12
- package/src/lib/server/prompts/types/PromptRegistryEntry.ts +3 -5
- package/src/lib/server/prompts/types/PromptRoutes.ts +4 -4
- package/src/lib/server/redirect.ts +13 -8
- package/src/lib/server/rpc/defineVerb.ts +4 -3
- package/src/lib/server/rpc/findVerbByCommandName.ts +18 -0
- package/src/lib/server/rpc/types/RemoteFunction.ts +1 -1
- package/src/lib/server/rpc/types/RemoteHandler.ts +4 -0
- package/src/lib/server/runtime/acceptsZstd.ts +8 -0
- package/src/lib/server/runtime/buildOpenApiSpec.ts +2 -0
- package/src/lib/server/runtime/cacheControlForAsset.ts +7 -2
- package/src/lib/server/runtime/createAssetHeaderCache.ts +35 -0
- package/src/lib/server/runtime/createPublicAssetServer.ts +6 -20
- package/src/lib/server/runtime/createServer.ts +50 -58
- package/src/lib/server/runtime/registryManifests.ts +33 -15
- package/src/lib/server/runtime/types/RequestStore.ts +2 -3
- package/src/lib/server/runtime/withResponseDefaults.ts +24 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +10 -7
- package/src/lib/server/sse.ts +10 -5
- package/src/lib/shared/belteImportName.test.ts +58 -0
- package/src/lib/shared/belteImportName.ts +45 -0
- package/src/lib/shared/beltePackageName.ts +7 -0
- package/src/lib/shared/cacheControlValues.ts +10 -2
- package/src/lib/shared/canonicalJson.ts +1 -5
- package/src/lib/shared/createCacheStore.ts +29 -20
- package/src/lib/shared/exitOnBuildFailure.ts +17 -0
- package/src/lib/shared/fileStem.ts +9 -0
- package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
- package/src/lib/shared/keyForRemoteCall.ts +7 -5
- package/src/lib/shared/parsePromptMarkdown.ts +34 -0
- package/src/lib/shared/prepareRpcModule.ts +14 -4
- package/src/lib/shared/prepareSocketModule.ts +16 -2
- package/src/lib/shared/promptNameForFile.ts +5 -5
- package/src/lib/shared/subscribableFromResponse.ts +104 -215
- package/src/lib/shared/types/PromptArgument.ts +12 -0
- package/src/lib/shared/writeRoutesDts.ts +5 -7
- package/src/serverBuildPlugins.ts +25 -0
- package/src/serverEntry.ts +4 -0
- package/template/package.json +3 -2
- package/src/lib/server/prompt.ts +0 -30
- package/src/lib/server/prompts/types/PromptMessage.ts +0 -10
- package/src/lib/shared/preparePromptModule.ts +0 -36
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { dirname } from 'node:path'
|
|
2
|
+
import { log } from '../shared/log.ts'
|
|
3
|
+
import { webviewCachePath } from './webviewCachePath.ts'
|
|
4
|
+
import { webviewVersion } from './webviewVersion.ts'
|
|
5
|
+
|
|
6
|
+
// Vendored upstream amalgamation; the host compiler turns it into a lib.
|
|
7
|
+
const HEADER = new URL('./native/webview.h', import.meta.url).pathname
|
|
8
|
+
|
|
9
|
+
// belte's own native shim, linked into the same dylib on macOS to add the
|
|
10
|
+
// standard application menu bar the upstream webview omits.
|
|
11
|
+
const MAC_MENU_SOURCE = new URL('./native/belteMenu.mm', import.meta.url).pathname
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
Linux GTK/WebKit pkg-config module sets, newest first. The vendored header
|
|
15
|
+
auto-selects the GTK4 or GTK3 backend from GTK_MAJOR_VERSION in the include
|
|
16
|
+
path, so supplying the right cflags/libs is all that's needed — no backend
|
|
17
|
+
macro. The first set whose packages are installed wins.
|
|
18
|
+
*/
|
|
19
|
+
const LINUX_PKG_SETS = [
|
|
20
|
+
['gtk4', 'webkitgtk-6.0'],
|
|
21
|
+
['gtk+-3.0', 'webkit2gtk-4.1'],
|
|
22
|
+
['gtk+-3.0', 'webkit2gtk-4.0'],
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
Compiles the vendored webview header into a native shared library for the
|
|
27
|
+
host platform and caches it (webviewCachePath), so `belte bundle` needs
|
|
28
|
+
no prebuilt binary and no third-party webview package — just
|
|
29
|
+
the platform's C++ toolchain. Returns the cached path. Throws actionable
|
|
30
|
+
guidance when the toolchain or native webview dev packages are missing,
|
|
31
|
+
rather than letting the compiler fail opaquely.
|
|
32
|
+
*/
|
|
33
|
+
export async function buildWebviewLib(): Promise<string> {
|
|
34
|
+
const outfile = webviewCachePath()
|
|
35
|
+
await Bun.$`mkdir -p ${dirname(outfile)}`.quiet()
|
|
36
|
+
log.info(`building webview ${webviewVersion} for ${process.platform}-${process.arch}…`)
|
|
37
|
+
|
|
38
|
+
if (process.platform === 'darwin') {
|
|
39
|
+
await compileDarwin(outfile)
|
|
40
|
+
} else if (process.platform === 'linux') {
|
|
41
|
+
await compileLinux(outfile)
|
|
42
|
+
} else {
|
|
43
|
+
/*
|
|
44
|
+
Windows needs MSVC + the WebView2 SDK header (WebView2.h), which
|
|
45
|
+
isn't a turnkey shell invocation. Until that build path lands,
|
|
46
|
+
point Windows users at the explicit-path escape hatch.
|
|
47
|
+
*/
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[belte] building the webview library on ${process.platform} isn't supported yet. ` +
|
|
50
|
+
'Set BELTE_WEBVIEW_LIB to a prebuilt webview library to continue.',
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
log.success(`built webview library: ${outfile}`)
|
|
55
|
+
return outfile
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// macOS: clang against the WebKit + Cocoa frameworks (always present with the
|
|
59
|
+
// Command Line Tools), linking belte's Objective-C++ menu shim into the same
|
|
60
|
+
// dylib. The `-x` flags switch the input language per file: the vendored
|
|
61
|
+
// header compiles as C++ (it uses the C objc runtime, not objc syntax), the
|
|
62
|
+
// shim as Objective-C++. Maps a missing compiler to the install hint.
|
|
63
|
+
async function compileDarwin(outfile: string): Promise<void> {
|
|
64
|
+
try {
|
|
65
|
+
await Bun.$`clang++ -std=c++17 -DWEBVIEW_BUILD_SHARED -fvisibility=hidden -shared -framework WebKit -framework Cocoa -x c++ ${HEADER} -x objective-c++ ${MAC_MENU_SOURCE} -o ${outfile}`.quiet()
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'[belte] failed to compile the webview library. Install the Xcode Command ' +
|
|
69
|
+
'Line Tools with `xcode-select --install` and try again.\n' +
|
|
70
|
+
describeShellError(error),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Linux: detect an installed GTK/WebKit set via pkg-config, then compile a
|
|
76
|
+
// position-independent shared object. Maps missing packages to install hints.
|
|
77
|
+
async function compileLinux(outfile: string): Promise<void> {
|
|
78
|
+
const flags = await linuxPkgFlags()
|
|
79
|
+
try {
|
|
80
|
+
await Bun.$`c++ -std=c++17 -DWEBVIEW_BUILD_SHARED -fvisibility=hidden -fPIC -shared -x c++ ${HEADER} ${flags} -o ${outfile}`.quiet()
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'[belte] failed to compile the webview library. Ensure a C++ compiler ' +
|
|
84
|
+
'(e.g. `build-essential`) is installed.\n' +
|
|
85
|
+
describeShellError(error),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Returns combined cflags + libs for the first available GTK/WebKit set, or
|
|
91
|
+
// throws with the package names to install when none resolve.
|
|
92
|
+
async function linuxPkgFlags(): Promise<string[]> {
|
|
93
|
+
for (const modules of LINUX_PKG_SETS) {
|
|
94
|
+
const probe = await Bun.$`pkg-config --exists ${modules}`.nothrow().quiet()
|
|
95
|
+
if (probe.exitCode === 0) {
|
|
96
|
+
const flags = await Bun.$`pkg-config --cflags --libs ${modules}`.quiet().text()
|
|
97
|
+
return flags.trim().split(/\s+/)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw new Error(
|
|
101
|
+
'[belte] no GTK/WebKit development packages found. Install one set, e.g. ' +
|
|
102
|
+
'`libgtk-4-dev libwebkitgtk-6.0-dev` or `libgtk-3-dev libwebkit2gtk-4.1-dev`, ' +
|
|
103
|
+
'or set BELTE_WEBVIEW_LIB to a prebuilt webview library.',
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Surfaces the compiler's own stderr when a Bun shell command throws.
|
|
108
|
+
function describeShellError(error: unknown): string {
|
|
109
|
+
const stderr = (error as { stderr?: Uint8Array }).stderr
|
|
110
|
+
return stderr ? new TextDecoder().decode(stderr) : String(error)
|
|
111
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Tailwind entry for the bundle connect screen. `@import "tailwindcss"` pulls the
|
|
3
|
+
engine in; the explicit `@source` guarantees the default component is scanned for
|
|
4
|
+
utility classes even if automatic source detection misses it. bun-plugin-tailwind
|
|
5
|
+
compiles this at build time; buildDisconnected inlines the result into the served
|
|
6
|
+
HTML so the screen needs zero external requests.
|
|
7
|
+
*/
|
|
8
|
+
@import 'tailwindcss';
|
|
9
|
+
@source './disconnected.svelte';
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
Default bundle connect screen. The launcher serves this (with the logo baked in
|
|
4
|
+
at build time and the app title injected at runtime) instead of a blank window,
|
|
5
|
+
and overriding it is a matter of dropping a `src/bundle/disconnected.svelte`.
|
|
6
|
+
|
|
7
|
+
It both connects to a remote server by URL and boots the embedded server, talking
|
|
8
|
+
to the launcher's in-process control server: POST /connect and POST /start each
|
|
9
|
+
reply with a `{ redirect }` the page follows, while the launcher records the
|
|
10
|
+
connection so the native File menu's enabled state stays authoritative.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
localStorage key holding the last connection so a relaunch repeats it: either a
|
|
15
|
+
remote server URL, or the START_EMBEDDED sentinel meaning "boot the embedded
|
|
16
|
+
server". The embedded server's own URL can't be persisted — it picks a fresh
|
|
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.
|
|
28
|
+
*/
|
|
29
|
+
const LAST_URL_KEY = 'belte:last-server-url'
|
|
30
|
+
|
|
31
|
+
// Injected globals: app title from the launcher, logo data URI from the build.
|
|
32
|
+
const heading =
|
|
33
|
+
(globalThis as { __BELTE_TITLE__?: string }).__BELTE_TITLE__ ?? 'belte app'
|
|
34
|
+
const logo = (globalThis as { __BELTE_LOGO__?: string }).__BELTE_LOGO__
|
|
35
|
+
|
|
36
|
+
const placeholder = 'https://example.com'
|
|
37
|
+
|
|
38
|
+
// Prefill the form with the last server we connected to, from any prior launch.
|
|
39
|
+
let url = $state(localStorage.getItem(LAST_URL_KEY) ?? '')
|
|
40
|
+
let starting = $state(false)
|
|
41
|
+
let error = $state<string | undefined>(undefined)
|
|
42
|
+
|
|
43
|
+
/*
|
|
44
|
+
Interpret the boot intent once on load. `?action=` is set by the native File
|
|
45
|
+
menu's navigate items (or the launcher when a live connection dies); absent it, a
|
|
46
|
+
remembered server reconnects automatically:
|
|
47
|
+
- start → boot the embedded server (matches the Start Server menu item).
|
|
48
|
+
- lost → the connected server stopped responding; explain and wait (the
|
|
49
|
+
form is already prefilled with the last URL for a one-click retry).
|
|
50
|
+
- disconnect → forget the URL + tear down any embedded server, stay here so the
|
|
51
|
+
form is the place to point at another server.
|
|
52
|
+
- (none) → reconnect to the saved server if there is one.
|
|
53
|
+
*/
|
|
54
|
+
$effect(() => {
|
|
55
|
+
const action = new URLSearchParams(location.search).get('action')
|
|
56
|
+
const saved = localStorage.getItem(STORAGE_KEY) ?? undefined
|
|
57
|
+
if (action === 'start') {
|
|
58
|
+
void start()
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (action === 'lost') {
|
|
62
|
+
error = 'The server stopped responding.'
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (action === 'disconnect') {
|
|
66
|
+
// Forget the auto-reconnect intent but keep LAST_URL_KEY, so the form
|
|
67
|
+
// stays prefilled with the server we just left for a one-click return.
|
|
68
|
+
localStorage.removeItem(STORAGE_KEY)
|
|
69
|
+
void fetch('/__belte/disconnect').catch(() => {})
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
// No action: repeat the last choice — re-boot the embedded server, or reconnect.
|
|
73
|
+
if (saved === START_EMBEDDED) {
|
|
74
|
+
void start()
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (saved) {
|
|
78
|
+
void connect(saved)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/*
|
|
83
|
+
Ask the launcher to connect to a server by URL. It verifies the URL really is a
|
|
84
|
+
belte server — POST /connect probes the target's identity endpoint — and flips the
|
|
85
|
+
native menu's connected flag before replying with the `{ redirect }` to follow; a
|
|
86
|
+
URL that isn't a belte server comes back as an error shown below the form. Only a
|
|
87
|
+
confirmed URL is remembered, so a relaunch never auto-retries a dead or wrong
|
|
88
|
+
address. The saved-URL reconnect path runs through here too.
|
|
89
|
+
*/
|
|
90
|
+
async function connect(target: string = url.trim()): Promise<void> {
|
|
91
|
+
const cleaned = target.trim()
|
|
92
|
+
if (!cleaned) {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
error = undefined
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetch('/connect', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({ url: cleaned }),
|
|
101
|
+
})
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
const body = (await response.json()) as { error?: string }
|
|
104
|
+
throw new Error(body.error ?? `connect failed (${response.status})`)
|
|
105
|
+
}
|
|
106
|
+
const { redirect } = (await response.json()) as { redirect: string }
|
|
107
|
+
localStorage.setItem(STORAGE_KEY, cleaned)
|
|
108
|
+
// Remember it separately so it outlives a later disconnect and prefills the form.
|
|
109
|
+
localStorage.setItem(LAST_URL_KEY, cleaned)
|
|
110
|
+
location.href = redirect
|
|
111
|
+
} catch (cause) {
|
|
112
|
+
error = `Could not connect: ${String(cause)}`
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Boot the embedded server via the launcher, then follow it once it answers.
|
|
117
|
+
async function start(): Promise<void> {
|
|
118
|
+
error = undefined
|
|
119
|
+
starting = true
|
|
120
|
+
// Remember the embedded-server choice so the next launch boots it automatically.
|
|
121
|
+
localStorage.setItem(STORAGE_KEY, START_EMBEDDED)
|
|
122
|
+
try {
|
|
123
|
+
const response = await fetch('/start', { method: 'POST' })
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
const body = (await response.json()) as { error?: string }
|
|
126
|
+
throw new Error(body.error ?? `start failed (${response.status})`)
|
|
127
|
+
}
|
|
128
|
+
const { redirect } = (await response.json()) as { redirect: string }
|
|
129
|
+
location.href = redirect
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
error = `Could not start the server: ${String(cause)}`
|
|
132
|
+
starting = false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<main class="flex min-h-screen items-center justify-center bg-gray-50 p-6 text-gray-900">
|
|
138
|
+
<div class="w-full max-w-sm rounded-2xl bg-white p-8 shadow-sm ring-1 ring-gray-200">
|
|
139
|
+
{#if logo}
|
|
140
|
+
<img src={logo} alt="" class="mx-auto mb-5 h-16 w-16 rounded-xl object-contain" />
|
|
141
|
+
{/if}
|
|
142
|
+
<h1 class="mb-6 text-center text-xl font-semibold tracking-tight">{heading}</h1>
|
|
143
|
+
|
|
144
|
+
<form
|
|
145
|
+
class="flex flex-col gap-3"
|
|
146
|
+
onsubmit={(event) => {
|
|
147
|
+
event.preventDefault()
|
|
148
|
+
void connect()
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<input
|
|
152
|
+
type="url"
|
|
153
|
+
bind:value={url}
|
|
154
|
+
{placeholder}
|
|
155
|
+
autocomplete="url"
|
|
156
|
+
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"
|
|
157
|
+
/>
|
|
158
|
+
<button
|
|
159
|
+
type="submit"
|
|
160
|
+
class="w-full rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
|
161
|
+
>
|
|
162
|
+
Connect
|
|
163
|
+
</button>
|
|
164
|
+
</form>
|
|
165
|
+
|
|
166
|
+
<div class="my-5 flex items-center gap-3 text-xs text-gray-400">
|
|
167
|
+
<span class="h-px flex-1 bg-gray-200"></span>
|
|
168
|
+
or
|
|
169
|
+
<span class="h-px flex-1 bg-gray-200"></span>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
onclick={() => void start()}
|
|
175
|
+
disabled={starting}
|
|
176
|
+
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-60"
|
|
177
|
+
>
|
|
178
|
+
{starting ? 'Starting…' : 'Start server'}
|
|
179
|
+
</button>
|
|
180
|
+
|
|
181
|
+
{#if error}
|
|
182
|
+
<p class="mt-4 text-center text-sm text-red-600">{error}</p>
|
|
183
|
+
{/if}
|
|
184
|
+
|
|
185
|
+
<p class="mt-8 text-center text-xs text-gray-400">
|
|
186
|
+
made with
|
|
187
|
+
<a href="https://github.com/briancray/belte" class="underline hover:text-gray-600">
|
|
188
|
+
belte
|
|
189
|
+
</a>
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
</main>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { buildWebviewLib } from './buildWebviewLib.ts'
|
|
2
|
+
import { resolveWebviewLib } from './resolveWebviewLib.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Build-time guarantee that a webview library exists, returning its path.
|
|
6
|
+
Tries plain resolution first (an explicit BELTE_WEBVIEW_LIB, a bundle-local
|
|
7
|
+
copy, or a previously built cache); on a miss it compiles the vendored
|
|
8
|
+
header for the host (buildWebviewLib) and caches the result.
|
|
9
|
+
|
|
10
|
+
Used by `belte bundle` (bundleApp), which runs under bun on a developer's
|
|
11
|
+
machine — never by the compiled launcher, which only ever resolves the copy
|
|
12
|
+
shipped beside it and must not invoke a compiler on an end user's machine.
|
|
13
|
+
*/
|
|
14
|
+
export async function ensureWebviewLib(cwd: string = process.cwd()): Promise<string> {
|
|
15
|
+
try {
|
|
16
|
+
return await resolveWebviewLib(cwd)
|
|
17
|
+
} catch {
|
|
18
|
+
return await buildWebviewLib()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Tie the embedded server's lifetime to the bundle launcher's.
|
|
3
|
+
|
|
4
|
+
The launcher spawns this server with BELTE_PARENT_PID set to its own pid. On a
|
|
5
|
+
clean window close the launcher reaps the child directly, but a force-quit (or
|
|
6
|
+
crash) of the launcher can't run that cleanup, which would leave the server
|
|
7
|
+
orphaned and holding its port. So when that env var is present, poll the parent
|
|
8
|
+
and exit once it's gone. A no-op when the var is absent (standalone `belte
|
|
9
|
+
start`), so it only ever activates inside a bundle.
|
|
10
|
+
*/
|
|
11
|
+
export function exitWithParent(): void {
|
|
12
|
+
const parent = process.env.BELTE_PARENT_PID
|
|
13
|
+
if (!parent) {
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
const parentPid = Number(parent)
|
|
17
|
+
const timer = setInterval(() => {
|
|
18
|
+
try {
|
|
19
|
+
// Signal 0 sends nothing — it only probes existence, throwing when the
|
|
20
|
+
// parent has exited (or its pid is no longer reachable).
|
|
21
|
+
process.kill(parentPid, 0)
|
|
22
|
+
} catch {
|
|
23
|
+
process.exit(0)
|
|
24
|
+
}
|
|
25
|
+
}, 1000)
|
|
26
|
+
// The watchdog alone shouldn't keep the server process alive.
|
|
27
|
+
timer.unref()
|
|
28
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Asks the OS for an unused TCP port by binding a throwaway Bun server to
|
|
3
|
+
port 0 (the kernel assigns a free port), reading the assigned port, then
|
|
4
|
+
stopping it immediately. There is an unavoidable race between releasing
|
|
5
|
+
the port here and the server child re-binding it, but for a
|
|
6
|
+
single-user bundle launch the window is negligible.
|
|
7
|
+
*/
|
|
8
|
+
export function findFreePort(): number {
|
|
9
|
+
const probe = Bun.serve({ port: 0, fetch: () => new Response() })
|
|
10
|
+
// A TCP server bound to port 0 always reports a numeric assigned port.
|
|
11
|
+
const port = probe.port as number
|
|
12
|
+
probe.stop(true)
|
|
13
|
+
return port
|
|
14
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Renders the `Info.plist` for a macOS `.app` bundle. CFBundleExecutable
|
|
3
|
+
must match the launcher's filename in `Contents/MacOS/` or the app won't
|
|
4
|
+
launch. `icon` is the filename (without extension) of an `.icns` under
|
|
5
|
+
`Contents/Resources/`; omitted when the project ships no icon. The
|
|
6
|
+
identifier is synthesized from the program name; a real distribution would
|
|
7
|
+
override it with a registered reverse-DNS id.
|
|
8
|
+
*/
|
|
9
|
+
export function infoPlist({
|
|
10
|
+
name,
|
|
11
|
+
version,
|
|
12
|
+
icon,
|
|
13
|
+
}: {
|
|
14
|
+
name: string
|
|
15
|
+
version: string
|
|
16
|
+
icon?: string
|
|
17
|
+
}): string {
|
|
18
|
+
const iconEntry = icon
|
|
19
|
+
? ` <key>CFBundleIconFile</key>
|
|
20
|
+
<string>${icon}</string>
|
|
21
|
+
`
|
|
22
|
+
: ''
|
|
23
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
24
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
25
|
+
<plist version="1.0">
|
|
26
|
+
<dict>
|
|
27
|
+
<key>CFBundleName</key>
|
|
28
|
+
<string>${name}</string>
|
|
29
|
+
<key>CFBundleDisplayName</key>
|
|
30
|
+
<string>${name}</string>
|
|
31
|
+
<key>CFBundleExecutable</key>
|
|
32
|
+
<string>${name}</string>
|
|
33
|
+
<key>CFBundleIdentifier</key>
|
|
34
|
+
<string>com.belte.${name}</string>
|
|
35
|
+
<key>CFBundleVersion</key>
|
|
36
|
+
<string>${version}</string>
|
|
37
|
+
<key>CFBundleShortVersionString</key>
|
|
38
|
+
<string>${version}</string>
|
|
39
|
+
${iconEntry} <key>CFBundlePackageType</key>
|
|
40
|
+
<string>APPL</string>
|
|
41
|
+
<key>NSHighResolutionCapable</key>
|
|
42
|
+
<true/>
|
|
43
|
+
</dict>
|
|
44
|
+
</plist>
|
|
45
|
+
`
|
|
46
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { dlopen, FFIType, type Pointer } from 'bun:ffi'
|
|
2
|
+
import type { BundleMenu } from './BundleMenu.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Installs the macOS application menu bar via belte's native shim in the webview
|
|
6
|
+
library. The standard App/Edit/Window menus are always present — so Cmd-Q and the
|
|
7
|
+
Edit shortcuts (Cmd-C/V/X/A/Z) work, which the bare upstream webview window lacks
|
|
8
|
+
— plus the launcher's `fileMenu` (inserted as File, before Edit) and the bundle's
|
|
9
|
+
custom `menu` (between Edit and Window). Menu items are serialised as
|
|
10
|
+
`{ separator: true }`, `{ label, shortcut?, navigate, role? }`, or
|
|
11
|
+
`{ label, shortcut?, emit }`: `navigate` items repoint the live window (the
|
|
12
|
+
launcher's File menu uses these, with `role` gating their enabled state against the
|
|
13
|
+
native connected flag set by `belte_set_connected`), `emit` items dispatch
|
|
14
|
+
`belte:menu` events into the page. `appName` labels the App-menu items.
|
|
15
|
+
|
|
16
|
+
The config is serialised to JSON and parsed natively, so the launcher never
|
|
17
|
+
touches FFI. A no-op off macOS, where the shim symbol isn't compiled into the
|
|
18
|
+
library; opened as its own short-lived handle to keep openWebview's FFI map
|
|
19
|
+
fully typed (a conditional symbol there defeats Bun's argument-type inference).
|
|
20
|
+
The native menu attaches to the shared NSApplication, so it persists after this
|
|
21
|
+
handle closes.
|
|
22
|
+
*/
|
|
23
|
+
export function installMacMenu(
|
|
24
|
+
libPath: string,
|
|
25
|
+
webviewHandle: Pointer | null,
|
|
26
|
+
appName: string,
|
|
27
|
+
menu: BundleMenu[] | undefined,
|
|
28
|
+
fileMenu: BundleMenu | undefined,
|
|
29
|
+
): void {
|
|
30
|
+
if (process.platform !== 'darwin') {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
const { symbols, close } = dlopen(libPath, {
|
|
34
|
+
belte_install_app_menu: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void },
|
|
35
|
+
})
|
|
36
|
+
const config = JSON.stringify({ appName, fileMenu, menu })
|
|
37
|
+
symbols.belte_install_app_menu(webviewHandle, new TextEncoder().encode(`${config}\0`))
|
|
38
|
+
close()
|
|
39
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Binds the launcher's in-process control server to the given localhost port,
|
|
3
|
+
falling back to a kernel-assigned free port when that port is already taken
|
|
4
|
+
(another instance of the same app, or an unrelated process). The stable port
|
|
5
|
+
keeps the connect screen's origin — and its localStorage — constant across
|
|
6
|
+
launches; the fallback trades that stability for the app still booting. Callers
|
|
7
|
+
read the actual port from the returned server's `.port` to build the origin.
|
|
8
|
+
*/
|
|
9
|
+
export function listenLocalControlServer(
|
|
10
|
+
port: number,
|
|
11
|
+
fetch: (request: Request) => Response | Promise<Response>,
|
|
12
|
+
): ReturnType<typeof Bun.serve> {
|
|
13
|
+
try {
|
|
14
|
+
return Bun.serve({ port, hostname: '127.0.0.1', fetch })
|
|
15
|
+
} catch {
|
|
16
|
+
// EADDRINUSE (or any bind failure) on the stable port → take any free one.
|
|
17
|
+
return Bun.serve({ port: 0, hostname: '127.0.0.1', fetch })
|
|
18
|
+
}
|
|
19
|
+
}
|