@briancray/belte 0.2.2 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@briancray/belte",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
6
6
  "license": "MIT",
@@ -29,8 +29,7 @@ click away even after an explicit disconnect.
29
29
  const LAST_URL_KEY = 'belte:last-server-url'
30
30
 
31
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'
32
+ const heading = (globalThis as { __BELTE_TITLE__?: string }).__BELTE_TITLE__ ?? 'belte app'
34
33
  const logo = (globalThis as { __BELTE_LOGO__?: string }).__BELTE_LOGO__
35
34
 
36
35
  const placeholder = 'https://example.com'
@@ -134,10 +133,12 @@ async function start(): Promise<void> {
134
133
  }
135
134
  </script>
136
135
 
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">
136
+ <main
137
+ 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
+ <div
139
+ class="w-full max-w-sm rounded-2xl bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
139
140
  {#if logo}
140
- <img src={logo} alt="" class="mx-auto mb-5 h-16 w-16 rounded-xl object-contain" />
141
+ <img src={logo} alt="" class="mx-auto mb-5 h-16 w-16 rounded-xl object-contain">
141
142
  {/if}
142
143
  <h1 class="mb-6 text-center text-xl font-semibold tracking-tight">{heading}</h1>
143
144
 
@@ -146,45 +147,43 @@ async function start(): Promise<void> {
146
147
  onsubmit={(event) => {
147
148
  event.preventDefault()
148
149
  void connect()
149
- }}
150
- >
150
+ }}>
151
151
  <input
152
152
  type="url"
153
153
  bind:value={url}
154
154
  {placeholder}
155
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
- />
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 dark:border-gray-700 dark:bg-gray-800 dark:focus:border-gray-100 dark:focus:ring-gray-100">
158
157
  <button
159
158
  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
- >
159
+ class="w-full rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300">
162
160
  Connect
163
161
  </button>
164
162
  </form>
165
163
 
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>
164
+ <div class="my-5 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
165
+ <span class="h-px flex-1 bg-gray-200 dark:bg-gray-800"></span>
168
166
  or
169
- <span class="h-px flex-1 bg-gray-200"></span>
167
+ <span class="h-px flex-1 bg-gray-200 dark:bg-gray-800"></span>
170
168
  </div>
171
169
 
172
170
  <button
173
171
  type="button"
174
172
  onclick={() => void start()}
175
173
  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
- >
174
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-60 dark:border-gray-700 dark:hover:bg-gray-800">
178
175
  {starting ? 'Starting…' : 'Start server'}
179
176
  </button>
180
177
 
