@pikku/deploy-standalone 0.12.12 → 0.12.13

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/adapter.d.ts +34 -0
  3. package/dist/adapter.js +184 -4
  4. package/dist/runtime/index.d.ts +9 -0
  5. package/dist/runtime/index.js +8 -0
  6. package/dist/runtime/parent-watch.d.ts +45 -0
  7. package/dist/runtime/parent-watch.js +87 -0
  8. package/dist/tauri/generate.d.ts +45 -0
  9. package/dist/tauri/generate.js +230 -0
  10. package/dist/tauri/icon.d.ts +1 -0
  11. package/dist/tauri/icon.js +54 -0
  12. package/dist/tauri/main-rs.d.ts +31 -0
  13. package/dist/tauri/main-rs.js +213 -0
  14. package/dist/tauri/next-steps.d.ts +15 -0
  15. package/dist/tauri/next-steps.js +16 -0
  16. package/dist/tauri/target-triple.d.ts +29 -0
  17. package/dist/tauri/target-triple.js +42 -0
  18. package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
  19. package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
  20. package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
  21. package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
  22. package/knowledge/decisions/index.md +19 -0
  23. package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
  24. package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
  25. package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
  26. package/knowledge/index.md +22 -0
  27. package/package.json +6 -4
  28. package/src/adapter.test.ts +186 -0
  29. package/src/adapter.ts +210 -4
  30. package/src/desktop-deploy.test.ts +167 -0
  31. package/src/runtime/index.ts +13 -0
  32. package/src/runtime/parent-watch.process.test.ts +112 -0
  33. package/src/runtime/parent-watch.test.ts +148 -0
  34. package/src/runtime/parent-watch.ts +115 -0
  35. package/src/sidecar-entry.test.ts +89 -0
  36. package/src/tauri/generate.test.ts +401 -0
  37. package/src/tauri/generate.ts +327 -0
  38. package/src/tauri/icon.test.ts +63 -0
  39. package/src/tauri/icon.ts +62 -0
  40. package/src/tauri/main-rs.rustfmt.test.ts +86 -0
  41. package/src/tauri/main-rs.ts +241 -0
  42. package/src/tauri/next-steps.test.ts +38 -0
  43. package/src/tauri/next-steps.ts +30 -0
  44. package/src/tauri/target-triple.test.ts +84 -0
  45. package/src/tauri/target-triple.ts +65 -0
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,230 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { renderPlaceholderIcon } from './icon.js';
5
+ import { renderMainRs } from './main-rs.js';
6
+ import { hostTargetTriple, sidecarFileName } from './target-triple.js';
7
+ /** Directory the shell crate is generated into, relative to the project root. */
8
+ export const TAURI_SHELL_DIR = 'src-tauri';
9
+ /**
10
+ * Records the bytes this generator last wrote for each file, so a regenerate
11
+ * can tell "unchanged since we wrote it" from "the user has taken this over".
12
+ * Without it the only options are to clobber edits or to never update anything.
13
+ */
14
+ const MANIFEST_FILE = '.pikku-shell.json';
15
+ const ICON_SIZE = 512;
16
+ const hash = (content) => createHash('sha256').update(content).digest('hex');
17
+ const readManifest = async (shellDir) => {
18
+ try {
19
+ const parsed = JSON.parse(await readFile(join(shellDir, MANIFEST_FILE), 'utf-8'));
20
+ if (parsed && typeof parsed === 'object' && parsed.files)
21
+ return parsed;
22
+ }
23
+ catch {
24
+ // A missing or unreadable manifest means every existing file is the user's.
25
+ }
26
+ return { version: 1, files: {} };
27
+ };
28
+ /**
29
+ * A crate name, a file name and a bundle identifier segment all reject the same
30
+ * things, so one rule covers them.
31
+ */
32
+ const slug = (raw) => raw
33
+ .toLowerCase()
34
+ .replace(/[^a-z0-9]+/g, '-')
35
+ .replace(/^-+|-+$/g, '');
36
+ /**
37
+ * A reverse-DNS identifier for the bundle.
38
+ *
39
+ * A scoped package already names its org, so `@acme/shop` becomes
40
+ * `com.acme.shop`. An unscoped name has no org to borrow, and `com.shop.app`
41
+ * is not an option — macOS rejects an identifier ending in `.app`.
42
+ */
43
+ export const tauriBundleIdentifier = (packageName) => {
44
+ const scoped = /^@([^/]+)\/(.+)$/.exec(packageName);
45
+ if (scoped) {
46
+ return `com.${slug(scoped[1])}.${slug(scoped[2])}`;
47
+ }
48
+ return `com.${slug(packageName)}.desktop`;
49
+ };
50
+ const renderConfig = (options) => JSON.stringify({
51
+ $schema: 'https://schema.tauri.app/config/2',
52
+ productName: options.appName,
53
+ version: options.version,
54
+ identifier: options.identifier,
55
+ build: {
56
+ // The real UI is served by the sidecar over HTTP; the window is pointed
57
+ // at it from Rust once the port is known. Tauri still requires a
58
+ // frontend directory to exist, so a placeholder page stands in.
59
+ frontendDist: 'ui',
60
+ },
61
+ app: {
62
+ // A sidecar's origin is not known until it reports its port, so its
63
+ // window is built from Rust and this stays deliberately empty. A remote
64
+ // url is known here, and a declared window is the whole program.
65
+ windows: options.remoteUrl
66
+ ? [
67
+ {
68
+ label: 'main',
69
+ url: options.remoteUrl,
70
+ title: options.windowTitle,
71
+ width: options.width,
72
+ height: options.height,
73
+ },
74
+ ]
75
+ : [],
76
+ security: { csp: null },
77
+ },
78
+ bundle: {
79
+ active: true,
80
+ targets: 'all',
81
+ icon: ['icons/icon.png'],
82
+ ...(options.remoteUrl
83
+ ? {}
84
+ : { externalBin: [`binaries/${options.appName}`] }),
85
+ },
86
+ }, null, 2) + '\n';
87
+ const renderCargoToml = (options) => `[package]
88
+ name = "${options.crateName}"
89
+ version = "${options.version}"
90
+ edition = "2021"
91
+
92
+ [build-dependencies]
93
+ tauri-build = { version = "2", features = [] }
94
+
95
+ [dependencies]
96
+ tauri = { version = "2", features = [] }
97
+ ${options.remoteUrl ? '' : 'tauri-plugin-shell = "2"\n'}tauri-plugin-single-instance = "2"
98
+
99
+ [profile.release]
100
+ panic = "abort"
101
+ codegen-units = 1
102
+ lto = true
103
+ strip = true
104
+ `;
105
+ const PLACEHOLDER_UI = `<!doctype html>
106
+ <meta charset="utf-8" />
107
+ <title>Starting…</title>
108
+ <p>Starting…</p>
109
+ `;
110
+ const CAPABILITIES = JSON.stringify({
111
+ $schema: '../gen/schemas/desktop-schema.json',
112
+ identifier: 'default',
113
+ description: 'Baseline permissions for the pikku desktop shell.',
114
+ windows: ['main'],
115
+ permissions: ['core:default'],
116
+ }, null, 2);
117
+ const GITIGNORE = `/target
118
+ /binaries
119
+ /gen
120
+ `;
121
+ /**
122
+ * A webview can only open an http(s) origin, and everything the shell exists to
123
+ * preserve — first-party cookies, CORS, OAuth redirects — is keyed on it. A
124
+ * `file:` or custom-scheme url would build fine and then fail at runtime.
125
+ */
126
+ const normalizeRemoteUrl = (raw) => {
127
+ const trimmed = raw.trim();
128
+ let parsed;
129
+ try {
130
+ parsed = new URL(trimmed);
131
+ }
132
+ catch {
133
+ throw new Error(`"${raw}" is not a url the desktop shell could open.`);
134
+ }
135
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
136
+ throw new Error(`The desktop shell opens an http or https url; "${raw}" is ${parsed.protocol.replace(':', '')}.`);
137
+ }
138
+ return trimmed;
139
+ };
140
+ export const generateTauriShell = async (options) => {
141
+ const appName = options.appName;
142
+ if (!appName || slug(appName) !== appName) {
143
+ throw new Error(`"${appName}" is not a usable app name for a Tauri shell — use lowercase letters, digits and dashes (got the slug "${slug(appName)}").`);
144
+ }
145
+ const remoteUrl = options.remoteUrl
146
+ ? normalizeRemoteUrl(options.remoteUrl)
147
+ : undefined;
148
+ if (remoteUrl && options.binaryPath) {
149
+ throw new Error('A remote desktop shell runs no server of its own, so there is no sidecar to install the binary as. Drop either the url or the binary.');
150
+ }
151
+ const version = options.version ?? '0.1.0';
152
+ const windowTitle = options.windowTitle ?? appName;
153
+ const width = options.width ?? 1200;
154
+ const height = options.height ?? 800;
155
+ const targetTriple = options.targetTriple ?? hostTargetTriple();
156
+ const shellDir = join(options.projectDir, TAURI_SHELL_DIR);
157
+ const files = [
158
+ [
159
+ 'tauri.conf.json',
160
+ renderConfig({
161
+ appName,
162
+ identifier: options.identifier,
163
+ version,
164
+ windowTitle,
165
+ width,
166
+ height,
167
+ remoteUrl,
168
+ }),
169
+ ],
170
+ [
171
+ 'Cargo.toml',
172
+ renderCargoToml({ crateName: `${appName}-shell`, version, remoteUrl }),
173
+ ],
174
+ ['build.rs', 'fn main() {\n tauri_build::build()\n}\n'],
175
+ [
176
+ 'src/main.rs',
177
+ renderMainRs(remoteUrl
178
+ ? { remoteUrl, windowTitle, width, height }
179
+ : { sidecarName: appName, windowTitle, width, height }),
180
+ ],
181
+ ['ui/index.html', PLACEHOLDER_UI],
182
+ ['capabilities/default.json', CAPABILITIES],
183
+ ['icons/icon.png', renderPlaceholderIcon(ICON_SIZE)],
184
+ ['.gitignore', GITIGNORE],
185
+ ];
186
+ const manifest = await readManifest(shellDir);
187
+ const written = [];
188
+ const preserved = [];
189
+ for (const [relativePath, content] of files) {
190
+ const target = join(shellDir, relativePath);
191
+ const nextHash = hash(content);
192
+ let existing;
193
+ try {
194
+ existing = await readFile(target);
195
+ }
196
+ catch {
197
+ existing = undefined;
198
+ }
199
+ if (existing) {
200
+ const currentHash = hash(existing);
201
+ if (currentHash === nextHash) {
202
+ manifest.files[relativePath] = nextHash;
203
+ continue;
204
+ }
205
+ if (manifest.files[relativePath] !== currentHash) {
206
+ preserved.push(relativePath);
207
+ continue;
208
+ }
209
+ }
210
+ await mkdir(dirname(target), { recursive: true });
211
+ await writeFile(target, content);
212
+ manifest.files[relativePath] = nextHash;
213
+ written.push(relativePath);
214
+ }
215
+ let sidecar;
216
+ if (options.binaryPath) {
217
+ // Build output rather than source: always replaced, never diffed against
218
+ // the manifest, and gitignored.
219
+ const binary = await readFile(options.binaryPath);
220
+ const fileName = sidecarFileName(appName, targetTriple);
221
+ const target = join(shellDir, 'binaries', fileName);
222
+ await mkdir(dirname(target), { recursive: true });
223
+ await writeFile(target, binary);
224
+ await chmod(target, 0o755);
225
+ sidecar = { fileName, path: target };
226
+ }
227
+ await mkdir(shellDir, { recursive: true });
228
+ await writeFile(join(shellDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
229
+ return { dir: shellDir, written, preserved, targetTriple, sidecar };
230
+ };
@@ -0,0 +1 @@
1
+ export declare const renderPlaceholderIcon: (size: number) => Buffer;
@@ -0,0 +1,54 @@
1
+ import { crc32, deflateSync } from 'node:zlib';
2
+ /**
3
+ * A valid, deliberately plain app icon.
4
+ *
5
+ * Tauri's bundler refuses to package without one, so a generated shell has to
6
+ * ship something rather than leaving the first `tauri build` to fail on a
7
+ * missing file. Encoding it here keeps the generator free of binary fixtures
8
+ * and of an image dependency; `npx tauri icon <your-icon.png>` replaces it with
9
+ * the full platform set the moment a project has real artwork.
10
+ */
11
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
12
+ const chunk = (type, data) => {
13
+ const typeAndData = Buffer.concat([Buffer.from(type, 'ascii'), data]);
14
+ const length = Buffer.alloc(4);
15
+ length.writeUInt32BE(data.length);
16
+ const crc = Buffer.alloc(4);
17
+ crc.writeUInt32BE(crc32(typeAndData) >>> 0);
18
+ return Buffer.concat([length, typeAndData, crc]);
19
+ };
20
+ /** A flat slate square — recognisably a placeholder, and legible at any size. */
21
+ const FILL = [0x2f, 0x36, 0x40, 0xff];
22
+ export const renderPlaceholderIcon = (size) => {
23
+ if (!Number.isInteger(size) || size <= 0) {
24
+ throw new Error(`Icon size must be a positive integer, got ${size}`);
25
+ }
26
+ const stride = 1 + size * 4;
27
+ const raw = Buffer.alloc(size * stride);
28
+ for (let y = 0; y < size; y++) {
29
+ const rowStart = y * stride;
30
+ // Filter type 0 (None) — no prediction, so the row is its own pixels.
31
+ raw[rowStart] = 0;
32
+ for (let x = 0; x < size; x++) {
33
+ const px = rowStart + 1 + x * 4;
34
+ raw[px] = FILL[0];
35
+ raw[px + 1] = FILL[1];
36
+ raw[px + 2] = FILL[2];
37
+ raw[px + 3] = FILL[3];
38
+ }
39
+ }
40
+ const ihdr = Buffer.alloc(13);
41
+ ihdr.writeUInt32BE(size, 0);
42
+ ihdr.writeUInt32BE(size, 4);
43
+ ihdr.writeUInt8(8, 8);
44
+ ihdr.writeUInt8(6, 9);
45
+ ihdr.writeUInt8(0, 10);
46
+ ihdr.writeUInt8(0, 11);
47
+ ihdr.writeUInt8(0, 12);
48
+ return Buffer.concat([
49
+ PNG_SIGNATURE,
50
+ chunk('IHDR', ihdr),
51
+ chunk('IDAT', deflateSync(raw, { level: 9 })),
52
+ chunk('IEND', Buffer.alloc(0)),
53
+ ]);
54
+ };
@@ -0,0 +1,31 @@
1
+ type WindowOptions = {
2
+ windowTitle: string;
3
+ width: number;
4
+ height: number;
5
+ };
6
+ export type MainRsOptions = WindowOptions & ({
7
+ /** The `externalBin` base name — how `shell().sidecar(..)` finds the binary. */
8
+ sidecarName: string;
9
+ remoteUrl?: undefined;
10
+ } | {
11
+ /** An already-running server the window opens against. */
12
+ remoteUrl: string;
13
+ sidecarName?: undefined;
14
+ });
15
+ /**
16
+ * The shell's whole program.
17
+ *
18
+ * Everything it does follows from one decision: the server serves the UI and
19
+ * the API from a single real HTTP origin, and the webview simply points at it.
20
+ * A `tauri://localhost` webview would break every cookie, CORS check and OAuth
21
+ * redirect keyed on `window.location.origin`, which is the trap this design
22
+ * exists to avoid.
23
+ *
24
+ * Which origin that is decides the program. A sidecar binds its port at startup,
25
+ * so the shell has to start it, read the port off its ready line and open the
26
+ * window itself. A remote url is known before a line of Rust is generated, so
27
+ * the window is declared in `tauri.conf.json` and nothing here has to run at
28
+ * all beyond keeping the app to a single instance.
29
+ */
30
+ export declare const renderMainRs: (options: MainRsOptions) => string;
31
+ export {};
@@ -0,0 +1,213 @@
1
+ import { SERVER_READY_MARKER } from '@pikku/deploy';
2
+ import { DATA_DIR_ENV, PARENT_PID_ENV } from '../runtime/parent-watch.js';
3
+ /**
4
+ * The shell's whole program.
5
+ *
6
+ * Everything it does follows from one decision: the server serves the UI and
7
+ * the API from a single real HTTP origin, and the webview simply points at it.
8
+ * A `tauri://localhost` webview would break every cookie, CORS check and OAuth
9
+ * redirect keyed on `window.location.origin`, which is the trap this design
10
+ * exists to avoid.
11
+ *
12
+ * Which origin that is decides the program. A sidecar binds its port at startup,
13
+ * so the shell has to start it, read the port off its ready line and open the
14
+ * window itself. A remote url is known before a line of Rust is generated, so
15
+ * the window is declared in `tauri.conf.json` and nothing here has to run at
16
+ * all beyond keeping the app to a single instance.
17
+ */
18
+ export const renderMainRs = (options) => options.remoteUrl === undefined
19
+ ? renderSidecarMainRs(options)
20
+ : renderRemoteMainRs();
21
+ const renderRemoteMainRs = () => `// Generated by \`pikku deploy apply --desktop\`. Safe to edit — the generator
22
+ // will not overwrite this file once you have changed it.
23
+ //
24
+ // The window is declared in tauri.conf.json, pointed straight at the remote
25
+ // server: its origin is known up front, so there is no port to discover and no
26
+ // process to supervise. The webview loads that origin rather than
27
+ // \`tauri://localhost\`, which is what keeps cookies first-party and OAuth
28
+ // redirects working.
29
+ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
30
+
31
+ use tauri::Manager;
32
+
33
+ fn main() {
34
+ tauri::Builder::default()
35
+ // A second launch should reach the window that is already open rather
36
+ // than start a second session against the same server.
37
+ .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
38
+ if let Some(window) = app.get_webview_window("main") {
39
+ let _ = window.unminimize();
40
+ let _ = window.show();
41
+ let _ = window.set_focus();
42
+ }
43
+ }))
44
+ .run(tauri::generate_context!())
45
+ .expect("error while running the pikku desktop shell");
46
+ }
47
+ `;
48
+ const renderSidecarMainRs = (options) => {
49
+ const { sidecarName, windowTitle, width, height } = options;
50
+ return `// Generated by \`pikku deploy apply --desktop\`. Safe to edit — the generator
51
+ // will not overwrite this file once you have changed it.
52
+ //
53
+ // The webview points at the sidecar's own HTTP origin rather than at
54
+ // \`tauri://localhost\`, so cookies are first-party, there is no CORS, and OAuth
55
+ // redirects land where the server expects. The sidecar is the application; this
56
+ // shell only supervises it.
57
+ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
58
+
59
+ use std::sync::atomic::{AtomicBool, Ordering};
60
+ use std::sync::{Arc, Mutex};
61
+ use std::time::Duration;
62
+
63
+ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
64
+ use tauri_plugin_shell::process::{CommandChild, CommandEvent};
65
+ use tauri_plugin_shell::ShellExt;
66
+
67
+ /// Printed by the sidecar once it is listening *and* its startup lifecycle has
68
+ /// finished. The runtime's own "listening on ..." line comes earlier and is not
69
+ /// readiness.
70
+ const READY_PREFIX: &str = "${SERVER_READY_MARKER} on http://";
71
+
72
+ /// How long to wait for the ready line before giving up. A sidecar that starts
73
+ /// and then hangs prints nothing, so no window is ever built — and a Tauri
74
+ /// process with no window cannot be quit from the dock or the taskbar.
75
+ const READY_TIMEOUT: Duration = Duration::from_secs(30);
76
+
77
+ /// Held so the child is not dropped while the app runs, and so a clean exit can
78
+ /// stop it explicitly.
79
+ struct Sidecar(Mutex<Option<CommandChild>>);
80
+
81
+ fn parse_ready_port(line: &str) -> Option<u16> {
82
+ let rest = line.split(READY_PREFIX).nth(1)?;
83
+ let authority = rest.split_whitespace().next()?;
84
+ authority
85
+ .trim_end_matches('/')
86
+ .rsplit(':')
87
+ .next()?
88
+ .parse()
89
+ .ok()
90
+ }
91
+
92
+ fn main() {
93
+ tauri::Builder::default()
94
+ // A second launch must never spawn a second sidecar: that would be two
95
+ // SQLite writers on one file. Focus what is already running instead.
96
+ .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
97
+ if let Some(window) = app.get_webview_window("main") {
98
+ let _ = window.unminimize();
99
+ let _ = window.show();
100
+ let _ = window.set_focus();
101
+ }
102
+ }))
103
+ .plugin(tauri_plugin_shell::init())
104
+ .setup(|app| {
105
+ let data_dir = app.path().app_data_dir()?;
106
+ std::fs::create_dir_all(&data_dir)?;
107
+
108
+ let (mut rx, child) = app
109
+ .shell()
110
+ .sidecar("${sidecarName}")?
111
+ // Port 0 asks the OS for a free port. Choosing one here and
112
+ // passing it down would race whatever binds it in between, so
113
+ // the sidecar binds first and reports back on its ready line.
114
+ .env("PORT", "0")
115
+ .env("HOST", "127.0.0.1")
116
+ // The database, uploaded content and runtime state live here
117
+ // and nowhere else. A double-clicked app has no meaningful
118
+ // working directory, so the platform's app-data path is the
119
+ // only sane answer — the server reads this in bootstrap.
120
+ .env("${DATA_DIR_ENV}", data_dir.to_string_lossy().to_string())
121
+ // Tauri stops the sidecar on a clean exit, but a hard crash of
122
+ // this process never runs that path, and an orphan would hold
123
+ // the database open with its data key still resident.
124
+ .env("${PARENT_PID_ENV}", std::process::id().to_string())
125
+ .spawn()?;
126
+
127
+ app.manage(Sidecar(Mutex::new(Some(child))));
128
+
129
+ let opened = Arc::new(AtomicBool::new(false));
130
+
131
+ let timeout_handle = app.handle().clone();
132
+ let timeout_opened = opened.clone();
133
+ std::thread::spawn(move || {
134
+ std::thread::sleep(READY_TIMEOUT);
135
+ if !timeout_opened.load(Ordering::SeqCst) {
136
+ eprintln!(
137
+ "the pikku sidecar did not become ready within {:?} — giving up",
138
+ READY_TIMEOUT
139
+ );
140
+ timeout_handle.exit(1);
141
+ }
142
+ });
143
+
144
+ let handle = app.handle().clone();
145
+ tauri::async_runtime::spawn(async move {
146
+ while let Some(event) = rx.recv().await {
147
+ let line = match event {
148
+ CommandEvent::Stdout(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
149
+ CommandEvent::Stderr(bytes) => {
150
+ eprint!("{}", String::from_utf8_lossy(&bytes));
151
+ continue;
152
+ }
153
+ CommandEvent::Terminated(payload) => {
154
+ eprintln!("pikku sidecar exited: {:?}", payload.code);
155
+ if !opened.load(Ordering::SeqCst) {
156
+ handle.exit(1);
157
+ }
158
+ break;
159
+ }
160
+ _ => continue,
161
+ };
162
+ print!("{}", line);
163
+
164
+ if opened.load(Ordering::SeqCst) {
165
+ continue;
166
+ }
167
+ if let Some(port) = parse_ready_port(&line) {
168
+ let url = format!("http://127.0.0.1:{}", port);
169
+ match url.parse() {
170
+ Ok(parsed) => {
171
+ let built = WebviewWindowBuilder::new(
172
+ &handle,
173
+ "main",
174
+ WebviewUrl::External(parsed),
175
+ )
176
+ .title("${windowTitle}")
177
+ .inner_size(${width}.0, ${height}.0)
178
+ .build();
179
+ match built {
180
+ Ok(_) => opened.store(true, Ordering::SeqCst),
181
+ Err(err) => {
182
+ eprintln!("could not open the window: {}", err);
183
+ handle.exit(1);
184
+ }
185
+ }
186
+ }
187
+ Err(err) => {
188
+ eprintln!("the sidecar reported an unusable url: {}", err);
189
+ handle.exit(1);
190
+ }
191
+ }
192
+ }
193
+ }
194
+ });
195
+
196
+ Ok(())
197
+ })
198
+ .on_window_event(|window, event| {
199
+ if let tauri::WindowEvent::Destroyed = event {
200
+ if let Some(sidecar) = window.app_handle().try_state::<Sidecar>() {
201
+ if let Ok(mut guard) = sidecar.0.lock() {
202
+ if let Some(child) = guard.take() {
203
+ let _ = child.kill();
204
+ }
205
+ }
206
+ }
207
+ }
208
+ })
209
+ .run(tauri::generate_context!())
210
+ .expect("error while running the pikku desktop shell");
211
+ }
212
+ `;
213
+ };
@@ -0,0 +1,15 @@
1
+ export type TauriNextStepsOptions = {
2
+ /** Absolute path of the generated crate. */
3
+ shellDir: string;
4
+ /** Whether a Rust toolchain answered when the triple was resolved. */
5
+ hasRust: boolean;
6
+ };
7
+ /**
8
+ * What to say once the crate and its sidecar are on disk.
9
+ *
10
+ * Generation is pure Node — it writes files and copies a binary — so `--desktop`
11
+ * succeeds perfectly well on a machine that cannot build the result. Saying so
12
+ * here is the difference between a known prerequisite and a cargo error at the
13
+ * point someone least expects one.
14
+ */
15
+ export declare const renderTauriNextSteps: ({ shellDir, hasRust, }: TauriNextStepsOptions) => string[];
@@ -0,0 +1,16 @@
1
+ const PREREQUISITES = 'https://tauri.app/start/prerequisites/';
2
+ /**
3
+ * What to say once the crate and its sidecar are on disk.
4
+ *
5
+ * Generation is pure Node — it writes files and copies a binary — so `--desktop`
6
+ * succeeds perfectly well on a machine that cannot build the result. Saying so
7
+ * here is the difference between a known prerequisite and a cargo error at the
8
+ * point someone least expects one.
9
+ */
10
+ export const renderTauriNextSteps = ({ shellDir, hasRust, }) => {
11
+ const lines = [` Next: cd ${shellDir} && npx tauri build`];
12
+ if (!hasRust) {
13
+ lines.push(` That step needs a Rust toolchain, and none answered here — see ${PREREQUISITES}.`, ' The crate and its sidecar are complete, so another machine can build them as they are.');
14
+ }
15
+ return lines;
16
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Rust target triples, and the file name Tauri's `externalBin` resolves a
3
+ * sidecar by.
4
+ *
5
+ * Tauri appends the *compile* target's triple to every `externalBin` path and
6
+ * looks the result up on disk, so a binary dropped in as plain `binaries/app`
7
+ * is silently invisible to the bundler. This is the one detail that makes an
8
+ * otherwise correct shell fail at build time with "binary not found".
9
+ */
10
+ export type HostPlatform = NodeJS.Platform;
11
+ export type HostArch = string;
12
+ /** Pull the `host:` line out of `rustc -vV` output. */
13
+ export declare const parseRustcHost: (output: string) => string | undefined;
14
+ export type HostTargetTripleOptions = {
15
+ platform?: HostPlatform;
16
+ arch?: HostArch;
17
+ /** Raw `rustc -vV` output, when the toolchain was reachable. */
18
+ rustcVersionVerbose?: string;
19
+ };
20
+ /**
21
+ * The triple the shell will be built for.
22
+ *
23
+ * `rustc -vV` wins when it is available: it is the toolchain that will link the
24
+ * shell, and it knows things the Node platform pair cannot express — a musl
25
+ * host, or a Node process running under Rosetta on an arm64 Mac.
26
+ */
27
+ export declare const hostTargetTriple: (options?: HostTargetTripleOptions) => string;
28
+ /** `binaries/<name>-<triple>[.exe]`, exactly as `externalBin` looks it up. */
29
+ export declare const sidecarFileName: (baseName: string, targetTriple: string) => string;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Rust target triples, and the file name Tauri's `externalBin` resolves a
3
+ * sidecar by.
4
+ *
5
+ * Tauri appends the *compile* target's triple to every `externalBin` path and
6
+ * looks the result up on disk, so a binary dropped in as plain `binaries/app`
7
+ * is silently invisible to the bundler. This is the one detail that makes an
8
+ * otherwise correct shell fail at build time with "binary not found".
9
+ */
10
+ const TRIPLES = {
11
+ 'darwin:arm64': 'aarch64-apple-darwin',
12
+ 'darwin:x64': 'x86_64-apple-darwin',
13
+ 'linux:arm64': 'aarch64-unknown-linux-gnu',
14
+ 'linux:x64': 'x86_64-unknown-linux-gnu',
15
+ 'win32:arm64': 'aarch64-pc-windows-msvc',
16
+ 'win32:x64': 'x86_64-pc-windows-msvc',
17
+ };
18
+ /** Pull the `host:` line out of `rustc -vV` output. */
19
+ export const parseRustcHost = (output) => /^host:\s*(\S+)$/m.exec(output)?.[1];
20
+ /**
21
+ * The triple the shell will be built for.
22
+ *
23
+ * `rustc -vV` wins when it is available: it is the toolchain that will link the
24
+ * shell, and it knows things the Node platform pair cannot express — a musl
25
+ * host, or a Node process running under Rosetta on an arm64 Mac.
26
+ */
27
+ export const hostTargetTriple = (options = {}) => {
28
+ const fromRustc = options.rustcVersionVerbose
29
+ ? parseRustcHost(options.rustcVersionVerbose)
30
+ : undefined;
31
+ if (fromRustc)
32
+ return fromRustc;
33
+ const platform = options.platform ?? process.platform;
34
+ const arch = options.arch ?? process.arch;
35
+ const triple = TRIPLES[`${platform}:${arch}`];
36
+ if (!triple) {
37
+ throw new Error(`No known Rust target triple for ${platform}/${arch}. Install a Rust toolchain so \`rustc -vV\` can report its host, or pass the triple explicitly.`);
38
+ }
39
+ return triple;
40
+ };
41
+ /** `binaries/<name>-<triple>[.exe]`, exactly as `externalBin` looks it up. */
42
+ export const sidecarFileName = (baseName, targetTriple) => `${baseName}-${targetTriple}${targetTriple.includes('windows') ? '.exe' : ''}`;
@@ -0,0 +1,36 @@
1
+ ---
2
+ type: decision
3
+ title: A pikku server serves a static frontend, not a rendered one
4
+ description: The frontend is TanStack Start built to static output and served through a static mount; pikku never runs a framework renderer in-process
5
+ tags: [frontend, tanstack, static-mounts, standalone]
6
+ ---
7
+
8
+ # A pikku server serves a static frontend, not a rendered one
9
+
10
+ The frontend story is one framework — **TanStack Start** — and one output shape:
11
+ static HTML, JS and CSS on disk, served through the same `StaticMount`
12
+ machinery that already serves the console. Next.js is out of scope, and so is
13
+ running any framework's server renderer inside the pikku process.
14
+
15
+ Both halves of that are deliberate, and for the same reason. Hosting a renderer
16
+ means owning that framework's server contract: its request/response adapter, its
17
+ streaming model, its middleware ordering, its version skew. It couples a pikku
18
+ release to a frontend framework release, and it multiplies by every runtime we
19
+ support — the node server, the bun server, and every serverless adapter would
20
+ each need their own integration. A directory of files needs none of that, and
21
+ what it costs the app is per-request server rendering, which a local-first
22
+ desktop app was never going to use.
23
+
24
+ The capability is not standalone-specific. `pikku serve` and `pikku dev` mount a
25
+ frontend the same way, because a server that can serve its own UI is useful long
26
+ before anyone wraps it in Tauri — it is the difference between one origin and
27
+ two, which is also the difference between first-party cookies and a CORS
28
+ configuration. Standalone is the case that makes it *feel* like an app; it is
29
+ not the case that justifies the feature.
30
+
31
+ Dev does not use a mount at all. Vite serves the frontend and proxies `/api` to
32
+ pikku, exactly as `packages/console/vite.config.ts` already does — HMR is the
33
+ whole point of dev, and a static mount cannot offer it.
34
+
35
+ **What this rules out:** a Next.js integration, an in-process SSR or streaming
36
+ handler, and a frontend capability that only exists inside `pikku deploy`.