@pikku/deploy-standalone 0.12.12 → 0.12.17

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 (50) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/dist/adapter.d.ts +45 -0
  3. package/dist/adapter.js +448 -6
  4. package/dist/runtime/cli.d.ts +75 -0
  5. package/dist/runtime/cli.js +193 -0
  6. package/dist/runtime/index.d.ts +11 -0
  7. package/dist/runtime/index.js +9 -0
  8. package/dist/runtime/parent-watch.d.ts +45 -0
  9. package/dist/runtime/parent-watch.js +87 -0
  10. package/dist/tauri/generate.d.ts +45 -0
  11. package/dist/tauri/generate.js +230 -0
  12. package/dist/tauri/icon.d.ts +1 -0
  13. package/dist/tauri/icon.js +54 -0
  14. package/dist/tauri/main-rs.d.ts +31 -0
  15. package/dist/tauri/main-rs.js +213 -0
  16. package/dist/tauri/next-steps.d.ts +15 -0
  17. package/dist/tauri/next-steps.js +16 -0
  18. package/dist/tauri/target-triple.d.ts +29 -0
  19. package/dist/tauri/target-triple.js +42 -0
  20. package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
  21. package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
  22. package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
  23. package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
  24. package/knowledge/decisions/index.md +19 -0
  25. package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
  26. package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
  27. package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
  28. package/knowledge/index.md +22 -0
  29. package/package.json +7 -4
  30. package/src/adapter.test.ts +725 -0
  31. package/src/adapter.ts +508 -6
  32. package/src/desktop-deploy.test.ts +167 -0
  33. package/src/runtime/cli.test.ts +222 -0
  34. package/src/runtime/cli.ts +311 -0
  35. package/src/runtime/index.ts +31 -0
  36. package/src/runtime/parent-watch.process.test.ts +112 -0
  37. package/src/runtime/parent-watch.test.ts +148 -0
  38. package/src/runtime/parent-watch.ts +115 -0
  39. package/src/sidecar-entry.test.ts +89 -0
  40. package/src/tauri/generate.test.ts +401 -0
  41. package/src/tauri/generate.ts +327 -0
  42. package/src/tauri/icon.test.ts +63 -0
  43. package/src/tauri/icon.ts +62 -0
  44. package/src/tauri/main-rs.rustfmt.test.ts +86 -0
  45. package/src/tauri/main-rs.ts +241 -0
  46. package/src/tauri/next-steps.test.ts +38 -0
  47. package/src/tauri/next-steps.ts +30 -0
  48. package/src/tauri/target-triple.test.ts +84 -0
  49. package/src/tauri/target-triple.ts +65 -0
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,193 @@
1
+ /** Where the migrations live, when the operator has moved them. */
2
+ export const MIGRATIONS_DIR_ENV = 'PIKKU_MIGRATIONS_DIR';
3
+ const usage = (hasDb, engine) => [
4
+ 'Usage: <bundle> [command]',
5
+ '',
6
+ ' serve Start the server. The default when no command is given.',
7
+ ' version Print the version this artifact was built from.',
8
+ ' help Print this.',
9
+ ...(hasDb
10
+ ? [
11
+ '',
12
+ ' db migrate Apply pending migrations to the database this build opens.',
13
+ ' db status List applied and pending migrations.',
14
+ ...(engine === 'sqlite'
15
+ ? [' backup <path> Copy the database to <path>, consistently.']
16
+ : []),
17
+ ]
18
+ : []),
19
+ '',
20
+ 'Environment:',
21
+ ` ${MIGRATIONS_DIR_ENV} Migrations directory, when not the one beside the bundle.`,
22
+ ].join('\n');
23
+ export function parseStandaloneCommand(argv, options) {
24
+ const write = options.write ?? ((line) => console.log(line));
25
+ const [command, ...rest] = argv;
26
+ if (command === undefined || command === 'serve')
27
+ return { kind: 'serve' };
28
+ if (command === 'version' || command === '--version' || command === '-v') {
29
+ write(options.version);
30
+ return { kind: 'exit', code: 0 };
31
+ }
32
+ if (command === 'help' || command === '--help' || command === '-h') {
33
+ write(usage(options.hasDb, options.engine));
34
+ return { kind: 'exit', code: 0 };
35
+ }
36
+ const needsDb = command === 'db' || command === 'backup';
37
+ if (needsDb && !options.hasDb) {
38
+ write(`This build opens no database, so there is nothing for \`${command}\` to act on.`);
39
+ return { kind: 'exit', code: 1 };
40
+ }
41
+ if (command === 'db') {
42
+ const action = rest[0];
43
+ if (action === 'migrate' || action === 'status') {
44
+ return { kind: 'db', action };
45
+ }
46
+ write(action === undefined
47
+ ? 'db needs an action: migrate or status.'
48
+ : `Unknown db action: ${action}. Expected migrate or status.`);
49
+ return { kind: 'exit', code: 1 };
50
+ }
51
+ if (command === 'backup') {
52
+ // Postgres has pg_dump, which understands roles, extensions and large
53
+ // objects that copying bytes out from in here would silently drop. Offering
54
+ // the word for both engines would promise a backup on one of them that is
55
+ // not one.
56
+ if (options.engine !== 'sqlite') {
57
+ write('backup is for the SQLite builds, whose database is a file this process owns. Use pg_dump against DATABASE_URL.');
58
+ return { kind: 'exit', code: 1 };
59
+ }
60
+ const destination = rest[0];
61
+ if (!destination) {
62
+ write('backup needs a path to write the copy to.');
63
+ return { kind: 'exit', code: 1 };
64
+ }
65
+ return { kind: 'backup', destination };
66
+ }
67
+ write(`Unknown command: ${command}\n\n${usage(options.hasDb, options.engine)}`);
68
+ return { kind: 'exit', code: 1 };
69
+ }
70
+ /**
71
+ * The migrations directory, honouring an operator who keeps them elsewhere.
72
+ *
73
+ * `bundleDir` is where the build put them, which is the same `db/<engine>/`
74
+ * path Fabric's build container stages into an artifact — so an artifact from
75
+ * either producer answers `db migrate` without being told where to look.
76
+ */
77
+ export const resolveMigrationsDir = (bundleDir, env = process.env) => env[MIGRATIONS_DIR_ENV] ?? bundleDir;
78
+ /**
79
+ * The migration executor for whichever database this build opens.
80
+ *
81
+ * Both drivers are reached by dynamic import so a SQLite build never carries
82
+ * the Postgres one into its bundle, and vice versa.
83
+ */
84
+ const executorFor = async (db) => {
85
+ if (db.engine === 'sqlite') {
86
+ const { SqliteMigrationExecutor, loadSqliteRuntime } = await import('@pikku/migrator-sql/sqlite');
87
+ const runtime = await loadSqliteRuntime();
88
+ const handle = runtime.open(db.databaseFile);
89
+ return {
90
+ executor: new SqliteMigrationExecutor(handle),
91
+ close: () => handle.close(),
92
+ };
93
+ }
94
+ const { PostgresMigrationExecutor } = await import('@pikku/migrator-sql/postgres');
95
+ return {
96
+ executor: new PostgresMigrationExecutor(postgresClient(db.sql)),
97
+ close: () => { },
98
+ };
99
+ };
100
+ /**
101
+ * A `PostgresMigrationClient` over the app's own postgres.js connection.
102
+ *
103
+ * `begin` is what makes a failed migration roll back: postgres.js hands the
104
+ * handler a single reserved connection, so the DDL and the bookkeeping row are
105
+ * one transaction rather than statements a pool may spread across three.
106
+ *
107
+ * `simple()` on the plain path is deliberate — a migration file is many
108
+ * statements, and the extended protocol accepts only one per message.
109
+ */
110
+ const postgresClient = (sql) => ({
111
+ async query(text, params) {
112
+ const rows = (await sql.unsafe(text, params ?? []));
113
+ return { rows };
114
+ },
115
+ async exec(text) {
116
+ return sql.unsafe(text).simple();
117
+ },
118
+ begin(handler) {
119
+ return sql.begin((tx) => handler(postgresClient(tx)));
120
+ },
121
+ });
122
+ const stdout = { write: (line) => console.log(line) };
123
+ export async function runDbCommand(action, db, out = stdout) {
124
+ const { migrate, pendingMigrations } = await import('@pikku/migrator-sql');
125
+ const { executor, close } = await executorFor(db);
126
+ try {
127
+ if (action === 'migrate') {
128
+ const { applied, skipped } = await migrate(executor, db.migrationsDir);
129
+ for (const name of applied)
130
+ out.write(`applied ${name}`);
131
+ out.write(applied.length === 0
132
+ ? `Already up to date (${skipped.length} applied previously).`
133
+ : `Applied ${applied.length} migration(s).`);
134
+ return;
135
+ }
136
+ await executor.ensureTrackingTable();
137
+ const applied = await executor.getApplied();
138
+ const pending = pendingMigrations(db.migrationsDir, applied);
139
+ for (const row of applied) {
140
+ out.write(`applied ${row.name} ${row.applied_at}`);
141
+ }
142
+ for (const name of pending)
143
+ out.write(`pending ${name}`);
144
+ out.write(`${applied.length} applied, ${pending.length} pending.`);
145
+ }
146
+ finally {
147
+ close();
148
+ }
149
+ }
150
+ /**
151
+ * Copy the SQLite database somewhere else, while the app may be running.
152
+ *
153
+ * `VACUUM INTO` rather than copying the file: a plain copy taken while another
154
+ * process is mid-write captures a torn page and a write-ahead log it has no
155
+ * copy of, which restores as a corrupt database and only says so later.
156
+ */
157
+ export async function runBackupCommand(destination, db, out = stdout) {
158
+ const { loadSqliteRuntime } = await import('@pikku/migrator-sql/sqlite');
159
+ const runtime = await loadSqliteRuntime();
160
+ const handle = runtime.open(db.databaseFile);
161
+ try {
162
+ handle.exec(`VACUUM INTO '${destination.replace(/'/g, "''")}'`);
163
+ out.write(`Copied ${db.databaseFile} to ${destination}.`);
164
+ }
165
+ finally {
166
+ handle.close();
167
+ }
168
+ }
169
+ /**
170
+ * Run whatever the argv asked for, and say whether the caller should serve.
171
+ *
172
+ * The database is passed already open, because the entry has to open it the one
173
+ * way the app does — a command that resolved its own connection could migrate a
174
+ * different database than the next `serve` reads.
175
+ */
176
+ export async function runStandaloneCommand(command, db, out = stdout) {
177
+ if (command.kind === 'serve')
178
+ return 'serve';
179
+ if (command.kind === 'exit')
180
+ process.exit(command.code);
181
+ if (!db) {
182
+ throw new Error('This build opens no database.');
183
+ }
184
+ if (command.kind === 'db') {
185
+ await runDbCommand(command.action, db, out);
186
+ return 'done';
187
+ }
188
+ if (db.engine !== 'sqlite') {
189
+ throw new Error('backup is only available on a SQLite build.');
190
+ }
191
+ await runBackupCommand(command.destination, db, out);
192
+ return 'done';
193
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@pikku/deploy-standalone/runtime` — the sliver of this package that runs
3
+ * inside the shipped artifact rather than on the build machine.
4
+ *
5
+ * A generated standalone entry imports from here, so the code is unit-tested
6
+ * in TypeScript instead of being a string the adapter emits and nobody runs.
7
+ */
8
+ export { DATA_DIR_ENV, PARENT_PID_ENV, watchParentProcess, } from './parent-watch.js';
9
+ export type { ParentWatch, ParentWatchOptions } from './parent-watch.js';
10
+ export { parseStandaloneCommand, runStandaloneCommand, runDbCommand, runBackupCommand, resolveMigrationsDir, MIGRATIONS_DIR_ENV, } from './cli.js';
11
+ export type { StandaloneCommand, StandaloneDb, StandaloneSqliteDb, StandalonePostgresDb, PostgresSql, CommandOutput, ParseOptions, } from './cli.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `@pikku/deploy-standalone/runtime` — the sliver of this package that runs
3
+ * inside the shipped artifact rather than on the build machine.
4
+ *
5
+ * A generated standalone entry imports from here, so the code is unit-tested
6
+ * in TypeScript instead of being a string the adapter emits and nobody runs.
7
+ */
8
+ export { DATA_DIR_ENV, PARENT_PID_ENV, watchParentProcess, } from './parent-watch.js';
9
+ export { parseStandaloneCommand, runStandaloneCommand, runDbCommand, runBackupCommand, resolveMigrationsDir, MIGRATIONS_DIR_ENV, } from './cli.js';
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Environment variable a desktop shell uses to tell its sidecar which process
3
+ * it must not outlive.
4
+ */
5
+ export declare const PARENT_PID_ENV = "PIKKU_PARENT_PID";
6
+ /**
7
+ * Environment variable a desktop shell uses to tell its sidecar where the
8
+ * SQLite file, uploaded content and runtime state belong. The shell resolves
9
+ * it from the platform's own app-data location, because a binary launched by
10
+ * double-click has no meaningful working directory.
11
+ */
12
+ export declare const DATA_DIR_ENV = "PIKKU_DATA_DIR";
13
+ export type ParentWatchOptions = {
14
+ /** Where the parent pid is read from. Defaults to `process.env`. */
15
+ env?: Record<string, string | undefined>;
16
+ /** Probe for whether a pid is still running. Defaults to signal 0. */
17
+ isAlive?: (pid: number) => boolean;
18
+ /** Run when the parent is found to be gone. Defaults to exiting cleanly. */
19
+ onOrphaned?: () => void;
20
+ intervalMs?: number;
21
+ };
22
+ export type ParentWatch = {
23
+ /** False when no usable parent pid was supplied — the watch is inert. */
24
+ readonly watching: boolean;
25
+ readonly parentPid: number | undefined;
26
+ /** True only if the poll timer would hold the event loop open. */
27
+ readonly holdsProcessOpen: boolean;
28
+ /** Probe immediately rather than waiting for the next interval. */
29
+ checkNow(): void;
30
+ stop(): void;
31
+ };
32
+ /**
33
+ * Exit when the process that spawned us does.
34
+ *
35
+ * Tauri kills its sidecar on a clean exit, but a hard crash of the shell never
36
+ * runs that path. An orphaned pikku server keeps the SQLite file open, and the
37
+ * next launch — which single-instance only guards against a second *shell* —
38
+ * would be a second writer against the same database. Polling the parent is the only portable answer: neither
39
+ * `process.on('disconnect')` (no IPC channel here) nor a closed stdin is
40
+ * reliable across the platforms a desktop build targets.
41
+ *
42
+ * With no parent pid in the environment the watch is inert, so a server run
43
+ * from a terminal or a container behaves exactly as it did before.
44
+ */
45
+ export declare const watchParentProcess: (options?: ParentWatchOptions) => ParentWatch;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Environment variable a desktop shell uses to tell its sidecar which process
3
+ * it must not outlive.
4
+ */
5
+ export const PARENT_PID_ENV = 'PIKKU_PARENT_PID';
6
+ /**
7
+ * Environment variable a desktop shell uses to tell its sidecar where the
8
+ * SQLite file, uploaded content and runtime state belong. The shell resolves
9
+ * it from the platform's own app-data location, because a binary launched by
10
+ * double-click has no meaningful working directory.
11
+ */
12
+ export const DATA_DIR_ENV = 'PIKKU_DATA_DIR';
13
+ /**
14
+ * A pid is alive if signalling it succeeds. `EPERM` also means alive — the
15
+ * process exists but belongs to another user — and only `ESRCH` means gone.
16
+ */
17
+ const defaultIsAlive = (pid) => {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch (err) {
23
+ return err.code === 'EPERM';
24
+ }
25
+ };
26
+ const parsePid = (raw) => {
27
+ if (!raw)
28
+ return undefined;
29
+ if (!/^\d+$/.test(raw))
30
+ return undefined;
31
+ const pid = Number(raw);
32
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
33
+ };
34
+ /**
35
+ * Exit when the process that spawned us does.
36
+ *
37
+ * Tauri kills its sidecar on a clean exit, but a hard crash of the shell never
38
+ * runs that path. An orphaned pikku server keeps the SQLite file open, and the
39
+ * next launch — which single-instance only guards against a second *shell* —
40
+ * would be a second writer against the same database. Polling the parent is the only portable answer: neither
41
+ * `process.on('disconnect')` (no IPC channel here) nor a closed stdin is
42
+ * reliable across the platforms a desktop build targets.
43
+ *
44
+ * With no parent pid in the environment the watch is inert, so a server run
45
+ * from a terminal or a container behaves exactly as it did before.
46
+ */
47
+ export const watchParentProcess = (options = {}) => {
48
+ const env = options.env ?? process.env;
49
+ const isAlive = options.isAlive ?? defaultIsAlive;
50
+ const onOrphaned = options.onOrphaned ?? (() => process.exit(0));
51
+ const intervalMs = options.intervalMs ?? 1_000;
52
+ const parentPid = parsePid(env[PARENT_PID_ENV]);
53
+ let timer;
54
+ let fired = false;
55
+ const stop = () => {
56
+ if (timer) {
57
+ clearInterval(timer);
58
+ timer = undefined;
59
+ }
60
+ };
61
+ const checkNow = () => {
62
+ if (parentPid === undefined || fired)
63
+ return;
64
+ if (isAlive(parentPid))
65
+ return;
66
+ fired = true;
67
+ stop();
68
+ onOrphaned();
69
+ };
70
+ if (parentPid !== undefined) {
71
+ timer = setInterval(checkNow, intervalMs);
72
+ // The watch is a guard, not a reason to stay running: a server that has
73
+ // finished its work must still be allowed to exit.
74
+ timer.unref?.();
75
+ }
76
+ return {
77
+ get watching() {
78
+ return timer !== undefined;
79
+ },
80
+ parentPid,
81
+ get holdsProcessOpen() {
82
+ return timer?.hasRef?.() ?? false;
83
+ },
84
+ checkNow,
85
+ stop,
86
+ };
87
+ };
@@ -0,0 +1,45 @@
1
+ /** Directory the shell crate is generated into, relative to the project root. */
2
+ export declare const TAURI_SHELL_DIR = "src-tauri";
3
+ /**
4
+ * A reverse-DNS identifier for the bundle.
5
+ *
6
+ * A scoped package already names its org, so `@acme/shop` becomes
7
+ * `com.acme.shop`. An unscoped name has no org to borrow, and `com.shop.app`
8
+ * is not an option — macOS rejects an identifier ending in `.app`.
9
+ */
10
+ export declare const tauriBundleIdentifier: (packageName: string) => string;
11
+ export type GenerateTauriShellOptions = {
12
+ /** Project root. The crate is written to `<projectDir>/src-tauri`. */
13
+ projectDir: string;
14
+ /** Product name, and the `externalBin` base name of the sidecar. */
15
+ appName: string;
16
+ /** Reverse-DNS bundle identifier. See {@link tauriBundleIdentifier}. */
17
+ identifier: string;
18
+ version?: string;
19
+ windowTitle?: string;
20
+ width?: number;
21
+ height?: number;
22
+ /** The compiled pikku binary to install as the sidecar. */
23
+ binaryPath?: string;
24
+ /** Defaults to the host triple, via `rustc -vV` when available. */
25
+ targetTriple?: string;
26
+ /**
27
+ * An already-running server to open the window against, instead of shipping
28
+ * one. The shell then bundles nothing: no sidecar, no binary, no supervision.
29
+ */
30
+ remoteUrl?: string;
31
+ };
32
+ export type GenerateTauriShellResult = {
33
+ /** Absolute path of the generated crate. */
34
+ dir: string;
35
+ /** Files written this run, relative to `dir`. */
36
+ written: string[];
37
+ /** Files left alone because the user has edited them, relative to `dir`. */
38
+ preserved: string[];
39
+ targetTriple: string;
40
+ sidecar?: {
41
+ fileName: string;
42
+ path: string;
43
+ };
44
+ };
45
+ export declare const generateTauriShell: (options: GenerateTauriShellOptions) => Promise<GenerateTauriShellResult>;
@@ -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
+ };