@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,401 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import { SERVER_READY_MARKER } from '@pikku/deploy'
8
+
9
+ import { DATA_DIR_ENV, PARENT_PID_ENV } from '../runtime/parent-watch.js'
10
+ import { generateTauriShell, tauriBundleIdentifier } from './generate.js'
11
+ import { sidecarFileName } from './target-triple.js'
12
+
13
+ const TRIPLE = 'aarch64-apple-darwin'
14
+
15
+ const scratch = async () => mkdtemp(join(tmpdir(), 'pikku-tauri-'))
16
+
17
+ const withBinary = async (dir: string) => {
18
+ const path = join(dir, 'shop')
19
+ await writeFile(path, 'not really a binary', 'utf-8')
20
+ return path
21
+ }
22
+
23
+ const generate = async (
24
+ projectDir: string,
25
+ overrides: Partial<Parameters<typeof generateTauriShell>[0]> = {}
26
+ ) =>
27
+ generateTauriShell({
28
+ projectDir,
29
+ appName: 'shop',
30
+ identifier: 'com.acme.shop',
31
+ targetTriple: TRIPLE,
32
+ ...overrides,
33
+ })
34
+
35
+ describe('the bundle identifier a shell is published under', () => {
36
+ it('reads an org out of a scoped package name', () => {
37
+ assert.equal(tauriBundleIdentifier('@acme/shop'), 'com.acme.shop')
38
+ })
39
+
40
+ it('gives an unscoped package a valid identifier of its own', () => {
41
+ const id = tauriBundleIdentifier('shop')
42
+ assert.equal(id, 'com.shop.desktop')
43
+ assert.ok(
44
+ !id.endsWith('.app'),
45
+ 'Tauri rejects an identifier ending in .app on macOS'
46
+ )
47
+ })
48
+
49
+ it('strips characters an identifier segment cannot hold', () => {
50
+ assert.equal(
51
+ tauriBundleIdentifier('@Acme Corp/My_Shop!'),
52
+ 'com.acme-corp.my-shop'
53
+ )
54
+ })
55
+ })
56
+
57
+ describe('generating a Tauri shell around a pikku binary', () => {
58
+ it('writes a crate a Rust toolchain could build', async () => {
59
+ const dir = await scratch()
60
+ try {
61
+ const result = await generate(dir)
62
+ for (const expected of [
63
+ 'tauri.conf.json',
64
+ 'Cargo.toml',
65
+ 'build.rs',
66
+ 'src/main.rs',
67
+ 'icons/icon.png',
68
+ '.gitignore',
69
+ ]) {
70
+ assert.ok(
71
+ result.written.includes(expected),
72
+ `${expected} was not generated (got ${result.written.join(', ')})`
73
+ )
74
+ await stat(join(dir, 'src-tauri', expected))
75
+ }
76
+ } finally {
77
+ await rm(dir, { recursive: true, force: true })
78
+ }
79
+ })
80
+
81
+ it('takes the product name and identifier from the project', async () => {
82
+ const dir = await scratch()
83
+ try {
84
+ await generate(dir)
85
+ const conf = JSON.parse(
86
+ await readFile(join(dir, 'src-tauri', 'tauri.conf.json'), 'utf-8')
87
+ )
88
+ assert.equal(conf.productName, 'shop')
89
+ assert.equal(conf.identifier, 'com.acme.shop')
90
+ assert.deepEqual(conf.bundle.externalBin, ['binaries/shop'])
91
+ assert.deepEqual(
92
+ conf.app.windows,
93
+ [],
94
+ 'the window is created from Rust once the port is known'
95
+ )
96
+ } finally {
97
+ await rm(dir, { recursive: true, force: true })
98
+ }
99
+ })
100
+
101
+ it('emits main.rs wired to the decisions this shell rests on', async () => {
102
+ const dir = await scratch()
103
+ try {
104
+ await generate(dir)
105
+ const main = await readFile(
106
+ join(dir, 'src-tauri', 'src', 'main.rs'),
107
+ 'utf-8'
108
+ )
109
+
110
+ assert.match(
111
+ main,
112
+ /tauri_plugin_single_instance/,
113
+ 'two shells would mean two SQLite writers'
114
+ )
115
+ assert.match(main, /app_data_dir/)
116
+ assert.match(main, new RegExp(DATA_DIR_ENV))
117
+ assert.match(main, new RegExp(PARENT_PID_ENV))
118
+ assert.match(main, /\.env\("PORT", "0"\)/)
119
+ assert.ok(
120
+ main.includes(SERVER_READY_MARKER),
121
+ 'the shell blocks on the ready line to learn the port'
122
+ )
123
+ assert.match(main, /127\.0\.0\.1/)
124
+ assert.match(main, /sidecar\("shop"\)/)
125
+ } finally {
126
+ await rm(dir, { recursive: true, force: true })
127
+ }
128
+ })
129
+
130
+ it('gives up when the sidecar never becomes ready', async () => {
131
+ const dir = await scratch()
132
+ try {
133
+ await generate(dir)
134
+ const main = await readFile(
135
+ join(dir, 'src-tauri', 'src', 'main.rs'),
136
+ 'utf-8'
137
+ )
138
+
139
+ // A sidecar that starts and then hangs prints no ready line, so nothing
140
+ // opens a window — and a Tauri process with no window cannot be quit
141
+ // from the dock. The shell has to notice and exit on its own.
142
+ assert.match(main, /READY_TIMEOUT/)
143
+ assert.match(main, /did not become ready/)
144
+ assert.match(main, /exit\(1\)/)
145
+ } finally {
146
+ await rm(dir, { recursive: true, force: true })
147
+ }
148
+ })
149
+
150
+ it('never asks the shell to choose a port', async () => {
151
+ const dir = await scratch()
152
+ try {
153
+ await generate(dir)
154
+ const main = await readFile(
155
+ join(dir, 'src-tauri', 'src', 'main.rs'),
156
+ 'utf-8'
157
+ )
158
+ assert.doesNotMatch(
159
+ main,
160
+ /TcpListener::bind|portpicker|free_port/,
161
+ 'picking a port in the parent races whatever binds it next'
162
+ )
163
+ } finally {
164
+ await rm(dir, { recursive: true, force: true })
165
+ }
166
+ })
167
+
168
+ it('lands the binary under the name externalBin resolves', async () => {
169
+ const dir = await scratch()
170
+ try {
171
+ const binaryPath = await withBinary(dir)
172
+ const result = await generate(dir, { binaryPath })
173
+
174
+ const expected = sidecarFileName('shop', TRIPLE)
175
+ assert.equal(result.sidecar?.fileName, expected)
176
+ const landed = join(dir, 'src-tauri', 'binaries', expected)
177
+ assert.equal(await readFile(landed, 'utf-8'), 'not really a binary')
178
+ const mode = (await stat(landed)).mode & 0o111
179
+ assert.notEqual(mode, 0, 'a sidecar Tauri cannot execute is useless')
180
+ } finally {
181
+ await rm(dir, { recursive: true, force: true })
182
+ }
183
+ })
184
+
185
+ it('keeps build output out of git', async () => {
186
+ const dir = await scratch()
187
+ try {
188
+ await generate(dir)
189
+ const ignore = await readFile(
190
+ join(dir, 'src-tauri', '.gitignore'),
191
+ 'utf-8'
192
+ )
193
+ assert.match(ignore, /^\/target$/m)
194
+ assert.match(ignore, /^\/binaries$/m)
195
+ } finally {
196
+ await rm(dir, { recursive: true, force: true })
197
+ }
198
+ })
199
+
200
+ it('is idempotent — a second run changes nothing', async () => {
201
+ const dir = await scratch()
202
+ try {
203
+ const binaryPath = await withBinary(dir)
204
+ await generate(dir, { binaryPath })
205
+ const before = await readFile(
206
+ join(dir, 'src-tauri', 'src', 'main.rs'),
207
+ 'utf-8'
208
+ )
209
+
210
+ const second = await generate(dir, { binaryPath })
211
+ assert.deepEqual(
212
+ second.written,
213
+ [],
214
+ `nothing should be rewritten, got ${second.written.join(', ')}`
215
+ )
216
+ assert.deepEqual(second.preserved, [])
217
+ assert.equal(
218
+ await readFile(join(dir, 'src-tauri', 'src', 'main.rs'), 'utf-8'),
219
+ before
220
+ )
221
+ } finally {
222
+ await rm(dir, { recursive: true, force: true })
223
+ }
224
+ })
225
+
226
+ it('refuses to clobber a main.rs the user has edited, and says so', async () => {
227
+ const dir = await scratch()
228
+ try {
229
+ await generate(dir)
230
+ const mainPath = join(dir, 'src-tauri', 'src', 'main.rs')
231
+ const edited = '// my own shell\nfn main() {}\n'
232
+ await writeFile(mainPath, edited, 'utf-8')
233
+
234
+ const second = await generate(dir)
235
+ assert.ok(
236
+ second.preserved.includes('src/main.rs'),
237
+ 'an untouched report would hide the fact that the edit was kept'
238
+ )
239
+ assert.ok(!second.written.includes('src/main.rs'))
240
+ assert.equal(await readFile(mainPath, 'utf-8'), edited)
241
+ } finally {
242
+ await rm(dir, { recursive: true, force: true })
243
+ }
244
+ })
245
+
246
+ it('leaves a pre-existing src-tauri it did not generate alone', async () => {
247
+ const dir = await scratch()
248
+ try {
249
+ await mkdir(join(dir, 'src-tauri', 'src'), { recursive: true })
250
+ const mainPath = join(dir, 'src-tauri', 'src', 'main.rs')
251
+ await writeFile(mainPath, 'fn main() { /* hand written */ }', 'utf-8')
252
+
253
+ const result = await generate(dir)
254
+ assert.ok(result.preserved.includes('src/main.rs'))
255
+ assert.equal(
256
+ await readFile(mainPath, 'utf-8'),
257
+ 'fn main() { /* hand written */ }'
258
+ )
259
+ } finally {
260
+ await rm(dir, { recursive: true, force: true })
261
+ }
262
+ })
263
+
264
+ it('updates a generated file the user never touched', async () => {
265
+ const dir = await scratch()
266
+ try {
267
+ await generate(dir)
268
+ const result = await generate(dir, { windowTitle: 'Shop Desktop' })
269
+ assert.ok(
270
+ result.written.includes('src/main.rs'),
271
+ 'a template change must reach a file nobody has claimed'
272
+ )
273
+ const main = await readFile(
274
+ join(dir, 'src-tauri', 'src', 'main.rs'),
275
+ 'utf-8'
276
+ )
277
+ assert.match(main, /Shop Desktop/)
278
+ } finally {
279
+ await rm(dir, { recursive: true, force: true })
280
+ }
281
+ })
282
+
283
+ it('rejects an app name that is not a usable crate or file name', async () => {
284
+ const dir = await scratch()
285
+ try {
286
+ await assert.rejects(() => generate(dir, { appName: '../evil' }), /name/i)
287
+ } finally {
288
+ await rm(dir, { recursive: true, force: true })
289
+ }
290
+ })
291
+
292
+ it('fails loudly when the binary it was pointed at is missing', async () => {
293
+ const dir = await scratch()
294
+ try {
295
+ await assert.rejects(
296
+ () => generate(dir, { binaryPath: join(dir, 'nope') }),
297
+ /nope/
298
+ )
299
+ } finally {
300
+ await rm(dir, { recursive: true, force: true })
301
+ }
302
+ })
303
+ })
304
+
305
+ describe('generating a Tauri shell that points at a remote pikku server', () => {
306
+ const remote = async (
307
+ projectDir: string,
308
+ overrides: Partial<Parameters<typeof generateTauriShell>[0]> = {}
309
+ ) =>
310
+ generate(projectDir, {
311
+ remoteUrl: 'https://shop.example.com',
312
+ ...overrides,
313
+ })
314
+
315
+ it('declares the window in the config, because the url is already known', async () => {
316
+ const dir = await scratch()
317
+ try {
318
+ await remote(dir)
319
+ const conf = JSON.parse(
320
+ await readFile(join(dir, 'src-tauri', 'tauri.conf.json'), 'utf-8')
321
+ )
322
+ assert.equal(conf.app.windows[0].url, 'https://shop.example.com')
323
+ assert.equal(conf.app.windows[0].label, 'main')
324
+ assert.equal(
325
+ conf.bundle.externalBin,
326
+ undefined,
327
+ 'there is no sidecar to declare'
328
+ )
329
+ } finally {
330
+ await rm(dir, { recursive: true, force: true })
331
+ }
332
+ })
333
+
334
+ it('leaves the sidecar machinery out of main.rs entirely', async () => {
335
+ const dir = await scratch()
336
+ try {
337
+ await remote(dir)
338
+ const main = await readFile(
339
+ join(dir, 'src-tauri', 'src', 'main.rs'),
340
+ 'utf-8'
341
+ )
342
+
343
+ assert.match(
344
+ main,
345
+ /tauri_plugin_single_instance/,
346
+ 'a second window on the same remote session is still not wanted'
347
+ )
348
+ assert.doesNotMatch(main, /sidecar|CommandChild/)
349
+ assert.ok(!main.includes(SERVER_READY_MARKER))
350
+ assert.doesNotMatch(main, new RegExp(DATA_DIR_ENV))
351
+ assert.doesNotMatch(main, new RegExp(PARENT_PID_ENV))
352
+ } finally {
353
+ await rm(dir, { recursive: true, force: true })
354
+ }
355
+ })
356
+
357
+ it('drops the shell plugin it no longer has a process to run', async () => {
358
+ const dir = await scratch()
359
+ try {
360
+ await remote(dir)
361
+ const cargo = await readFile(
362
+ join(dir, 'src-tauri', 'Cargo.toml'),
363
+ 'utf-8'
364
+ )
365
+ assert.doesNotMatch(cargo, /tauri-plugin-shell/)
366
+ assert.match(cargo, /tauri-plugin-single-instance/)
367
+ } finally {
368
+ await rm(dir, { recursive: true, force: true })
369
+ }
370
+ })
371
+
372
+ it('refuses a binary it has nothing to run with', async () => {
373
+ const dir = await scratch()
374
+ try {
375
+ const binaryPath = await withBinary(dir)
376
+ await assert.rejects(
377
+ () => remote(dir, { binaryPath }),
378
+ /remote/i,
379
+ 'shipping a sidecar nothing starts would be a silent lie'
380
+ )
381
+ } finally {
382
+ await rm(dir, { recursive: true, force: true })
383
+ }
384
+ })
385
+
386
+ it('rejects a url the webview could not open', async () => {
387
+ const dir = await scratch()
388
+ try {
389
+ await assert.rejects(
390
+ () => generate(dir, { remoteUrl: 'file:///etc/passwd' }),
391
+ /http/i
392
+ )
393
+ await assert.rejects(
394
+ () => generate(dir, { remoteUrl: 'not a url' }),
395
+ /url/i
396
+ )
397
+ } finally {
398
+ await rm(dir, { recursive: true, force: true })
399
+ }
400
+ })
401
+ })