181
178
  {#if error}
182
- <p class="mt-4 text-center text-sm text-red-600">{error}</p>
179
+ <p class="mt-4 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
183
180
  {/if}
184
181
 
185
- <p class="mt-8 text-center text-xs text-gray-400">
182
+ <p class="mt-8 text-center text-xs text-gray-400 dark:text-gray-500">
186
183
  made with
187
- <a href="https://github.com/briancray/belte" class="underline hover:text-gray-600">
184
+ <a
185
+ href="https://github.com/briancray/belte"
186
+ class="underline hover:text-gray-600 dark:hover:text-gray-300">
188
187
  belte
189
188
  </a>
190
189
  </p>
@@ -0,0 +1,24 @@
1
+ import { dlopen, FFIType, type Pointer } from 'bun:ffi'
2
+
3
+ /*
4
+ Wires belte's native download delegate onto the bundle's WKWebView, so `<a
5
+ download>` clicks, blob:/data: links, and attachment responses save a real file
6
+ into the user's Downloads folder and reveal it in Finder — the bare upstream
7
+ webview sets no navigation delegate and silently drops all of these.
8
+
9
+ A no-op off macOS (the shim symbol isn't compiled into the library there) and on
10
+ macOS before 11.3 (no WKDownload API). Opened as its own short-lived handle,
11
+ mirroring installMacMenu, to keep openWebview's FFI map fully typed — a
12
+ conditional symbol there defeats Bun's argument-type inference. The delegate
13
+ attaches to the live WKWebView, so it persists after this handle closes.
14
+ */
15
+ export function installDownloads(libPath: string, webviewHandle: Pointer | null): void {
16
+ if (process.platform !== 'darwin') {
17
+ return
18
+ }
19
+ const { symbols, close } = dlopen(libPath, {
20
+ belte_install_downloads: { args: [FFIType.ptr], returns: FFIType.void },
21
+ })
22
+ symbols.belte_install_downloads(webviewHandle)
23
+ close()
24
+ }
@@ -6,6 +6,8 @@
6
6
  // the built-in File menu (Start / Disconnect), and the bundle's custom menus
7
7
  // whose items emit events into the page.
8
8
  #import <Cocoa/Cocoa.h>
9
+ #import <WebKit/WebKit.h>
10
+ #import <objc/runtime.h>
9
11
  #include <cstdlib>
10
12
  #include <cstring>
11
13
 
@@ -15,6 +17,9 @@ extern "C" int webview_eval(void *w, const char *js);
15
17
  // Marshals a callback onto the UI thread; the only safe way to touch the window
16
18
  // from another thread (the launcher runs its control server off the main thread).
17
19
  extern "C" int webview_dispatch(void *w, void (*fn)(void *w, void *arg), void *arg);
20
+ // Returns a backend-native handle for the webview; with kind
21
+ // WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER (2) that's the WKWebView.
22
+ extern "C" void *webview_get_native_handle(void *w, int kind);
18
23
 
19
24
  // Exported despite -fvisibility=hidden so belte's FFI layer can resolve it.
20
25
  #define BELTE_EXPORT __attribute__((visibility("default")))
@@ -296,3 +301,122 @@ extern "C" BELTE_EXPORT void belte_install_app_menu(void *webview_handle,
296
301
  [app setMainMenu:mainMenu];
297
302
  }
298
303
  }
