@livx.cc/appwrap 0.42.3 → 0.43.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 +4 -2
- package/runtime/app/app.ts +15 -0
- package/runtime/app/main-page.ts +11 -2
- package/runtime/app/shell/custom-webview.android.ts +81 -46
- package/runtime/app/shell/custom-webview.ios.ts +18 -8
- package/runtime/app/shell/handlers-android.ts +25 -6
- package/runtime/app/shell/handlers-background.ts +1 -1
- package/runtime/app/shell/web-quirks.ts +63 -0
- package/runtime/webpack.config.js +20 -0
- package/runtime-desktop/autotest/index.html +77 -0
- package/runtime-desktop/src-tauri/Cargo.lock +5459 -0
- package/runtime-desktop/src-tauri/Cargo.toml +38 -0
- package/runtime-desktop/src-tauri/build.rs +3 -0
- package/runtime-desktop/src-tauri/capabilities/default.json +6 -0
- package/runtime-desktop/src-tauri/icons/icon.png +0 -0
- package/runtime-desktop/src-tauri/shell_config.json +10 -0
- package/runtime-desktop/src-tauri/src/biometrics_mac.rs +138 -0
- package/runtime-desktop/src-tauri/src/main.rs +683 -0
- package/runtime-desktop/src-tauri/src/notifications_mac.rs +202 -0
- package/runtime-desktop/src-tauri/src/oauth_mac.rs +139 -0
- package/runtime-desktop/src-tauri/src/popup_mac.rs +466 -0
- package/runtime-desktop/src-tauri/src/push_mac.rs +193 -0
- package/runtime-desktop/src-tauri/src/sidecar.rs +398 -0
- package/runtime-desktop/src-tauri/tauri.conf.json +13 -0
- package/scripts/stage-assets.mjs +4 -2
- package/src/cli.ts +621 -48
- package/src/config.ts +40 -1
- package/src/desktop.ts +246 -0
- package/src/handlers.ts +71 -0
- package/src/icon.ts +207 -0
package/src/config.ts
CHANGED
|
@@ -51,6 +51,45 @@ export interface AppwrapConfig {
|
|
|
51
51
|
* Set 0 for no extra lift (input flush at the reported height; the strip may show). */
|
|
52
52
|
iosKeyboardExtraLift?: number;
|
|
53
53
|
pwaDist: string;
|
|
54
|
+
/** Desktop (Tauri chassis) shell — phase-0, macOS-only. When present, `appwrap dev|build desktop`
|
|
55
|
+
* stamps a generated Tauri wrapper (`native-desktop/`). Identity (`id`/`name`/`version`), `urlScheme`
|
|
56
|
+
* (internal deep-link loopback) and `pwaDist` (→ Tauri `frontendDist`) come from the top-level config;
|
|
57
|
+
* this only carries the desktop-window specifics. Absent → the desktop lane is inert (mobile-only app). */
|
|
58
|
+
desktop?: {
|
|
59
|
+
/** Window title (default: `name`). */
|
|
60
|
+
title?: string;
|
|
61
|
+
/** Initial window inner width in px (default 430 — a phone-ish portrait frame). */
|
|
62
|
+
width?: number;
|
|
63
|
+
/** Initial window inner height in px (default 800). */
|
|
64
|
+
height?: number;
|
|
65
|
+
/** Bundle identifier for the desktop build (default: `${id}.desktop`). Reverse-DNS. */
|
|
66
|
+
identifier?: string;
|
|
67
|
+
/** Remember the window's size + position across launches (tauri-plugin-window-state). Default
|
|
68
|
+
* `true`. When `false`, the plugin isn't registered so every launch opens at the configured
|
|
69
|
+
* `width`/`height` (no saved state applied). */
|
|
70
|
+
restoreWindowState?: boolean;
|
|
71
|
+
/** Force Google's account chooser in `signInWithPopup` OAuth. The shared WKWebView cookie store
|
|
72
|
+
* keeps the Google session, so Firebase auto-signs the remembered account; for apps that run
|
|
73
|
+
* multiple instances against different accounts (e.g. Unclaw) set `true` to append
|
|
74
|
+
* `prompt=select_account` to the popup's `accounts.google.com` OAuth URL so the user always picks.
|
|
75
|
+
* Default `false` (remember-me behavior stays). macOS-only. */
|
|
76
|
+
googleAccountPicker?: boolean;
|
|
77
|
+
/** Launch the app automatically when the user logs in (tauri-plugin-autostart, macOS LaunchAgent).
|
|
78
|
+
* Default `false`. The shell registers a login item on first launch when `true`, and explicitly
|
|
79
|
+
* REMOVES it when `false`, so flipping this off actually clears the login item on the next launch.
|
|
80
|
+
* NOTE: an adhoc-signed `.app` (the phase-0 desktop build) still autostarts via the LaunchAgent
|
|
81
|
+
* plist — no Developer ID required. macOS-only. */
|
|
82
|
+
autostart?: boolean;
|
|
83
|
+
/** Path (relative to the app root) to a TypeScript file that exports custom native handlers via
|
|
84
|
+
* `defineHandlers({...})` from `@livx.cc/appwrap/handlers`. When present the desktop shell spawns a
|
|
85
|
+
* Bun sidecar (`bun <path>`) at launch and routes any method the sidecar advertises to it — so an
|
|
86
|
+
* app ships app-custom native capabilities (called from the PWA via `kit.invoke('myapp.foo', …)`)
|
|
87
|
+
* without touching the Rust shell. Absent → the feature is fully inert (no sidecar spawned).
|
|
88
|
+
* The CLI stamps the ABSOLUTE resolved path into the shell config; handlers run with their CWD at
|
|
89
|
+
* the app dir, so `build desktop` apps must keep their repo (+ node_modules) around at runtime —
|
|
90
|
+
* embedding the handler file into the `.app` is out of scope for phase-0. macOS-only. */
|
|
91
|
+
handlers?: string;
|
|
92
|
+
};
|
|
54
93
|
/** Custom URL scheme for deep links (e.g. "hellowrap" → hellowrap://...). */
|
|
55
94
|
urlScheme?: string;
|
|
56
95
|
/** Custom URL schemes `kit.app.canOpenUrl()` may probe (e.g. `['whatsapp', 'tg']`) — covers BOTH
|
|
@@ -241,7 +280,7 @@ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
|
|
|
241
280
|
*/
|
|
242
281
|
export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
|
243
282
|
'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
|
|
244
|
-
'debugLog', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'name',
|
|
283
|
+
'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'name',
|
|
245
284
|
'iosEntitlements', 'neutralizeServiceWorker', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
|
|
246
285
|
'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'signing', 'signingProfiles', 'statusBarStyle',
|
|
247
286
|
'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
|
package/src/desktop.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure desktop (Tauri-chassis) config derivation + stamp transforms — side-effect-free (no fs, no
|
|
3
|
+
* config I/O) so they unit-test against fixtures, same standard as derive.ts. The CLI wires these into
|
|
4
|
+
* the `dev|build desktop` lane: `deriveDesktopConfig` → the JSON the Rust shell reads (`shell_config.json`,
|
|
5
|
+
* embedded at compile time), `stampTauriConf` → the generated `tauri.conf.json`.
|
|
6
|
+
*/
|
|
7
|
+
import type { AppwrapConfig } from './config';
|
|
8
|
+
|
|
9
|
+
/** The resolved shell config the Rust desktop chassis reads (mirrors the `ShellConfig` struct in
|
|
10
|
+
* runtime-desktop/src-tauri/src/main.rs — keys are camelCase; Rust deserializes with rename_all). */
|
|
11
|
+
export interface DesktopShellConfig {
|
|
12
|
+
appId: string;
|
|
13
|
+
name: string;
|
|
14
|
+
version: string;
|
|
15
|
+
title: string;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
/** App's own deep-link scheme (e.g. "hellowrap"); '' when none — the Rust shell skips the
|
|
19
|
+
* `<scheme>://` loopback so an empty scheme can't spuriously match every url. */
|
|
20
|
+
urlScheme: string;
|
|
21
|
+
identifier: string;
|
|
22
|
+
/** Persist + restore window size/position across launches (default true). When false the Rust
|
|
23
|
+
* shell skips registering tauri-plugin-window-state, so the configured width/height always apply. */
|
|
24
|
+
restoreWindowState: boolean;
|
|
25
|
+
/** Append `prompt=select_account` to Google `signInWithPopup` OAuth URLs (default false) so a
|
|
26
|
+
* remembered Google session doesn't auto-sign — the user picks an account per instance. */
|
|
27
|
+
googleAccountPicker: boolean;
|
|
28
|
+
/** Launch on login via tauri-plugin-autostart (macOS LaunchAgent). Default false. The Rust shell
|
|
29
|
+
* enables the login item when true and explicitly disables it when false (so turning this off
|
|
30
|
+
* removes the item on the next launch). Works for the adhoc-signed .app (no Developer ID needed). */
|
|
31
|
+
autostart: boolean;
|
|
32
|
+
/** Path to the app's custom-handlers TS file for the Bun sidecar; '' when none (the Rust shell
|
|
33
|
+
* skips spawning the sidecar entirely). `deriveDesktopConfig` carries it through AS AUTHORED
|
|
34
|
+
* (relative to the app root); the CLI resolves it to an ABSOLUTE path before stamping
|
|
35
|
+
* `shell_config.json` (see `regenerateDesktop`), since the shell binary runs from elsewhere. */
|
|
36
|
+
handlers: string;
|
|
37
|
+
/** True when the CLI resolved a real signing identity + a macOS provisioning profile for this
|
|
38
|
+
* build (the .app will carry the aps-environment entitlement). Stamped by `build desktop` —
|
|
39
|
+
* never authored; gates the Rust shell's push capability + push.register. */
|
|
40
|
+
pushSigned: boolean;
|
|
41
|
+
/** Absolute path to the Bun binary the sidecar should be spawned with; '' until the CLI stamps it
|
|
42
|
+
* (from `process.execPath` — the exact runtime the app was built with). Needed because a
|
|
43
|
+
* GUI-launched .app inherits the launchd PATH (/usr/bin:/bin:...) which does NOT contain
|
|
44
|
+
* ~/.bun/bin, so a bare `bun` spawn fails. The Rust shell falls back to `bun` when this is
|
|
45
|
+
* missing/nonexistent. */
|
|
46
|
+
handlersRuntime: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve the desktop shell config from an appwrap config: identity + `urlScheme` come from the
|
|
50
|
+
* top-level fields, the window specifics from the `desktop` sub-shape (with lean defaults). */
|
|
51
|
+
export function deriveDesktopConfig(cfg: AppwrapConfig): DesktopShellConfig {
|
|
52
|
+
const d = cfg.desktop ?? {};
|
|
53
|
+
return {
|
|
54
|
+
appId: cfg.id,
|
|
55
|
+
name: cfg.name,
|
|
56
|
+
version: cfg.version,
|
|
57
|
+
title: d.title ?? cfg.name,
|
|
58
|
+
width: d.width ?? 430,
|
|
59
|
+
height: d.height ?? 800,
|
|
60
|
+
urlScheme: cfg.urlScheme ?? '',
|
|
61
|
+
identifier: d.identifier ?? `${cfg.id}.desktop`,
|
|
62
|
+
restoreWindowState: d.restoreWindowState ?? true,
|
|
63
|
+
googleAccountPicker: d.googleAccountPicker ?? false,
|
|
64
|
+
autostart: d.autostart ?? false,
|
|
65
|
+
handlers: d.handlers ?? '',
|
|
66
|
+
pushSigned: false, // stamped by the CLI when a signing identity + macOS profile resolve — see buildDesktop
|
|
67
|
+
handlersRuntime: '', // stamped by the CLI (process.execPath) when handlers is set — see regenerateDesktop
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Stamp a `tauri.conf.json` template object from the resolved shell config + the (src-tauri-relative)
|
|
72
|
+
* frontendDist. Pure: takes the parsed template, returns the stamped object (the CLI reads/writes). */
|
|
73
|
+
export function stampTauriConf(
|
|
74
|
+
template: Record<string, unknown>,
|
|
75
|
+
shell: DesktopShellConfig,
|
|
76
|
+
frontendDist: string,
|
|
77
|
+
): Record<string, unknown> {
|
|
78
|
+
const out: Record<string, unknown> = {
|
|
79
|
+
...template,
|
|
80
|
+
productName: shell.name,
|
|
81
|
+
version: shell.version,
|
|
82
|
+
identifier: shell.identifier,
|
|
83
|
+
build: { ...(template.build as Record<string, unknown> | undefined), frontendDist },
|
|
84
|
+
};
|
|
85
|
+
// tauri-plugin-deep-link needs its schemes declared for the desktop lane (used by the plugin's
|
|
86
|
+
// registration + the tauri bundler). We assemble the .app ourselves, but keep this in sync so the
|
|
87
|
+
// plugin config is honest. Omit entirely when the app declares no scheme.
|
|
88
|
+
if (shell.urlScheme) {
|
|
89
|
+
const plugins = { ...(template.plugins as Record<string, unknown> | undefined) };
|
|
90
|
+
plugins['deep-link'] = { desktop: { schemes: [shell.urlScheme] } };
|
|
91
|
+
out.plugins = plugins;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const xmlEscape = (s: string): string =>
|
|
97
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Generate the macOS `Contents/Info.plist` for the assembled `.app`. This is what registers the app's
|
|
101
|
+
* `urlScheme` with LaunchServices (CFBundleURLTypes) so a `<scheme>://…` open reaches the running shell
|
|
102
|
+
* via tauri-plugin-deep-link. `executable` = the copied Mach-O name (CFBundleExecutable must match the
|
|
103
|
+
* file in `Contents/MacOS/`); `iconFile` = the `.icns` basename when an icon was generated (else omit);
|
|
104
|
+
* `iconName` = the asset-catalog icon name (e.g. `AppIcon`) when an `Assets.car` was compiled — macOS 26
|
|
105
|
+
* (Tahoe) resolves the Finder/Dock icon via `CFBundleIconName`+`Assets.car`, not the legacy `.icns`.
|
|
106
|
+
*/
|
|
107
|
+
export function buildInfoPlist(
|
|
108
|
+
shell: DesktopShellConfig,
|
|
109
|
+
executable: string,
|
|
110
|
+
iconFile?: string,
|
|
111
|
+
iconName?: string,
|
|
112
|
+
): string {
|
|
113
|
+
const urlTypes = shell.urlScheme
|
|
114
|
+
? `
|
|
115
|
+
<key>CFBundleURLTypes</key>
|
|
116
|
+
<array>
|
|
117
|
+
<dict>
|
|
118
|
+
<key>CFBundleURLName</key>
|
|
119
|
+
<string>${xmlEscape(shell.identifier)}</string>
|
|
120
|
+
<key>CFBundleURLSchemes</key>
|
|
121
|
+
<array>
|
|
122
|
+
<string>${xmlEscape(shell.urlScheme)}</string>
|
|
123
|
+
</array>
|
|
124
|
+
</dict>
|
|
125
|
+
</array>`
|
|
126
|
+
: '';
|
|
127
|
+
const icon = iconFile
|
|
128
|
+
? `
|
|
129
|
+
<key>CFBundleIconFile</key>
|
|
130
|
+
<string>${xmlEscape(iconFile)}</string>`
|
|
131
|
+
: '';
|
|
132
|
+
const iconAsset = iconName
|
|
133
|
+
? `
|
|
134
|
+
<key>CFBundleIconName</key>
|
|
135
|
+
<string>${xmlEscape(iconName)}</string>`
|
|
136
|
+
: '';
|
|
137
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
138
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
139
|
+
<plist version="1.0">
|
|
140
|
+
<dict>
|
|
141
|
+
<key>CFBundleName</key>
|
|
142
|
+
<string>${xmlEscape(shell.name)}</string>
|
|
143
|
+
<key>CFBundleDisplayName</key>
|
|
144
|
+
<string>${xmlEscape(shell.name)}</string>
|
|
145
|
+
<key>CFBundleIdentifier</key>
|
|
146
|
+
<string>${xmlEscape(shell.identifier)}</string>
|
|
147
|
+
<key>CFBundleVersion</key>
|
|
148
|
+
<string>${xmlEscape(shell.version)}</string>
|
|
149
|
+
<key>CFBundleShortVersionString</key>
|
|
150
|
+
<string>${xmlEscape(shell.version)}</string>
|
|
151
|
+
<key>CFBundleExecutable</key>
|
|
152
|
+
<string>${xmlEscape(executable)}</string>
|
|
153
|
+
<key>CFBundlePackageType</key>
|
|
154
|
+
<string>APPL</string>
|
|
155
|
+
<key>NSHighResolutionCapable</key>
|
|
156
|
+
<true/>${icon}${iconAsset}${urlTypes}
|
|
157
|
+
</dict>
|
|
158
|
+
</plist>
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Generate the macOS code-signing entitlements plist for the signed desktop lane. macOS uses the
|
|
164
|
+
* PREFIXED keys (`com.apple.application-identifier`, `com.apple.developer.aps-environment`) — the
|
|
165
|
+
* bare iOS spellings (`application-identifier`, `aps-environment`) are NOT valid on macOS. The
|
|
166
|
+
* restricted entitlements here (app identifier + aps) only launch when backed by an embedded
|
|
167
|
+
* provisioning profile, so the caller must only include `apsEnvironment` when it embeds one.
|
|
168
|
+
* `apsEnvironment`: 'development' for Apple Development signing, 'production' for Developer ID.
|
|
169
|
+
*/
|
|
170
|
+
/** A macOS provisioning profile's decoded payload (the CMS-decoded `security cms -D` plist). */
|
|
171
|
+
export interface MacProfileInfo {
|
|
172
|
+
teamId?: string;
|
|
173
|
+
appId?: string;
|
|
174
|
+
expiration?: string;
|
|
175
|
+
/** The profile's OWN `com.apple.developer.aps-environment` entitlement (source of truth for the
|
|
176
|
+
* app entitlement — a cert-derived guess can claim `production` the profile doesn't back → launch kill). */
|
|
177
|
+
apsEnvironment?: string;
|
|
178
|
+
/** Base64 DER of each embedded DeveloperCertificate (whitespace-stripped). */
|
|
179
|
+
certificates: string[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Parse a CMS-decoded `.provisionprofile` plist (XML). Pure — unit-tested. */
|
|
183
|
+
export function parseMacProfile(plist: string): MacProfileInfo {
|
|
184
|
+
const teamId = plist.match(/<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/)?.[1];
|
|
185
|
+
const appId = plist.match(/<key>com\.apple\.application-identifier<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
|
|
186
|
+
const expiration = plist.match(/<key>ExpirationDate<\/key>\s*<date>([^<]+)<\/date>/)?.[1];
|
|
187
|
+
const apsEnvironment = plist.match(/<key>com\.apple\.developer\.aps-environment<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
|
|
188
|
+
const certBlock = plist.match(/<key>DeveloperCertificates<\/key>\s*<array>([\s\S]*?)<\/array>/)?.[1] ?? '';
|
|
189
|
+
const certificates = [...certBlock.matchAll(/<data>([\s\S]*?)<\/data>/g)].map((m) => m[1].replace(/\s+/g, ''));
|
|
190
|
+
return { teamId, appId, expiration, apsEnvironment, certificates };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function buildMacEntitlements(opts: {
|
|
194
|
+
teamId: string;
|
|
195
|
+
bundleId: string;
|
|
196
|
+
apsEnvironment?: 'development' | 'production';
|
|
197
|
+
}): string {
|
|
198
|
+
const aps = opts.apsEnvironment
|
|
199
|
+
? `
|
|
200
|
+
<key>com.apple.developer.aps-environment</key>
|
|
201
|
+
<string>${xmlEscape(opts.apsEnvironment)}</string>`
|
|
202
|
+
: '';
|
|
203
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
204
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
205
|
+
<plist version="1.0">
|
|
206
|
+
<dict>
|
|
207
|
+
<key>com.apple.application-identifier</key>
|
|
208
|
+
<string>${xmlEscape(`${opts.teamId}.${opts.bundleId}`)}</string>
|
|
209
|
+
<key>com.apple.developer.team-identifier</key>
|
|
210
|
+
<string>${xmlEscape(opts.teamId)}</string>${aps}
|
|
211
|
+
</dict>
|
|
212
|
+
</plist>
|
|
213
|
+
`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Result of locating a spawnable `cargo` for the desktop lane. `bin` is what to invoke (a bare
|
|
217
|
+
* `cargo` when it's already on PATH, else the absolute `<home>/.cargo/bin/cargo`); `path` is the
|
|
218
|
+
* (possibly augmented) PATH to hand the child so rustc/rustup neighbours resolve too. */
|
|
219
|
+
export interface CargoResolution {
|
|
220
|
+
found: boolean;
|
|
221
|
+
augmented: boolean;
|
|
222
|
+
bin: string;
|
|
223
|
+
path: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Pure PATH resolution for `cargo` (side-effect-free — fs access is injected via `hasCargo`), so it
|
|
227
|
+
* unit-tests against fixtures. If `cargo` isn't on any current PATH entry but `<home>/.cargo/bin/cargo`
|
|
228
|
+
* exists (rustup's default location, commonly missing from a GUI/login shell's PATH), append that dir
|
|
229
|
+
* and invoke the binary by absolute path. Returns `found:false` when it's genuinely nowhere — the
|
|
230
|
+
* caller turns that into an actionable "install rust via rustup / source $HOME/.cargo/env" message
|
|
231
|
+
* instead of a raw spawn ENOENT. */
|
|
232
|
+
export function resolveCargoPath(opts: {
|
|
233
|
+
path: string;
|
|
234
|
+
home: string;
|
|
235
|
+
delimiter: string;
|
|
236
|
+
hasCargo: (dir: string) => boolean;
|
|
237
|
+
}): CargoResolution {
|
|
238
|
+
const { path, home, delimiter, hasCargo } = opts;
|
|
239
|
+
const dirs = path.split(delimiter).filter(Boolean);
|
|
240
|
+
if (dirs.some(hasCargo)) return { found: true, augmented: false, bin: 'cargo', path };
|
|
241
|
+
const cargoBin = home ? `${home}/.cargo/bin` : '';
|
|
242
|
+
if (cargoBin && hasCargo(cargoBin)) {
|
|
243
|
+
return { found: true, augmented: true, bin: `${cargoBin}/cargo`, path: [...dirs, cargoBin].join(delimiter) };
|
|
244
|
+
}
|
|
245
|
+
return { found: false, augmented: false, bin: 'cargo', path };
|
|
246
|
+
}
|
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineHandlers` — author custom native handlers for the appwrap DESKTOP shell, run as a Bun sidecar.
|
|
3
|
+
*
|
|
4
|
+
* The desktop shell (runtime-desktop) spawns `bun <yourFile>` when `desktop.handlers` is set, and routes
|
|
5
|
+
* any method your map advertises to this process over NDJSON on stdin/stdout (appwrap sidecar protocol v1).
|
|
6
|
+
* The PWA calls them through the kit with a raw invoke: `kit.invoke('myapp.foo', { … })`.
|
|
7
|
+
*
|
|
8
|
+
* // handlers.ts (referenced by desktop.handlers in appwrap.config.ts)
|
|
9
|
+
* import { defineHandlers } from '@livx.cc/appwrap/handlers';
|
|
10
|
+
* defineHandlers({
|
|
11
|
+
* 'myapp.echo': (p) => ({ echoed: p }),
|
|
12
|
+
* 'myapp.addOne': (p) => ({ n: (p.n ?? 0) + 1 }),
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* Wire protocol (one JSON object per line):
|
|
16
|
+
* sidecar → shell (on spawn): { "kind":"ready", "methods":["myapp.echo", …] }
|
|
17
|
+
* shell → sidecar: { "kind":"request", "id":"…", "method":"…", "params":{…} }
|
|
18
|
+
* sidecar → shell (per req): { "kind":"response", "id":"…", "ok":true, "data":{…} }
|
|
19
|
+
* or: { "kind":"response", "id":"…", "ok":false, "error":{ "code":"…", "message":"…" } }
|
|
20
|
+
* A handler that throws is caught and returned as ok:false / NATIVE_ERROR — the shell maps that to the
|
|
21
|
+
* kit error envelope, so a bug in one handler never takes the sidecar down.
|
|
22
|
+
*/
|
|
23
|
+
import type { Readable, Writable } from 'node:stream';
|
|
24
|
+
|
|
25
|
+
export type HandlerFn = (params: any) => any | Promise<any>;
|
|
26
|
+
export type HandlerMap = Record<string, HandlerFn>;
|
|
27
|
+
|
|
28
|
+
/** Start the sidecar loop against process stdin/stdout. Call once, top-level, in the handlers file. */
|
|
29
|
+
export function defineHandlers(map: HandlerMap): void {
|
|
30
|
+
runHandlers(map, process.stdin, process.stdout);
|
|
31
|
+
// If the shell goes away (stdin closes) without killing us, don't linger as an orphan.
|
|
32
|
+
process.stdin.on('end', () => process.exit(0));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The dispatch loop, decoupled from process streams so it's unit-testable with in-memory streams. */
|
|
36
|
+
export function runHandlers(map: HandlerMap, input: Readable, output: Writable): void {
|
|
37
|
+
const write = (obj: unknown): void => { output.write(JSON.stringify(obj) + '\n'); };
|
|
38
|
+
// Announce the surface first — the shell blocks on this line (with a timeout) before going live.
|
|
39
|
+
write({ kind: 'ready', methods: Object.keys(map) });
|
|
40
|
+
|
|
41
|
+
let buf = '';
|
|
42
|
+
input.setEncoding('utf8');
|
|
43
|
+
input.on('data', (chunk: string) => {
|
|
44
|
+
buf += chunk;
|
|
45
|
+
let nl: number;
|
|
46
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
47
|
+
const line = buf.slice(0, nl).trim();
|
|
48
|
+
buf = buf.slice(nl + 1);
|
|
49
|
+
if (line) void handleLine(line);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function handleLine(line: string): Promise<void> {
|
|
54
|
+
let req: any;
|
|
55
|
+
try { req = JSON.parse(line); } catch { return; } // ignore malformed lines
|
|
56
|
+
if (!req || req.kind !== 'request') return;
|
|
57
|
+
const id = req.id;
|
|
58
|
+
const fn = map[req.method];
|
|
59
|
+
if (!fn) {
|
|
60
|
+
// The shell only forwards advertised methods, so this is defensive.
|
|
61
|
+
write({ kind: 'response', id, ok: false, error: { code: 'UNSUPPORTED', message: `${req.method} not implemented` } });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const data = await fn(req.params);
|
|
66
|
+
write({ kind: 'response', id, ok: true, data: data ?? null });
|
|
67
|
+
} catch (e) {
|
|
68
|
+
write({ kind: 'response', id, ok: false, error: { code: 'NATIVE_ERROR', message: e instanceof Error ? e.message : String(e) } });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/icon.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free PNG codec + macOS app-icon corner rounding, in pure TS on `node:zlib`.
|
|
3
|
+
*
|
|
4
|
+
* WHY: Tauri embeds `src-tauri/icons/icon.png` via `generate_context!` and sets it as the RUNTIME
|
|
5
|
+
* app icon on macOS — which OVERRIDES the bundle icns/Assets.car in cmd+tab/Dock. The template ships
|
|
6
|
+
* a placeholder square, so every built app shows that square while running. We stamp the app's real
|
|
7
|
+
* icon in instead: downscale (sips, in the CLI) → decode here → round the corners with the macOS
|
|
8
|
+
* app-icon squircle mask → ensure RGBA (Tauri hard-requires alpha) → re-encode.
|
|
9
|
+
*
|
|
10
|
+
* Supports 8-bit RGB / RGBA, non-interlaced PNG (what `sips` emits). Errors clearly on anything else.
|
|
11
|
+
*/
|
|
12
|
+
import { deflateSync, inflateSync } from 'zlib';
|
|
13
|
+
|
|
14
|
+
/** Apple's app-icon corner radius as a fraction of the icon side (the "squircle" bounding ratio).
|
|
15
|
+
* A plain rounded-rect at this radius is a close-enough approximation of the true superellipse. */
|
|
16
|
+
export const APPLE_CORNER_RATIO = 0.2237;
|
|
17
|
+
|
|
18
|
+
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
19
|
+
|
|
20
|
+
export interface RgbaImage {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
/** Row-major RGBA, 4 bytes/pixel, length = width*height*4. */
|
|
24
|
+
data: Buffer;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const paeth = (a: number, b: number, c: number): number => {
|
|
28
|
+
const p = a + b - c;
|
|
29
|
+
const pa = Math.abs(p - a);
|
|
30
|
+
const pb = Math.abs(p - b);
|
|
31
|
+
const pc = Math.abs(p - c);
|
|
32
|
+
if (pa <= pb && pa <= pc) return a;
|
|
33
|
+
if (pb <= pc) return b;
|
|
34
|
+
return c;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Decode an 8-bit, non-interlaced RGB/RGBA PNG into a normalized RGBA image. RGB input gets an
|
|
38
|
+
* opaque (255) alpha channel added. Throws on unsupported PNGs (paletted, 16-bit, interlaced, …). */
|
|
39
|
+
export function decodePng(buf: Buffer): RgbaImage {
|
|
40
|
+
if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIG)) throw new Error('not a PNG (bad signature)');
|
|
41
|
+
|
|
42
|
+
let width = 0;
|
|
43
|
+
let height = 0;
|
|
44
|
+
let bitDepth = 0;
|
|
45
|
+
let colorType = -1;
|
|
46
|
+
const idat: Buffer[] = [];
|
|
47
|
+
|
|
48
|
+
let off = 8;
|
|
49
|
+
while (off + 8 <= buf.length) {
|
|
50
|
+
const len = buf.readUInt32BE(off);
|
|
51
|
+
const type = buf.toString('ascii', off + 4, off + 8);
|
|
52
|
+
const dataStart = off + 8;
|
|
53
|
+
const data = buf.subarray(dataStart, dataStart + len);
|
|
54
|
+
if (type === 'IHDR') {
|
|
55
|
+
width = data.readUInt32BE(0);
|
|
56
|
+
height = data.readUInt32BE(4);
|
|
57
|
+
bitDepth = data[8];
|
|
58
|
+
colorType = data[9];
|
|
59
|
+
const interlace = data[12];
|
|
60
|
+
if (bitDepth !== 8) throw new Error(`unsupported PNG bit depth ${bitDepth} (only 8-bit)`);
|
|
61
|
+
if (colorType !== 2 && colorType !== 6)
|
|
62
|
+
throw new Error(`unsupported PNG color type ${colorType} (only RGB=2 / RGBA=6)`);
|
|
63
|
+
if (interlace !== 0) throw new Error('unsupported interlaced PNG');
|
|
64
|
+
} else if (type === 'IDAT') {
|
|
65
|
+
idat.push(Buffer.from(data));
|
|
66
|
+
} else if (type === 'IEND') {
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
off = dataStart + len + 4; // +4 CRC
|
|
70
|
+
}
|
|
71
|
+
if (!width || !height) throw new Error('PNG missing IHDR');
|
|
72
|
+
|
|
73
|
+
const channels = colorType === 6 ? 4 : 3;
|
|
74
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
75
|
+
const stride = width * channels;
|
|
76
|
+
if (raw.length < (stride + 1) * height) throw new Error('PNG IDAT truncated');
|
|
77
|
+
|
|
78
|
+
// Unfilter scanlines (filter types 0-4) in place into a channels-packed buffer.
|
|
79
|
+
const unf = Buffer.alloc(stride * height);
|
|
80
|
+
for (let y = 0; y < height; y++) {
|
|
81
|
+
const filter = raw[y * (stride + 1)];
|
|
82
|
+
const inRow = y * (stride + 1) + 1;
|
|
83
|
+
const outRow = y * stride;
|
|
84
|
+
for (let x = 0; x < stride; x++) {
|
|
85
|
+
const rawByte = raw[inRow + x];
|
|
86
|
+
const a = x >= channels ? unf[outRow + x - channels] : 0; // left
|
|
87
|
+
const b = y > 0 ? unf[outRow - stride + x] : 0; // up
|
|
88
|
+
const c = x >= channels && y > 0 ? unf[outRow - stride + x - channels] : 0; // up-left
|
|
89
|
+
let val: number;
|
|
90
|
+
switch (filter) {
|
|
91
|
+
case 0: val = rawByte; break;
|
|
92
|
+
case 1: val = rawByte + a; break;
|
|
93
|
+
case 2: val = rawByte + b; break;
|
|
94
|
+
case 3: val = rawByte + ((a + b) >> 1); break;
|
|
95
|
+
case 4: val = rawByte + paeth(a, b, c); break;
|
|
96
|
+
default: throw new Error(`unsupported PNG filter type ${filter}`);
|
|
97
|
+
}
|
|
98
|
+
unf[outRow + x] = val & 0xff;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Normalize to RGBA.
|
|
103
|
+
const data = Buffer.alloc(width * height * 4);
|
|
104
|
+
for (let i = 0, j = 0; i < width * height; i++) {
|
|
105
|
+
data[j++] = unf[i * channels];
|
|
106
|
+
data[j++] = unf[i * channels + 1];
|
|
107
|
+
data[j++] = unf[i * channels + 2];
|
|
108
|
+
data[j++] = channels === 4 ? unf[i * channels + 3] : 255;
|
|
109
|
+
}
|
|
110
|
+
return { width, height, data };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Encode a normalized RGBA image to a PNG (color type 6, 8-bit, filter type 0, zlib deflate). */
|
|
114
|
+
export function encodePng(img: RgbaImage): Buffer {
|
|
115
|
+
const { width, height, data } = img;
|
|
116
|
+
const stride = width * 4;
|
|
117
|
+
const rawFiltered = Buffer.alloc((stride + 1) * height);
|
|
118
|
+
for (let y = 0; y < height; y++) {
|
|
119
|
+
rawFiltered[y * (stride + 1)] = 0; // filter type 0 (None)
|
|
120
|
+
data.copy(rawFiltered, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
|
121
|
+
}
|
|
122
|
+
const idat = deflateSync(rawFiltered);
|
|
123
|
+
|
|
124
|
+
const chunk = (type: string, body: Buffer): Buffer => {
|
|
125
|
+
const out = Buffer.alloc(12 + body.length);
|
|
126
|
+
out.writeUInt32BE(body.length, 0);
|
|
127
|
+
out.write(type, 4, 'ascii');
|
|
128
|
+
body.copy(out, 8);
|
|
129
|
+
out.writeUInt32BE(crc32(out.subarray(4, 8 + body.length)), 8 + body.length);
|
|
130
|
+
return out;
|
|
131
|
+
};
|
|
132
|
+
const ihdr = Buffer.alloc(13);
|
|
133
|
+
ihdr.writeUInt32BE(width, 0);
|
|
134
|
+
ihdr.writeUInt32BE(height, 4);
|
|
135
|
+
ihdr[8] = 8; // bit depth
|
|
136
|
+
ihdr[9] = 6; // color type RGBA
|
|
137
|
+
ihdr[10] = 0; // compression
|
|
138
|
+
ihdr[11] = 0; // filter
|
|
139
|
+
ihdr[12] = 0; // interlace
|
|
140
|
+
return Buffer.concat([PNG_SIG, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Multiply the alpha channel by a rounded-rect mask (radius = ratio*side) so corners become
|
|
144
|
+
* transparent — the macOS app-icon shape. Mutates + returns the image. */
|
|
145
|
+
export function roundCorners(img: RgbaImage, ratio: number = APPLE_CORNER_RATIO): RgbaImage {
|
|
146
|
+
const { width, height, data } = img;
|
|
147
|
+
const r = Math.min(width, height) * ratio;
|
|
148
|
+
for (let y = 0; y < height; y++) {
|
|
149
|
+
for (let x = 0; x < width; x++) {
|
|
150
|
+
// Distance into the nearest corner's radius square; only corners are clipped.
|
|
151
|
+
const dx = x < r ? r - (x + 0.5) : x + 0.5 > width - r ? x + 0.5 - (width - r) : 0;
|
|
152
|
+
const dy = y < r ? r - (y + 0.5) : y + 0.5 > height - r ? y + 0.5 - (height - r) : 0;
|
|
153
|
+
if (dx > 0 && dy > 0) {
|
|
154
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
155
|
+
// 1px anti-aliased edge: coverage 1 inside the radius, 0 outside, linear across the boundary.
|
|
156
|
+
const coverage = Math.max(0, Math.min(1, r - dist + 0.5));
|
|
157
|
+
const idx = (y * width + x) * 4 + 3;
|
|
158
|
+
data[idx] = Math.round(data[idx] * coverage);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return img;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Center the image on a larger transparent canvas (no resampling — callers pre-size via sips).
|
|
166
|
+
* macOS app icons bake in a transparent margin: the squircle artwork is 824px on a 1024 canvas
|
|
167
|
+
* (~80.5%). A full-bleed runtime icon renders visibly LARGER than its bundle-masked Dock neighbors. */
|
|
168
|
+
export function padToCanvas(img: RgbaImage, canvas: number): RgbaImage {
|
|
169
|
+
const { width, height, data } = img;
|
|
170
|
+
const ox = Math.floor((canvas - width) / 2);
|
|
171
|
+
const oy = Math.floor((canvas - height) / 2);
|
|
172
|
+
const out = Buffer.alloc(canvas * canvas * 4);
|
|
173
|
+
for (let y = 0; y < height; y++) {
|
|
174
|
+
out.set(data.subarray(y * width * 4, (y + 1) * width * 4), ((y + oy) * canvas + ox) * 4);
|
|
175
|
+
}
|
|
176
|
+
return { width: canvas, height: canvas, data: out };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Artwork fraction of the icon canvas per Apple's Big Sur+ app-icon grid (824/1024). */
|
|
180
|
+
export const APPLE_ICON_GRID_SCALE = 824 / 1024;
|
|
181
|
+
|
|
182
|
+
/** Convenience: decode a (sips-produced) PNG, round its corners, guarantee RGBA, re-encode. */
|
|
183
|
+
export function makeRoundedRgbaIcon(pngBuf: Buffer, ratio: number = APPLE_CORNER_RATIO): Buffer {
|
|
184
|
+
return encodePng(roundCorners(decodePng(pngBuf), ratio));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Dock-parity runtime icon: rounded artwork centered on a transparent `canvas`² px canvas at the
|
|
188
|
+
* Apple icon-grid scale. Input PNG should already be sized to `canvas * APPLE_ICON_GRID_SCALE`. */
|
|
189
|
+
export function makeDockRuntimeIcon(pngBuf: Buffer, canvas: number): Buffer {
|
|
190
|
+
return encodePng(padToCanvas(roundCorners(decodePng(pngBuf)), canvas));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── CRC32 (PNG/zlib polynomial) ───
|
|
194
|
+
let CRC_TABLE: Uint32Array | null = null;
|
|
195
|
+
function crc32(buf: Buffer): number {
|
|
196
|
+
if (!CRC_TABLE) {
|
|
197
|
+
CRC_TABLE = new Uint32Array(256);
|
|
198
|
+
for (let n = 0; n < 256; n++) {
|
|
199
|
+
let c = n;
|
|
200
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
201
|
+
CRC_TABLE[n] = c >>> 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
let crc = 0xffffffff;
|
|
205
|
+
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
|
206
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
207
|
+
}
|