@pikku/deploy-standalone 0.12.11 → 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 (48) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/adapter.d.ts +37 -45
  3. package/dist/adapter.js +187 -46
  4. package/dist/index.d.ts +1 -1
  5. package/dist/runtime/index.d.ts +9 -0
  6. package/dist/runtime/index.js +8 -0
  7. package/dist/runtime/parent-watch.d.ts +45 -0
  8. package/dist/runtime/parent-watch.js +87 -0
  9. package/dist/tauri/generate.d.ts +45 -0
  10. package/dist/tauri/generate.js +230 -0
  11. package/dist/tauri/icon.d.ts +1 -0
  12. package/dist/tauri/icon.js +54 -0
  13. package/dist/tauri/main-rs.d.ts +31 -0
  14. package/dist/tauri/main-rs.js +213 -0
  15. package/dist/tauri/next-steps.d.ts +15 -0
  16. package/dist/tauri/next-steps.js +16 -0
  17. package/dist/tauri/target-triple.d.ts +29 -0
  18. package/dist/tauri/target-triple.js +42 -0
  19. package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
  20. package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
  21. package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
  22. package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
  23. package/knowledge/decisions/index.md +19 -0
  24. package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
  25. package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
  26. package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
  27. package/knowledge/index.md +22 -0
  28. package/package.json +10 -5
  29. package/src/adapter.test.ts +186 -0
  30. package/src/adapter.ts +216 -62
  31. package/src/desktop-deploy.test.ts +167 -0
  32. package/src/index.ts +1 -3
  33. package/src/runtime/index.ts +13 -0
  34. package/src/runtime/parent-watch.process.test.ts +112 -0
  35. package/src/runtime/parent-watch.test.ts +148 -0
  36. package/src/runtime/parent-watch.ts +115 -0
  37. package/src/sidecar-entry.test.ts +89 -0
  38. package/src/tauri/generate.test.ts +401 -0
  39. package/src/tauri/generate.ts +327 -0
  40. package/src/tauri/icon.test.ts +63 -0
  41. package/src/tauri/icon.ts +62 -0
  42. package/src/tauri/main-rs.rustfmt.test.ts +86 -0
  43. package/src/tauri/main-rs.ts +241 -0
  44. package/src/tauri/next-steps.test.ts +38 -0
  45. package/src/tauri/next-steps.ts +30 -0
  46. package/src/tauri/target-triple.test.ts +84 -0
  47. package/src/tauri/target-triple.ts +65 -0
  48. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,241 @@