304
+
305
+ // WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER — the WKWebView pointer.
306
+ static const int kBelteBrowserController = 2;
307
+
308
+ // Associated-object key under which each WKDownload stashes its chosen
309
+ // destination URL, so downloadDidFinish: can reveal the saved file in Finder.
310
+ static const char kBelteDownloadDestKey = 0;
311
+
312
+ /*
313
+ Navigation + download delegate for the bundle's WKWebView. The upstream webview
314
+ sets no navigation delegate, so WKWebView silently drops `<a download>` clicks,
315
+ blob:/data: downloads, and attachment responses — leaving every belte bundle app
316
+ unable to save a file. This routes those to a real download saved into the user's
317
+ Downloads folder and reveals it in Finder, while passing every ordinary
318
+ navigation straight through (the app's own page loads must not be hijacked).
319
+ A process-lifetime singleton, never released (MRC), matching the menu objects
320
+ above; WKWebView holds its navigationDelegate weakly, so the strong global is
321
+ what keeps it alive.
322
+ */
323
+ API_AVAILABLE(macos(11.3))
324
+ @interface BelteDownloadDelegate : NSObject <WKNavigationDelegate, WKDownloadDelegate>
325
+ @end
326
+
327
+ @implementation BelteDownloadDelegate
328
+
329
+ // A link with a `download` attribute (e.g. URL.createObjectURL + a.download)
330
+ // sets shouldPerformDownload; turn only those into downloads and allow the rest
331
+ // — notably the app's own navigations, which must load normally.
332
+ - (void)webView:(WKWebView *)webView
333
+ decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction
334
+ decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
335
+ if (navigationAction.shouldPerformDownload) {
336
+ decisionHandler(WKNavigationActionPolicyDownload);
337
+ } else {
338
+ decisionHandler(WKNavigationActionPolicyAllow);
339
+ }
340
+ }
341
+
342
+ // A response the webview can't render (or that the server marks as an
343
+ // attachment) becomes a download too, mirroring how a browser behaves.
344
+ - (void)webView:(WKWebView *)webView
345
+ decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse
346
+ decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler {
347
+ if (navigationResponse.canShowMIMEType) {
348
+ decisionHandler(WKNavigationResponsePolicyAllow);
349
+ } else {
350
+ decisionHandler(WKNavigationResponsePolicyDownload);
351
+ }
352
+ }
353
+
354
+ - (void)webView:(WKWebView *)webView
355
+ navigationAction:(WKNavigationAction *)navigationAction
356
+ didBecomeDownload:(WKDownload *)download {
357
+ download.delegate = self;
358
+ }
359
+
360
+ - (void)webView:(WKWebView *)webView
361
+ navigationResponse:(WKNavigationResponse *)navigationResponse
362
+ didBecomeDownload:(WKDownload *)download {
363
+ download.delegate = self;
364
+ }
365
+
366
+ // Save under ~/Downloads using the browser-suggested name, de-duplicating with a
367
+ // " (n)" suffix so a repeat export never silently clobbers the previous file.
368
+ - (void)download:(WKDownload *)download
369
+ decideDestinationUsingResponse:(NSURLResponse *)response
370
+ suggestedFilename:(NSString *)suggestedFilename
371
+ completionHandler:(void (^)(NSURL *))completionHandler {
372
+ NSFileManager *fm = [NSFileManager defaultManager];
373
+ NSURL *dir =
374
+ [[fm URLsForDirectory:NSDownloadsDirectory inDomains:NSUserDomainMask] firstObject];
375
+ if (dir == nil) {
376
+ dir = [NSURL fileURLWithPath:NSHomeDirectory()];
377
+ }
378
+ NSString *name = suggestedFilename.length ? suggestedFilename : @"download";
379
+ NSURL *dest = [dir URLByAppendingPathComponent:name];
380
+ NSString *base = [name stringByDeletingPathExtension];
381
+ NSString *ext = [name pathExtension];
382
+ for (int i = 1; [fm fileExistsAtPath:dest.path]; i++) {
383
+ NSString *candidate =
384
+ ext.length ? [NSString stringWithFormat:@"%@ (%d).%@", base, i, ext]
385
+ : [NSString stringWithFormat:@"%@ (%d)", base, i];
386
+ dest = [dir URLByAppendingPathComponent:candidate];
387
+ }
388
+ objc_setAssociatedObject(download, &kBelteDownloadDestKey, dest,
389
+ OBJC_ASSOCIATION_RETAIN_NONATOMIC);
390
+ completionHandler(dest);
391
+ }
392
+
393
+ - (void)downloadDidFinish:(WKDownload *)download {
394
+ NSURL *dest = objc_getAssociatedObject(download, &kBelteDownloadDestKey);
395
+ if (dest != nil) {
396
+ [[NSWorkspace sharedWorkspace] activateFileViewerSelectingURLs:@[ dest ]];
397
+ }
398
+ }
399
+
400
+ @end
401
+
402
+ /*
403
+ Attaches the download delegate to the bundle's WKWebView. Safe to call after
404
+ webview_create and must run before the first navigation. A no-op on macOS
405
+ versions before 11.3 (no WKDownload API) — there downloads stay unsupported, as
406
+ they were. The delegate is a strong process-lifetime singleton because WKWebView
407
+ holds its navigationDelegate weakly.
408
+ */
409
+ extern "C" BELTE_EXPORT void belte_install_downloads(void *webview_handle) {
410
+ if (@available(macOS 11.3, *)) {
411
+ WKWebView *webView =
412
+ (WKWebView *)webview_get_native_handle(webview_handle, kBelteBrowserController);
413
+ if (webView == nil) {
414
+ return;
415
+ }
416
+ static BelteDownloadDelegate *delegate = nil;
417
+ if (delegate == nil) {
418
+ delegate = [[BelteDownloadDelegate alloc] init];
419
+ }
420
+ webView.navigationDelegate = delegate;
421
+ }
422
+ }
@@ -1,5 +1,6 @@
1
1
  import { dlopen, FFIType, type Pointer } from 'bun:ffi'
