@briancray/belte 0.2.1 → 0.3.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/package.json +1 -1
- package/src/belteResolverPlugin.ts +12 -8
- package/src/lib/bundle/installDownloads.ts +24 -0
- package/src/lib/bundle/native/belteMenu.mm +124 -0
- package/src/lib/bundle/openWebview.ts +7 -0
- package/src/lib/bundle/webviewBuildRevision.ts +1 -1
- package/src/lib/server/prompts/renderPromptTemplate.ts +1 -1
- package/src/lib/server/rpc/types/RemoteHandler.ts +1 -1
- package/src/lib/shared/belteImportName.ts +1 -1
- package/src/lib/shared/subscribableFromResponse.ts +1 -1
- package/src/lib/shared/writeRoutesDts.ts +7 -2
- package/template/package.json +1 -1
- package/template/src/app.ts +1 -1
- package/template/src/browser/pages/page.svelte +1 -1
- package/template/src/server/rpc/getHello.ts +2 -2
- package/template/svelte.config.js +1 -1
- package/template/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -112,6 +112,13 @@ export function belteResolverPlugin({
|
|
|
112
112
|
const promptsDir = `${mcpDir}/prompts`
|
|
113
113
|
const resourcesDir = `${mcpDir}/resources`
|
|
114
114
|
|
|
115
|
+
/*
|
|
116
|
+
The bare specifier the project imports belte under (canonical
|
|
117
|
+
`@briancray/belte` or a package alias). Resolved once from the project's
|
|
118
|
+
package.json and threaded into every generated module so the codegen's
|
|
119
|
+
imports resolve regardless of which install style the project uses.
|
|
120
|
+
*/
|
|
121
|
+
const belteImportNameOnce = once(() => belteImportName(cwd))
|
|
115
122
|
/*
|
|
116
123
|
The whole-tree validation + per-leaf classification only needs to run
|
|
117
124
|
once per build. Memoise the promise so the virtual manifests
|
|
@@ -121,7 +128,11 @@ export function belteResolverPlugin({
|
|
|
121
128
|
*/
|
|
122
129
|
const scanPagesOnce = once(() =>
|
|
123
130
|
scanPages(pagesDir).then(async (scan) => {
|
|
124
|
-
await writeRoutesDts({
|
|
131
|
+
await writeRoutesDts({
|
|
132
|
+
cwd,
|
|
133
|
+
pageFiles: scan.pageFiles,
|
|
134
|
+
importName: await belteImportNameOnce(),
|
|
135
|
+
})
|
|
125
136
|
return scan
|
|
126
137
|
}),
|
|
127
138
|
)
|
|
@@ -129,13 +140,6 @@ export function belteResolverPlugin({
|
|
|
129
140
|
const scanSocketsOnce = once(() => scanSockets(socketsDir))
|
|
130
141
|
const scanPromptsOnce = once(() => scanPrompts(promptsDir))
|
|
131
142
|
const loadShellOnce = once(() => loadShell(cwd))
|
|
132
|
-
/*
|
|
133
|
-
The bare specifier the project imports belte under (canonical
|
|
134
|
-
`@briancray/belte` or a package alias). Resolved once from the project's
|
|
135
|
-
package.json and threaded into every generated module so the codegen's
|
|
136
|
-
imports resolve regardless of which install style the project uses.
|
|
137
|
-
*/
|
|
138
|
-
const belteImportNameOnce = once(() => belteImportName(cwd))
|
|
139
143
|
|
|
140
144
|
const rpcFilter = new RegExp(`^${escapeRegex(rpcDir)}/.*\\.ts$`)
|
|
141
145
|
const socketsFilter = new RegExp(`^${escapeRegex(socketsDir)}/.*\\.ts$`)
|
|
@@ -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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
@@ -30,14 +30,19 @@ page file in the project. Page picks this up as a discriminated union keyed
|
|
|
30
30
|
on `route`, so `if (page.route === '/media/[id]') page.params.id` is typed
|
|
31
31
|
automatically without consumers writing route types by hand.
|
|
32
32
|
The file is written to `src/.belte/routes.d.ts` so the consumer's existing
|
|
33
|
-
src tsconfig include picks it up with no extra configuration.
|
|
33
|
+
src tsconfig include picks it up with no extra configuration. The augmented
|
|
34
|
+
module is keyed on the name the project imports belte under (`importName`),
|
|
35
|
+
so the augmentation matches the consumer's `page` import whether belte is
|
|
36
|
+
installed directly (`@briancray/belte`) or behind an alias.
|
|
34
37
|
*/
|
|
35
38
|
export async function writeRoutesDts({
|
|
36
39
|
cwd,
|
|
37
40
|
pageFiles,
|
|
41
|
+
importName,
|
|
38
42
|
}: {
|
|
39
43
|
cwd: string
|
|
40
44
|
pageFiles: string[]
|
|
45
|
+
importName: string
|
|
41
46
|
}): Promise<void> {
|
|
42
47
|
const entries = pageFiles
|
|
43
48
|
.map((file) => ({
|
|
@@ -50,7 +55,7 @@ export async function writeRoutesDts({
|
|
|
50
55
|
)
|
|
51
56
|
.join('\n')
|
|
52
57
|
const contents = `// Generated by belte. Do not edit by hand.
|
|
53
|
-
declare module '
|
|
58
|
+
declare module '${importName}/browser/page' {
|
|
54
59
|
interface Routes {
|
|
55
60
|
${entries}
|
|
56
61
|
}
|
package/template/package.json
CHANGED
package/template/src/app.ts
CHANGED
|
@@ -7,7 +7,7 @@ module — no import is needed from your own code.
|
|
|
7
7
|
handle middleware wrapping the default request pipeline
|
|
8
8
|
handleError custom 500 fallback
|
|
9
9
|
*/
|
|
10
|
-
import type { AppModule } from 'belte/server/AppModule'
|
|
10
|
+
import type { AppModule } from '@briancray/belte/server/AppModule'
|
|
11
11
|
|
|
12
12
|
export const init: AppModule['init'] = ({ server }) => {
|
|
13
13
|
console.log(`server listening on http://localhost:${server.port}`)
|
|
@@ -3,7 +3,7 @@ Root page — served at GET /. Every folder under src/browser/pages/ that
|
|
|
3
3
|
contains a page.svelte mounts at that folder's URL.
|
|
4
4
|
-->
|
|
5
5
|
<script lang="ts">
|
|
6
|
-
import { cache } from 'belte/browser/cache'
|
|
6
|
+
import { cache } from '@briancray/belte/browser/cache'
|
|
7
7
|
import { getHello } from '$server/rpc/getHello.ts'
|
|
8
8
|
|
|
9
9
|
/*
|
|
@@ -27,7 +27,7 @@ Every rpc value also exposes `.raw(args?)` (returns the underlying
|
|
|
27
27
|
for callers that need headers/status or want to iterate SSE/JSONL frames.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { GET } from 'belte/server/GET'
|
|
31
|
-
import { json } from 'belte/server/json'
|
|
30
|
+
import { GET } from '@briancray/belte/server/GET'
|
|
31
|
+
import { json } from '@briancray/belte/server/json'
|
|
32
32
|
|
|
33
33
|
export const getHello = GET(() => json({ message: 'Hello from belte' }))
|
|
@@ -3,7 +3,7 @@ Optional Svelte compiler configuration. Same shape as upstream Svelte.
|
|
|
3
3
|
Delete this file to use defaults.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/** @type {import('belte').SvelteConfig} */
|
|
6
|
+
/** @type {import('@briancray/belte').SvelteConfig} */
|
|
7
7
|
export default {
|
|
8
8
|
compilerOptions: {
|
|
9
9
|
// Opt in to top-level await inside Svelte components.
|
package/template/tsconfig.json
CHANGED