@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,327 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ import { renderPlaceholderIcon } from './icon.js'
6
+ import { renderMainRs } from './main-rs.js'
7
+ import { hostTargetTriple, sidecarFileName } from './target-triple.js'
8
+
9
+ /** Directory the shell crate is generated into, relative to the project root. */
10
+ export const TAURI_SHELL_DIR = 'src-tauri'
11
+
12
+ /**
13
+ * Records the bytes this generator last wrote for each file, so a regenerate
14
+ * can tell "unchanged since we wrote it" from "the user has taken this over".
15
+ * Without it the only options are to clobber edits or to never update anything.
16
+ */
17
+ const MANIFEST_FILE = '.pikku-shell.json'
18
+
19
+ const ICON_SIZE = 512
20
+
21
+ type Manifest = { version: number; files: Record<string, string> }
22
+
23
+ const hash = (content: Buffer | string): string =>
24
+ createHash('sha256').update(content).digest('hex')
25
+
26
+ const readManifest = async (shellDir: string): Promise<Manifest> => {
27
+ try {
28
+ const parsed = JSON.parse(
29
+ await readFile(join(shellDir, MANIFEST_FILE), 'utf-8')
30
+ ) as Manifest
31
+ if (parsed && typeof parsed === 'object' && parsed.files) return parsed
32
+ } catch {
33
+ // A missing or unreadable manifest means every existing file is the user's.
34
+ }
35
+ return { version: 1, files: {} }
36
+ }
37
+
38
+ /**
39
+ * A crate name, a file name and a bundle identifier segment all reject the same
40
+ * things, so one rule covers them.
41
+ */
42
+ const slug = (raw: string): string =>
43
+ raw
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, '-')
46
+ .replace(/^-+|-+$/g, '')
47
+
48
+ /**
49
+ * A reverse-DNS identifier for the bundle.
50
+ *
51
+ * A scoped package already names its org, so `@acme/shop` becomes
52
+ * `com.acme.shop`. An unscoped name has no org to borrow, and `com.shop.app`
53
+ * is not an option — macOS rejects an identifier ending in `.app`.
54
+ */
55
+ export const tauriBundleIdentifier = (packageName: string): string => {
56
+ const scoped = /^@([^/]+)\/(.+)$/.exec(packageName)
57
+ if (scoped) {
58
+ return `com.${slug(scoped[1]!)}.${slug(scoped[2]!)}`
59
+ }
60
+ return `com.${slug(packageName)}.desktop`
61
+ }
62
+
63
+ export type GenerateTauriShellOptions = {
64
+ /** Project root. The crate is written to `<projectDir>/src-tauri`. */
65
+ projectDir: string
66
+ /** Product name, and the `externalBin` base name of the sidecar. */
67
+ appName: string
68
+ /** Reverse-DNS bundle identifier. See {@link tauriBundleIdentifier}. */
69
+ identifier: string
70
+ version?: string
71
+ windowTitle?: string
72
+ width?: number
73
+ height?: number
74
+ /** The compiled pikku binary to install as the sidecar. */
75
+ binaryPath?: string
76
+ /** Defaults to the host triple, via `rustc -vV` when available. */
77
+ targetTriple?: string
78
+ /**
79
+ * An already-running server to open the window against, instead of shipping
80
+ * one. The shell then bundles nothing: no sidecar, no binary, no supervision.
81
+ */
82
+ remoteUrl?: string
83
+ }
84
+
85
+ export type GenerateTauriShellResult = {
86
+ /** Absolute path of the generated crate. */
87
+ dir: string
88
+ /** Files written this run, relative to `dir`. */
89
+ written: string[]
90
+ /** Files left alone because the user has edited them, relative to `dir`. */
91
+ preserved: string[]
92
+ targetTriple: string
93
+ sidecar?: { fileName: string; path: string }
94
+ }
95
+
96
+ const renderConfig = (options: {
97
+ appName: string
98
+ identifier: string
99
+ version: string
100
+ windowTitle: string
101
+ width: number
102
+ height: number
103
+ remoteUrl?: string
104
+ }): string =>
105
+ JSON.stringify(
106
+ {
107
+ $schema: 'https://schema.tauri.app/config/2',
108
+ productName: options.appName,
109
+ version: options.version,
110
+ identifier: options.identifier,
111
+ build: {
112
+ // The real UI is served by the sidecar over HTTP; the window is pointed
113
+ // at it from Rust once the port is known. Tauri still requires a
114
+ // frontend directory to exist, so a placeholder page stands in.
115
+ frontendDist: 'ui',
116
+ },
117
+ app: {
118
+ // A sidecar's origin is not known until it reports its port, so its
119
+ // window is built from Rust and this stays deliberately empty. A remote
120
+ // url is known here, and a declared window is the whole program.
121
+ windows: options.remoteUrl
122
+ ? [
123
+ {
124
+ label: 'main',
125
+ url: options.remoteUrl,
126
+ title: options.windowTitle,
127
+ width: options.width,
128
+ height: options.height,
129
+ },
130
+ ]
131
+ : [],
132
+ security: { csp: null },
133
+ },
134
+ bundle: {
135
+ active: true,
136
+ targets: 'all',
137
+ icon: ['icons/icon.png'],
138
+ ...(options.remoteUrl
139
+ ? {}
140
+ : { externalBin: [`binaries/${options.appName}`] }),
141
+ },
142
+ },
143
+ null,
144
+ 2
145
+ ) + '\n'
146
+
147
+ const renderCargoToml = (options: {
148
+ crateName: string
149
+ version: string
150
+ remoteUrl?: string
151
+ }): string =>
152
+ `[package]
153
+ name = "${options.crateName}"
154
+ version = "${options.version}"
155
+ edition = "2021"
156
+
157
+ [build-dependencies]
158
+ tauri-build = { version = "2", features = [] }
159
+
160
+ [dependencies]
161
+ tauri = { version = "2", features = [] }
162
+ ${options.remoteUrl ? '' : 'tauri-plugin-shell = "2"\n'}tauri-plugin-single-instance = "2"
163
+
164
+ [profile.release]
165
+ panic = "abort"
166
+ codegen-units = 1
167
+ lto = true
168
+ strip = true
169
+ `
170
+
171
+ const PLACEHOLDER_UI = `<!doctype html>
172
+ <meta charset="utf-8" />
173
+ <title>Starting…</title>
174
+ <p>Starting…</p>
175
+ `
176
+
177
+ const CAPABILITIES = JSON.stringify(
178
+ {
179
+ $schema: '../gen/schemas/desktop-schema.json',
180
+ identifier: 'default',
181
+ description: 'Baseline permissions for the pikku desktop shell.',
182
+ windows: ['main'],
183
+ permissions: ['core:default'],
184
+ },
185
+ null,
186
+ 2
187
+ )
188
+
189
+ const GITIGNORE = `/target
190
+ /binaries
191
+ /gen
192
+ `
193
+
194
+ /**
195
+ * A webview can only open an http(s) origin, and everything the shell exists to
196
+ * preserve — first-party cookies, CORS, OAuth redirects — is keyed on it. A
197
+ * `file:` or custom-scheme url would build fine and then fail at runtime.
198
+ */
199
+ const normalizeRemoteUrl = (raw: string): string => {
200
+ const trimmed = raw.trim()
201
+ let parsed: URL
202
+ try {
203
+ parsed = new URL(trimmed)
204
+ } catch {
205
+ throw new Error(`"${raw}" is not a url the desktop shell could open.`)
206
+ }
207
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
208
+ throw new Error(
209
+ `The desktop shell opens an http or https url; "${raw}" is ${parsed.protocol.replace(':', '')}.`
210
+ )
211
+ }
212
+ return trimmed
213
+ }
214
+
215
+ export const generateTauriShell = async (
216
+ options: GenerateTauriShellOptions
217
+ ): Promise<GenerateTauriShellResult> => {
218
+ const appName = options.appName
219
+ if (!appName || slug(appName) !== appName) {
220
+ throw new Error(
221
+ `"${appName}" is not a usable app name for a Tauri shell — use lowercase letters, digits and dashes (got the slug "${slug(appName)}").`
222
+ )
223
+ }
224
+
225
+ const remoteUrl = options.remoteUrl
226
+ ? normalizeRemoteUrl(options.remoteUrl)
227
+ : undefined
228
+ if (remoteUrl && options.binaryPath) {
229
+ throw new Error(
230
+ '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.'
231
+ )
232
+ }
233
+
234
+ const version = options.version ?? '0.1.0'
235
+ const windowTitle = options.windowTitle ?? appName
236
+ const width = options.width ?? 1200
237
+ const height = options.height ?? 800
238
+ const targetTriple = options.targetTriple ?? hostTargetTriple()
239
+ const shellDir = join(options.projectDir, TAURI_SHELL_DIR)
240
+
241
+ const files: Array<[string, Buffer | string]> = [
242
+ [
243
+ 'tauri.conf.json',
244
+ renderConfig({
245
+ appName,
246
+ identifier: options.identifier,
247
+ version,
248
+ windowTitle,
249
+ width,
250
+ height,
251
+ remoteUrl,
252
+ }),
253
+ ],
254
+ [
255
+ 'Cargo.toml',
256
+ renderCargoToml({ crateName: `${appName}-shell`, version, remoteUrl }),
257
+ ],
258
+ ['build.rs', 'fn main() {\n tauri_build::build()\n}\n'],
259
+ [
260
+ 'src/main.rs',
261
+ renderMainRs(
262
+ remoteUrl
263
+ ? { remoteUrl, windowTitle, width, height }
264
+ : { sidecarName: appName, windowTitle, width, height }
265
+ ),
266
+ ],
267
+ ['ui/index.html', PLACEHOLDER_UI],
268
+ ['capabilities/default.json', CAPABILITIES],
269
+ ['icons/icon.png', renderPlaceholderIcon(ICON_SIZE)],
270
+ ['.gitignore', GITIGNORE],
271
+ ]
272
+
273
+ const manifest = await readManifest(shellDir)
274
+ const written: string[] = []
275
+ const preserved: string[] = []
276
+
277
+ for (const [relativePath, content] of files) {
278
+ const target = join(shellDir, relativePath)
279
+ const nextHash = hash(content)
280
+
281
+ let existing: Buffer | undefined
282
+ try {
283
+ existing = await readFile(target)
284
+ } catch {
285
+ existing = undefined
286
+ }
287
+
288
+ if (existing) {
289
+ const currentHash = hash(existing)
290
+ if (currentHash === nextHash) {
291
+ manifest.files[relativePath] = nextHash
292
+ continue
293
+ }
294
+ if (manifest.files[relativePath] !== currentHash) {
295
+ preserved.push(relativePath)
296
+ continue
297
+ }
298
+ }
299
+
300
+ await mkdir(dirname(target), { recursive: true })
301
+ await writeFile(target, content)
302
+ manifest.files[relativePath] = nextHash
303
+ written.push(relativePath)
304
+ }
305
+
306
+ let sidecar: GenerateTauriShellResult['sidecar']
307
+ if (options.binaryPath) {
308
+ // Build output rather than source: always replaced, never diffed against
309
+ // the manifest, and gitignored.
310
+ const binary = await readFile(options.binaryPath)
311
+ const fileName = sidecarFileName(appName, targetTriple)
312
+ const target = join(shellDir, 'binaries', fileName)
313
+ await mkdir(dirname(target), { recursive: true })
314
+ await writeFile(target, binary)
315
+ await chmod(target, 0o755)
316
+ sidecar = { fileName, path: target }
317
+ }
318
+
319
+ await mkdir(shellDir, { recursive: true })
320
+ await writeFile(
321
+ join(shellDir, MANIFEST_FILE),
322
+ JSON.stringify(manifest, null, 2) + '\n',
323
+ 'utf-8'
324
+ )
325
+
326
+ return { dir: shellDir, written, preserved, targetTriple, sidecar }
327
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { inflateSync } from 'node:zlib'
4
+
5
+ import { renderPlaceholderIcon } from './icon.js'
6
+
7
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
8
+
9
+ const chunks = (png: Buffer) => {
10
+ const found: Array<{ type: string; data: Buffer }> = []
11
+ let offset = PNG_SIGNATURE.length
12
+ while (offset < png.length) {
13
+ const length = png.readUInt32BE(offset)
14
+ const type = png.subarray(offset + 4, offset + 8).toString('ascii')
15
+ const data = png.subarray(offset + 8, offset + 8 + length)
16
+ const crc = png.readUInt32BE(offset + 8 + length)
17
+ found.push({ type, data })
18
+ assert.equal(typeof crc, 'number')
19
+ offset += 12 + length
20
+ }
21
+ return found
22
+ }
23
+
24
+ describe('the placeholder icon a generated shell ships with', () => {
25
+ it('is a real PNG, not a stub a bundler will reject', () => {
26
+ const png = renderPlaceholderIcon(512)
27
+ assert.ok(png.subarray(0, 8).equals(PNG_SIGNATURE))
28
+
29
+ const parsed = chunks(png)
30
+ assert.deepEqual(
31
+ parsed.map((c) => c.type),
32
+ ['IHDR', 'IDAT', 'IEND'],
33
+ 'a decoder walks the chunks in order and stops at IEND'
34
+ )
35
+ })
36
+
37
+ it('is square at the size asked for, in 8-bit RGBA', () => {
38
+ const ihdr = chunks(renderPlaceholderIcon(256)).find(
39
+ (c) => c.type === 'IHDR'
40
+ )!
41
+ assert.equal(ihdr.data.readUInt32BE(0), 256)
42
+ assert.equal(ihdr.data.readUInt32BE(4), 256)
43
+ assert.equal(ihdr.data.readUInt8(8), 8, 'bit depth')
44
+ assert.equal(ihdr.data.readUInt8(9), 6, 'colour type RGBA')
45
+ })
46
+
47
+ it('carries one filter byte and one opaque row per line', () => {
48
+ const size = 8
49
+ const idat = chunks(renderPlaceholderIcon(size)).find(
50
+ (c) => c.type === 'IDAT'
51
+ )!
52
+ const raw = inflateSync(idat.data)
53
+ assert.equal(raw.length, size * (1 + size * 4))
54
+ for (let y = 0; y < size; y++) {
55
+ assert.equal(raw[y * (1 + size * 4)], 0, 'filter type 0')
56
+ }
57
+ assert.equal(raw[4], 255, 'alpha must be opaque')
58
+ })
59
+
60
+ it('is deterministic, so regenerating never churns the file', () => {
61
+ assert.ok(renderPlaceholderIcon(64).equals(renderPlaceholderIcon(64)))
62
+ })
63
+ })
@@ -0,0 +1,62 @@
1
+ import { crc32, deflateSync } from 'node:zlib'
2
+
3
+ /**
4
+ * A valid, deliberately plain app icon.
5
+ *
6
+ * Tauri's bundler refuses to package without one, so a generated shell has to
7
+ * ship something rather than leaving the first `tauri build` to fail on a
8
+ * missing file. Encoding it here keeps the generator free of binary fixtures
9
+ * and of an image dependency; `npx tauri icon <your-icon.png>` replaces it with
10
+ * the full platform set the moment a project has real artwork.
11
+ */
12
+
13
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
14
+
15
+ const chunk = (type: string, data: Buffer): Buffer => {
16
+ const typeAndData = Buffer.concat([Buffer.from(type, 'ascii'), data])
17
+ const length = Buffer.alloc(4)
18
+ length.writeUInt32BE(data.length)
19
+ const crc = Buffer.alloc(4)
20
+ crc.writeUInt32BE(crc32(typeAndData) >>> 0)
21
+ return Buffer.concat([length, typeAndData, crc])
22
+ }
23
+
24
+ /** A flat slate square — recognisably a placeholder, and legible at any size. */
25
+ const FILL = [0x2f, 0x36, 0x40, 0xff] as const
26
+
27
+ export const renderPlaceholderIcon = (size: number): Buffer => {
28
+ if (!Number.isInteger(size) || size <= 0) {
29
+ throw new Error(`Icon size must be a positive integer, got ${size}`)
30
+ }
31
+
32
+ const stride = 1 + size * 4
33
+ const raw = Buffer.alloc(size * stride)
34
+ for (let y = 0; y < size; y++) {
35
+ const rowStart = y * stride
36
+ // Filter type 0 (None) — no prediction, so the row is its own pixels.
37
+ raw[rowStart] = 0
38
+ for (let x = 0; x < size; x++) {
39
+ const px = rowStart + 1 + x * 4
40
+ raw[px] = FILL[0]
41
+ raw[px + 1] = FILL[1]
42
+ raw[px + 2] = FILL[2]
43
+ raw[px + 3] = FILL[3]
44
+ }
45
+ }
46
+
47
+ const ihdr = Buffer.alloc(13)
48
+ ihdr.writeUInt32BE(size, 0)
49
+ ihdr.writeUInt32BE(size, 4)
50
+ ihdr.writeUInt8(8, 8)
51
+ ihdr.writeUInt8(6, 9)
52
+ ihdr.writeUInt8(0, 10)
53
+ ihdr.writeUInt8(0, 11)
54
+ ihdr.writeUInt8(0, 12)
55
+
56
+ return Buffer.concat([
57
+ PNG_SIGNATURE,
58
+ chunk('IHDR', ihdr),
59
+ chunk('IDAT', deflateSync(raw, { level: 9 })),
60
+ chunk('IEND', Buffer.alloc(0)),
61
+ ])
62
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { spawnSync } from 'node:child_process'
4
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ import { renderMainRs } from './main-rs.js'
9
+
10
+ /**
11
+ * `rustfmt` parses the file before it formats it, so a clean `--check` is proof
12
+ * the generated shell is syntactically valid Rust. It is not proof that it
13
+ * type-checks — only `cargo build`, with the Tauri crates fetched, shows that.
14
+ */
15
+ const rustfmtAvailable =
16
+ spawnSync('rustfmt', ['--version'], { stdio: 'ignore' }).status === 0
17
+
18
+ describe('the generated main.rs as Rust source', () => {
19
+ it(
20
+ 'parses, and is already in rustfmt form',
21
+ { skip: rustfmtAvailable ? false : 'rustfmt is not installed' },
22
+ async () => {
23
+ const dir = await mkdtemp(join(tmpdir(), 'pikku-mainrs-'))
24
+ const file = join(dir, 'main.rs')
25
+ try {
26
+ await writeFile(
27
+ file,
28
+ renderMainRs({
29
+ sidecarName: 'shop',
30
+ windowTitle: 'Shop',
31
+ width: 1200,
32
+ height: 800,
33
+ }),
34
+ 'utf-8'
35
+ )
36
+
37
+ const result = spawnSync(
38
+ 'rustfmt',
39
+ ['--edition', '2021', '--check', file],
40
+ { encoding: 'utf-8' }
41
+ )
42
+ assert.equal(
43
+ result.status,
44
+ 0,
45
+ `rustfmt rejected the generated shell:\n${result.stdout}${result.stderr}`
46
+ )
47
+ } finally {
48
+ await rm(dir, { recursive: true, force: true })
49
+ }
50
+ }
51
+ )
52
+
53
+ it(
54
+ 'parses in its remote form too',
55
+ { skip: rustfmtAvailable ? false : 'rustfmt is not installed' },
56
+ async () => {
57
+ const dir = await mkdtemp(join(tmpdir(), 'pikku-mainrs-remote-'))
58
+ const file = join(dir, 'main.rs')
59
+ try {
60
+ await writeFile(
61
+ file,
62
+ renderMainRs({
63
+ remoteUrl: 'https://shop.example.com',
64
+ windowTitle: 'Shop',
65
+ width: 1200,
66
+ height: 800,
67
+ }),
68
+ 'utf-8'
69
+ )
70
+
71
+ const result = spawnSync(
72
+ 'rustfmt',
73
+ ['--edition', '2021', '--check', file],
74
+ { encoding: 'utf-8' }
75
+ )
76
+ assert.equal(
77
+ result.status,
78
+ 0,
79
+ `rustfmt rejected the generated remote shell:\n${result.stdout}${result.stderr}`
80
+ )
81
+ } finally {
82
+ await rm(dir, { recursive: true, force: true })
83
+ }
84
+ }
85
+ )
86
+ })