2
2
  import type { BundleMenu } from './BundleMenu.ts'
3
+ import { installDownloads } from './installDownloads.ts'
3
4
  import { installMacMenu } from './installMacMenu.ts'
4
5
  import { resolveWebviewLib } from './resolveWebviewLib.ts'
5
6
 
@@ -71,6 +72,12 @@ export async function openWebview({
71
72
  upstream webview omits the menu entirely — plus the bundle's custom menus.
72
73
  */
73
74
  installMacMenu(libPath, handle, title, menu, fileMenu)
75
+ /*
76
+ Attach the download delegate (no-op off macOS / before 11.3) before the
77
+ first navigation, so `<a download>`, blob/data links, and attachment
78
+ responses save a file instead of being silently dropped by the bare webview.
79
+ */
80
+ installDownloads(libPath, handle)
74
81
  onWindow?.(handle)
75
82
 
76
83
  symbols.webview_navigate(handle, cString(url))
@@ -6,4 +6,4 @@ cache key alongside the upstream version, so changing belte's native build
6
6
  selects a fresh cache path and bypasses any library built before the change.
7
7
  Bump this whenever the shim sources or their compile invocation change.
8
8
  */
9
- export const webviewBuildRevision = 8
9
+ export const webviewBuildRevision = 9
@@ -6,7 +6,7 @@ const PLACEHOLDER = /\{\{\s*([\w-]+)\s*\}\}/g
6
6
  Renders a markdown prompt body by substituting each `{{name}}` placeholder
7
7
  with the matching argument value. Missing arguments collapse to an empty
8
8
  string — MCP only enforces `required` at the client, so an optional
9
- argument the model omits should simply vanish from the text. Called by the
9
+ argument the model omits should vanish from the text. Called by the
10
10
  render closure the resolver plugin generates for every `.md` prompt.
11
11
  */
12
12
  export function renderPromptTemplate(template: string, args: Record<string, string>): string {
@@ -15,7 +15,7 @@ carries the body shape through the function's inferred return type so the
15
15
  verb helper can infer `Return` automatically — no need to annotate
16
16
  `GET<Args, Return>` when the handler returns one of the respond helpers.
17
17
  A bare `new Response(...)` is still acceptable: the brand is optional, so
18
- untagged Responses simply fall back to `Return = unknown`.
18
+ untagged Responses fall back to `Return = unknown`.
19
19
 
20
20
  Handlers that need the inbound Request (headers, `request.signal`, …) read
21
21
  it via `request()` from `belte/server` rather than a handler parameter, so
@@ -7,7 +7,7 @@ belte directly (`@briancray/belte`) or behind a package alias
7
7
  (`"belte": "npm:@briancray/belte@..."`, or `workspace:@briancray/belte@*`
8
8
  inside this repo). An alias-only install resolves only under the alias key and
9
9
  a direct install only under the canonical name, so the generated rpc / socket
10
- / prompt modules must import under whichever name the project actually
10
+ / prompt modules must import under whichever name the project
11
11
  declared.
12
12
 
13
13
  Prefers a `belte` alias (the ergonomic surface the docs use) when present, then
@@ -176,7 +176,7 @@ async function* parseJsonLines<T>(response: Response): AsyncGenerator<T> {
176
176
  Builds the Subscribable returned by `fn.stream(args)`. The carried
177
177
  `name` is the cache-style key for (method, url, args) so subscribe()
178
178
  dedupes multiple subscribers to identical args into one underlying
179
- fetch. The fetch is deferred until the first iterator pull so simply
179
+ fetch. The fetch is deferred until the first iterator pull so
180
180
  constructing the Subscribable (which happens on every $derived
181
181
  re-evaluation) doesn't open a connection — subscribe()'s registry
182
182
  short-circuits the second instance before it iterates.