1
+ import { SERVER_READY_MARKER } from '@pikku/deploy'
2
+
3
+ import { DATA_DIR_ENV, PARENT_PID_ENV } from '../runtime/parent-watch.js'
4
+
5
+ type WindowOptions = {
6
+ windowTitle: string
7
+ width: number
8
+ height: number
9
+ }
10
+
11
+ export type MainRsOptions = WindowOptions &
12
+ (
13
+ | {
14
+ /** The `externalBin` base name — how `shell().sidecar(..)` finds the binary. */
15
+ sidecarName: string
16
+ remoteUrl?: undefined
17
+ }
18
+ | {
19
+ /** An already-running server the window opens against. */
20
+ remoteUrl: string
21
+ sidecarName?: undefined
22
+ }
23
+ )
24
+
25
+ /**
26
+ * The shell's whole program.
27
+ *
28
+ * Everything it does follows from one decision: the server serves the UI and
29
+ * the API from a single real HTTP origin, and the webview simply points at it.
30
+ * A `tauri://localhost` webview would break every cookie, CORS check and OAuth
31
+ * redirect keyed on `window.location.origin`, which is the trap this design
32
+ * exists to avoid.
33
+ *
34
+ * Which origin that is decides the program. A sidecar binds its port at startup,
35
+ * so the shell has to start it, read the port off its ready line and open the
36
+ * window itself. A remote url is known before a line of Rust is generated, so
37
+ * the window is declared in `tauri.conf.json` and nothing here has to run at
38
+ * all beyond keeping the app to a single instance.
39
+ */
40
+ export const renderMainRs = (options: MainRsOptions): string =>
41
+ options.remoteUrl === undefined
42
+ ? renderSidecarMainRs(options)
43
+ : renderRemoteMainRs()
44
+
45
+ const renderRemoteMainRs = (): string =>
46
+ `// Generated by \`pikku deploy apply --desktop\`. Safe to edit — the generator
47
+ // will not overwrite this file once you have changed it.
48
+ //
49
+ // The window is declared in tauri.conf.json, pointed straight at the remote
50
+ // server: its origin is known up front, so there is no port to discover and no
51
+ // process to supervise. The webview loads that origin rather than
52
+ // \`tauri://localhost\`, which is what keeps cookies first-party and OAuth
53
+ // redirects working.
54
+ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
55
+
56
+ use tauri::Manager;
57
+
58
+ fn main() {
59
+ tauri::Builder::default()
60
+ // A second launch should reach the window that is already open rather
61
+ // than start a second session against the same server.
62
+ .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
63
+ if let Some(window) = app.get_webview_window("main") {
64
+ let _ = window.unminimize();
65
+ let _ = window.show();
66
+ let _ = window.set_focus();
67
+ }
68
+ }))
69
+ .run(tauri::generate_context!())
70
+ .expect("error while running the pikku desktop shell");
71
+ }
72
+ `
73
+
74
+ const renderSidecarMainRs = (
75
+ options: WindowOptions & { sidecarName: string }
76
+ ): string => {
77
+ const { sidecarName, windowTitle, width, height } = options
78
+ return `// Generated by \`pikku deploy apply --desktop\`. Safe to edit — the generator
79
+ // will not overwrite this file once you have changed it.
80
+ //
81
+ // The webview points at the sidecar's own HTTP origin rather than at
82
+ // \`tauri://localhost\`, so cookies are first-party, there is no CORS, and OAuth
83
+ // redirects land where the server expects. The sidecar is the application; this
84
+ // shell only supervises it.
85
+ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
86
+
87
+ use std::sync::atomic::{AtomicBool, Ordering};
88
+ use std::sync::{Arc, Mutex};
89
+ use std::time::Duration;
90
+
91
+ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
92
+ use tauri_plugin_shell::process::{CommandChild, CommandEvent};
93
+ use tauri_plugin_shell::ShellExt;
94
+
95
+ /// Printed by the sidecar once it is listening *and* its startup lifecycle has
96
+ /// finished. The runtime's own "listening on ..." line comes earlier and is not
97
+ /// readiness.
98
+ const READY_PREFIX: &str = "${SERVER_READY_MARKER} on http://";
99
+
100
+ /// How long to wait for the ready line before giving up. A sidecar that starts
101
+ /// and then hangs prints nothing, so no window is ever built — and a Tauri
102
+ /// process with no window cannot be quit from the dock or the taskbar.
103
+ const READY_TIMEOUT: Duration = Duration::from_secs(30);
104
+
105
+ /// Held so the child is not dropped while the app runs, and so a clean exit can
106
+ /// stop it explicitly.
107
+ struct Sidecar(Mutex<Option<CommandChild>>);
108
+
109
+ fn parse_ready_port(line: &str) -> Option<u16> {
110
+ let rest = line.split(READY_PREFIX).nth(1)?;
111
+ let authority = rest.split_whitespace().next()?;
112
+ authority
113
+ .trim_end_matches('/')
114
+ .rsplit(':')
115
+ .next()?
116
+ .parse()
117
+ .ok()
118
+ }
119
+
120
+ fn main() {
121
+ tauri::Builder::default()
122
+ // A second launch must never spawn a second sidecar: that would be two
123
+ // SQLite writers on one file. Focus what is already running instead.
124
+ .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
125
+ if let Some(window) = app.get_webview_window("main") {
126
+ let _ = window.unminimize();
127
+ let _ = window.show();
128
+ let _ = window.set_focus();
129
+ }
130
+ }))
131
+ .plugin(tauri_plugin_shell::init())
132
+ .setup(|app| {
133
+ let data_dir = app.path().app_data_dir()?;
134
+ std::fs::create_dir_all(&data_dir)?;
135
+
136
+ let (mut rx, child) = app
137
+ .shell()
138
+ .sidecar("${sidecarName}")?
139
+ // Port 0 asks the OS for a free port. Choosing one here and
140
+ // passing it down would race whatever binds it in between, so
141
+ // the sidecar binds first and reports back on its ready line.
142
+ .env("PORT", "0")
143
+ .env("HOST", "127.0.0.1")
144
+ // The database, uploaded content and runtime state live here
145
+ // and nowhere else. A double-clicked app has no meaningful
146
+ // working directory, so the platform's app-data path is the
147
+ // only sane answer — the server reads this in bootstrap.
148
+ .env("${DATA_DIR_ENV}", data_dir.to_string_lossy().to_string())
149
+ // Tauri stops the sidecar on a clean exit, but a hard crash of
150
+ // this process never runs that path, and an orphan would hold
151
+ // the database open with its data key still resident.
152
+ .env("${PARENT_PID_ENV}", std::process::id().to_string())
153
+ .spawn()?;
154
+
155
+ app.manage(Sidecar(Mutex::new(Some(child))));
156
+
157
+ let opened = Arc::new(AtomicBool::new(false));
158
+
159
+ let timeout_handle = app.handle().clone();
160
+ let timeout_opened = opened.clone();
161
+ std::thread::spawn(move || {
162
+ std::thread::sleep(READY_TIMEOUT);
163
+ if !timeout_opened.load(Ordering::SeqCst) {
164
+ eprintln!(
165
+ "the pikku sidecar did not become ready within {:?} — giving up",
166
+ READY_TIMEOUT
167
+ );
168
+ timeout_handle.exit(1);
169
+ }
170
+ });
171
+
172
+ let handle = app.handle().clone();
173
+ tauri::async_runtime::spawn(async move {
174
+ while let Some(event) = rx.recv().await {
175
+ let line = match event {
176
+ CommandEvent::Stdout(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
177
+ CommandEvent::Stderr(bytes) => {
178
+ eprint!("{}", String::from_utf8_lossy(&bytes));
179
+ continue;
180
+ }
181
+ CommandEvent::Terminated(payload) => {
182
+ eprintln!("pikku sidecar exited: {:?}", payload.code);
183
+ if !opened.load(Ordering::SeqCst) {
184
+ handle.exit(1);
185
+ }
186
+ break;
187
+ }
188
+ _ => continue,
189
+ };
190
+ print!("{}", line);
191
+
192
+ if opened.load(Ordering::SeqCst) {
193
+ continue;
194
+ }
195
+ if let Some(port) = parse_ready_port(&line) {
196
+ let url = format!("http://127.0.0.1:{}", port);
197
+ match url.parse() {
198
+ Ok(parsed) => {
199
+ let built = WebviewWindowBuilder::new(
200
+ &handle,
201
+ "main",
202
+ WebviewUrl::External(parsed),
203
+ )
204
+ .title("${windowTitle}")
205
+ .inner_size(${width}.0, ${height}.0)
206
+ .build();
207
+ match built {
208
+ Ok(_) => opened.store(true, Ordering::SeqCst),
209
+ Err(err) => {
210
+ eprintln!("could not open the window: {}", err);
211
+ handle.exit(1);
212
+ }
213
+ }
214
+ }
215
+ Err(err) => {
216
+ eprintln!("the sidecar reported an unusable url: {}", err);
217
+ handle.exit(1);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ });
223
+
224
+ Ok(())
225
+ })
226
+ .on_window_event(|window, event| {
227
+ if let tauri::WindowEvent::Destroyed = event {
228
+ if let Some(sidecar) = window.app_handle().try_state::<Sidecar>() {
229
+ if let Ok(mut guard) = sidecar.0.lock() {
230
+ if let Some(child) = guard.take() {
231
+ let _ = child.kill();
232
+ }
233
+ }
234
+ }
235
+ }
236
+ })
237
+ .run(tauri::generate_context!())
238
+ .expect("error while running the pikku desktop shell");
239
+ }
240
+ `
241
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { renderTauriNextSteps } from './next-steps.js'
5
+
6
+ describe('what to tell someone holding a freshly generated shell', () => {
7
+ it('names the command that turns the crate into an app', () => {
8
+ const lines = renderTauriNextSteps({
9
+ shellDir: '/work/shop/src-tauri',
10
+ hasRust: true,
11
+ }).join('\n')
12
+
13
+ assert.match(lines, /cd \/work\/shop\/src-tauri/)
14
+ assert.match(lines, /tauri build/)
15
+ })
16
+
17
+ it('says a toolchain is missing rather than letting cargo say it', () => {
18
+ // Generation is pure Node, so `--desktop` succeeds on a machine that cannot
19
+ // build the result. Someone who has never used Tauri would otherwise find
20
+ // out from a cargo error, at the point they least expect one.
21
+ const lines = renderTauriNextSteps({
22
+ shellDir: '/work/shop/src-tauri',
23
+ hasRust: false,
24
+ }).join('\n')
25
+
26
+ assert.match(lines, /Rust/)
27
+ assert.match(lines, /tauri\.app/)
28
+ })
29
+
30
+ it('stays quiet about prerequisites that are already met', () => {
31
+ const lines = renderTauriNextSteps({
32
+ shellDir: '/work/shop/src-tauri',
33
+ hasRust: true,
34
+ }).join('\n')
35
+
36
+ assert.doesNotMatch(lines, /tauri\.app/)
37
+ })
38
+ })
@@ -0,0 +1,30 @@
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
+ const PREREQUISITES = 'https://tauri.app/start/prerequisites/'
9
+
10
+ /**
11
+ * What to say once the crate and its sidecar are on disk.
12
+ *
13
+ * Generation is pure Node — it writes files and copies a binary — so `--desktop`
14
+ * succeeds perfectly well on a machine that cannot build the result. Saying so
15
+ * here is the difference between a known prerequisite and a cargo error at the
16
+ * point someone least expects one.
17
+ */
18
+ export const renderTauriNextSteps = ({
19
+ shellDir,
20
+ hasRust,
21
+ }: TauriNextStepsOptions): string[] => {
22
+ const lines = [` Next: cd ${shellDir} && npx tauri build`]
23
+ if (!hasRust) {
24
+ lines.push(
25
+ ` That step needs a Rust toolchain, and none answered here — see ${PREREQUISITES}.`,
26
+ ' The crate and its sidecar are complete, so another machine can build them as they are.'
27
+ )
28
+ }
29
+ return lines
30
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ hostTargetTriple,
6
+ sidecarFileName,
7
+ parseRustcHost,
8
+ } from './target-triple.js'
9
+
10
+ describe('the target triple Tauri demands on a sidecar file name', () => {
11
+ it('prefers what rustc says its host is', () => {
12
+ const triple = hostTargetTriple({
13
+ platform: 'linux',
14
+ arch: 'x64',
15
+ rustcVersionVerbose:
16
+ 'rustc 1.97.1\nbinary: rustc\nhost: aarch64-apple-darwin\n',
17
+ })
18
+ assert.equal(
19
+ triple,
20
+ 'aarch64-apple-darwin',
21
+ 'rustc is the toolchain that will actually link the shell'
22
+ )
23
+ })
24
+
25
+ it('falls back to the running platform when rustc is not installed', () => {
26
+ assert.equal(
27
+ hostTargetTriple({ platform: 'darwin', arch: 'arm64' }),
28
+ 'aarch64-apple-darwin'
29
+ )
30
+ assert.equal(
31
+ hostTargetTriple({ platform: 'darwin', arch: 'x64' }),
32
+ 'x86_64-apple-darwin'
33
+ )
34
+ assert.equal(
35
+ hostTargetTriple({ platform: 'linux', arch: 'x64' }),
36
+ 'x86_64-unknown-linux-gnu'
37
+ )
38
+ assert.equal(
39
+ hostTargetTriple({ platform: 'linux', arch: 'arm64' }),
40
+ 'aarch64-unknown-linux-gnu'
41
+ )
42
+ assert.equal(
43
+ hostTargetTriple({ platform: 'win32', arch: 'x64' }),
44
+ 'x86_64-pc-windows-msvc'
45
+ )
46
+ assert.equal(
47
+ hostTargetTriple({ platform: 'win32', arch: 'arm64' }),
48
+ 'aarch64-pc-windows-msvc'
49
+ )
50
+ })
51
+
52
+ it('names the platform it cannot map rather than guessing', () => {
53
+ assert.throws(
54
+ () => hostTargetTriple({ platform: 'sunos', arch: 'mips' as never }),
55
+ /sunos/
56
+ )
57
+ })
58
+
59
+ it('ignores rustc output that carries no host line', () => {
60
+ assert.equal(parseRustcHost('rustc 1.97.1 (8bab26f4f)'), undefined)
61
+ assert.equal(
62
+ hostTargetTriple({
63
+ platform: 'darwin',
64
+ arch: 'arm64',
65
+ rustcVersionVerbose: 'rustc 1.97.1',
66
+ }),
67
+ 'aarch64-apple-darwin'
68
+ )
69
+ })
70
+
71
+ it('suffixes the binary the way externalBin resolves it', () => {
72
+ assert.equal(
73
+ sidecarFileName('shop', 'aarch64-apple-darwin'),
74
+ 'shop-aarch64-apple-darwin'
75
+ )
76
+ })
77
+
78
+ it('keeps the .exe extension after the triple on windows', () => {
79
+ assert.equal(
80
+ sidecarFileName('shop', 'x86_64-pc-windows-msvc'),
81
+ 'shop-x86_64-pc-windows-msvc.exe'
82
+ )
83
+ })
84
+ })
@@ -0,0 +1,65 @@
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
+
11
+ export type HostPlatform = NodeJS.Platform
12
+ export type HostArch = string
13
+
14
+ const TRIPLES: Record<string, string> = {
15
+ 'darwin:arm64': 'aarch64-apple-darwin',
16
+ 'darwin:x64': 'x86_64-apple-darwin',
17
+ 'linux:arm64': 'aarch64-unknown-linux-gnu',
18
+ 'linux:x64': 'x86_64-unknown-linux-gnu',
19
+ 'win32:arm64': 'aarch64-pc-windows-msvc',
20
+ 'win32:x64': 'x86_64-pc-windows-msvc',
21
+ }
22
+
23
+ /** Pull the `host:` line out of `rustc -vV` output. */
24
+ export const parseRustcHost = (output: string): string | undefined =>
25
+ /^host:\s*(\S+)$/m.exec(output)?.[1]
26
+
27
+ export type HostTargetTripleOptions = {
28
+ platform?: HostPlatform
29
+ arch?: HostArch
30
+ /** Raw `rustc -vV` output, when the toolchain was reachable. */
31
+ rustcVersionVerbose?: string
32
+ }
33
+
34
+ /**
35
+ * The triple the shell will be built for.
36
+ *
37
+ * `rustc -vV` wins when it is available: it is the toolchain that will link the
38
+ * shell, and it knows things the Node platform pair cannot express — a musl
39
+ * host, or a Node process running under Rosetta on an arm64 Mac.
40
+ */
41
+ export const hostTargetTriple = (
42
+ options: HostTargetTripleOptions = {}
43
+ ): string => {
44
+ const fromRustc = options.rustcVersionVerbose
45
+ ? parseRustcHost(options.rustcVersionVerbose)
46
+ : undefined
47
+ if (fromRustc) return fromRustc
48
+
49
+ const platform = options.platform ?? process.platform
50
+ const arch = options.arch ?? process.arch
51
+ const triple = TRIPLES[`${platform}:${arch}`]
52
+ if (!triple) {
53
+ throw new Error(
54
+ `No known Rust target triple for ${platform}/${arch}. Install a Rust toolchain so \`rustc -vV\` can report its host, or pass the triple explicitly.`
55
+ )
56
+ }
57
+ return triple
58
+ }
59
+
60
+ /** `binaries/<name>-<triple>[.exe]`, exactly as `externalBin` looks it up. */
61
+ export const sidecarFileName = (
62
+ baseName: string,
63
+ targetTriple: string
64
+ ): string =>
65
+ `${baseName}-${targetTriple}${targetTriple.includes('windows') ? '.exe' : ''}`