@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.
- package/CHANGELOG.md +70 -0
- package/dist/adapter.d.ts +37 -45
- package/dist/adapter.js +187 -46
- package/dist/index.d.ts +1 -1
- package/dist/runtime/index.d.ts +9 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/parent-watch.d.ts +45 -0
- package/dist/runtime/parent-watch.js +87 -0
- package/dist/tauri/generate.d.ts +45 -0
- package/dist/tauri/generate.js +230 -0
- package/dist/tauri/icon.d.ts +1 -0
- package/dist/tauri/icon.js +54 -0
- package/dist/tauri/main-rs.d.ts +31 -0
- package/dist/tauri/main-rs.js +213 -0
- package/dist/tauri/next-steps.d.ts +15 -0
- package/dist/tauri/next-steps.js +16 -0
- package/dist/tauri/target-triple.d.ts +29 -0
- package/dist/tauri/target-triple.js +42 -0
- package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
- package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
- package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
- package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
- package/knowledge/decisions/index.md +19 -0
- package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
- package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
- package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
- package/knowledge/index.md +22 -0
- package/package.json +10 -5
- package/src/adapter.test.ts +186 -0
- package/src/adapter.ts +216 -62
- package/src/desktop-deploy.test.ts +167 -0
- package/src/index.ts +1 -3
- package/src/runtime/index.ts +13 -0
- package/src/runtime/parent-watch.process.test.ts +112 -0
- package/src/runtime/parent-watch.test.ts +148 -0
- package/src/runtime/parent-watch.ts +115 -0
- package/src/sidecar-entry.test.ts +89 -0
- package/src/tauri/generate.test.ts +401 -0
- package/src/tauri/generate.ts +327 -0
- package/src/tauri/icon.test.ts +63 -0
- package/src/tauri/icon.ts +62 -0
- package/src/tauri/main-rs.rustfmt.test.ts +86 -0
- package/src/tauri/main-rs.ts +241 -0
- package/src/tauri/next-steps.test.ts +38 -0
- package/src/tauri/next-steps.ts +30 -0
- package/src/tauri/target-triple.test.ts +84 -0
- package/src/tauri/target-triple.ts +65 -0
- 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
|
+
})
|