Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 80 additions & 28 deletions scripts/esbuild-main-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,89 @@
*
* When MESH_CLIENT_BUILD_INFO is set (CI packaging), embeds it via esbuild define
* as __MESH_CLIENT_BUILD_INFO__ for src/shared/buildInfo.ts.
*
* Uses the esbuild JS API (not a direct spawn of bin/esbuild). On Windows, postinstall leaves
* bin/esbuild as a Node shim — execFile of that path fails with no stdout (EINVAL).
*/
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import * as esbuild from 'esbuild';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';

import { mainEsbuildExternalArgs } from './esbuild-main-externals.mjs';
import { MAIN_ESBUILD_EXTERNALS } from './esbuild-main-externals.mjs';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const require = createRequire(import.meta.url);
const esbuildBin = require.resolve('esbuild/bin/esbuild');

const extraArgs = process.argv.slice(2);
const buildInfoRaw = process.env.MESH_CLIENT_BUILD_INFO ?? '';
const defineArg = `--define:__MESH_CLIENT_BUILD_INFO__=${JSON.stringify(buildInfoRaw)}`;

const args = [
'src/main/index.ts',
'--bundle',
'--platform=node',
'--outfile=dist-electron/main/index.js',
...mainEsbuildExternalArgs(),
'--format=cjs',
defineArg,
...extraArgs,
];

// shell:false so JSON quotes in --define survive on Windows runners
const result = spawnSync(esbuildBin, args, {
cwd: projectRoot,
stdio: 'inherit',
});
process.exit(result.status ?? 1);

/**
* @param {string[]} argv
* @returns {{ minify: boolean, metafilePath: string | null }}
*/
export function parseEsbuildMainBuildArgs(argv) {
let minify = false;
/** @type {string | null} */
let metafilePath = null;
for (const arg of argv) {
if (arg === '--minify') {
minify = true;
continue;
}
if (arg.startsWith('--metafile=')) {
metafilePath = arg.slice('--metafile='.length);
continue;
}
throw new Error(`Unknown esbuild-main-build argument: ${arg}`);
}
return { minify, metafilePath };
}

/**
* @param {{
* minify?: boolean
* metafilePath?: string | null
* buildInfoRaw?: string
* absWorkingDir?: string
* }} [opts]
*/
export async function buildMainProcess(opts = {}) {
const minify = opts.minify === true;
const metafilePath = opts.metafilePath ?? null;
const buildInfoRaw = opts.buildInfoRaw ?? process.env.MESH_CLIENT_BUILD_INFO ?? '';
const absWorkingDir = opts.absWorkingDir ?? projectRoot;

const result = await esbuild.build({
absWorkingDir,
entryPoints: ['src/main/index.ts'],
bundle: true,
platform: 'node',
outfile: 'dist-electron/main/index.js',
external: [...MAIN_ESBUILD_EXTERNALS],
format: 'cjs',
define: {
__MESH_CLIENT_BUILD_INFO__: JSON.stringify(buildInfoRaw),
},
minify,
metafile: Boolean(metafilePath),
logLevel: 'info',
});

if (metafilePath && result.metafile) {
const outPath = path.resolve(absWorkingDir, metafilePath);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, JSON.stringify(result.metafile));
}

return result;
}

async function main() {
const { minify, metafilePath } = parseEsbuildMainBuildArgs(process.argv.slice(2));
await buildMainProcess({ minify, metafilePath });
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
main().catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});
}
43 changes: 43 additions & 0 deletions scripts/esbuild-main-build.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';

import { parseEsbuildMainBuildArgs } from './esbuild-main-build.mjs';

describe('esbuild-main-build', () => {
it('parses minify and metafile flags', () => {
expect(parseEsbuildMainBuildArgs(['--minify'])).toEqual({
minify: true,
metafilePath: null,
});
expect(parseEsbuildMainBuildArgs(['--metafile=dist-electron/main/metafile.json'])).toEqual({
minify: false,
metafilePath: 'dist-electron/main/metafile.json',
});
expect(
parseEsbuildMainBuildArgs(['--minify', '--metafile=dist-electron/main/meta.json']),
).toEqual({
minify: true,
metafilePath: 'dist-electron/main/meta.json',
});
});

it('rejects unknown CLI flags', () => {
expect(() => parseEsbuildMainBuildArgs(['--watch'])).toThrow(/Unknown/);
});

it('uses the esbuild JS API instead of spawning bin/esbuild (Windows shim)', async () => {
const fs = await import('node:fs');
const path = await import('node:path');
const { fileURLToPath } = await import('node:url');
const src = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), 'esbuild-main-build.mjs'),
'utf8',
);
// Regression: spawnSync(require.resolve('esbuild/bin/esbuild')) fails on win32 where
// postinstall leaves bin/esbuild as a Node shim (maybeOptimizePackage skips win32).
expect(src).toContain("from 'esbuild'");
expect(src).toMatch(/\besbuild\.build\s*\(/);
expect(src).not.toMatch(/\bspawnSync\s*\(/);
expect(src).not.toContain("require.resolve('esbuild/bin/esbuild')");
expect(src).not.toMatch(/\bchild_process\b/);
});
});
130 changes: 130 additions & 0 deletions src/main/ipc/reticulum-db-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,136 @@ describe('reticulum destination / activity prune IPC', () => {
expect(row.delivery_method).toBe('paper');
});

it('saveReticulumMessage replaces exact pending hash while still sending', () => {
const identityId = 'id-rt-pending-orphan';
const senderId = 'cc'.repeat(16);
const payload = 'hello aibot';
const ts = 1_700_000_000_000;
const pendingHash = 'reticulum-pending-1700000000000';
const save = handlers.get('db:saveReticulumMessage');
save?.(event, {
identity_id: identityId,
sender_id: senderId,
sender_name: 'Me',
payload,
timestamp: ts,
message_hash: pendingHash,
delivery_status: 'sending',
});
save?.(event, {
identity_id: identityId,
sender_id: senderId,
sender_name: 'Me',
payload,
timestamp: ts + 182,
message_hash: 'ab'.repeat(32),
replaces_message_hash: pendingHash,
delivery_status: 'sending',
});
const rows = db!
.prepareOnce(
'SELECT message_hash, delivery_status FROM reticulum_messages WHERE identity_id = ? ORDER BY id',
)
.all(identityId) as { message_hash: string; delivery_status: string }[];
expect(rows).toEqual([{ message_hash: 'ab'.repeat(32), delivery_status: 'sending' }]);
});

it('saveReticulumMessage replaces only the named pending when two identical payloads exist', () => {
const identityId = 'id-rt-twin-payload';
const senderId = 'dd'.repeat(16);
const payload = 'hello';
const ts = 1_700_000_100_000;
const pendingA = 'reticulum-pending-a';
const pendingB = 'reticulum-pending-b';
const save = handlers.get('db:saveReticulumMessage');
save?.(event, {
identity_id: identityId,
sender_id: senderId,
sender_name: 'Me',
payload,
timestamp: ts,
message_hash: pendingA,
delivery_status: 'sending',
});
save?.(event, {
identity_id: identityId,
sender_id: senderId,
sender_name: 'Me',
payload,
timestamp: ts + 50,
message_hash: pendingB,
delivery_status: 'sending',
});
save?.(event, {
identity_id: identityId,
sender_id: senderId,
sender_name: 'Me',
payload,
timestamp: ts + 80,
message_hash: 'ee'.repeat(32),
replaces_message_hash: pendingA,
delivery_status: 'sending',
});
const rows = db!
.prepareOnce('SELECT message_hash FROM reticulum_messages WHERE identity_id = ? ORDER BY id')
.all(identityId) as { message_hash: string }[];
expect(rows.map((r) => r.message_hash)).toEqual([pendingB, 'ee'.repeat(32)]);
});

it('saveReticulumMessage rolls back pending delete when replacement insert fails', () => {
const identityId = 'id-rt-pending-rollback';
const senderId = 'ff'.repeat(16);
const payload = 'rollback me';
const ts = 1_700_000_200_000;
const pendingHash = 'reticulum-pending-rollback';
const save = handlers.get('db:saveReticulumMessage');
save?.(event, {
identity_id: identityId,
sender_id: senderId,
sender_name: 'Me',
payload,
timestamp: ts,
message_hash: pendingHash,
delivery_status: 'sending',
});

const prepareOnce = db!.prepareOnce.bind(db!);
const spy = vi.spyOn(db!, 'prepareOnce').mockImplementation((sql: string) => {
const stmt = prepareOnce(sql);
if (sql.includes('INSERT INTO reticulum_messages')) {
return {
run: () => {
throw new Error('insert boom');
},
get: stmt.get.bind(stmt),
all: stmt.all.bind(stmt),
} as unknown as ReturnType<typeof prepareOnce>;
}
return stmt;
});

expect(() =>
save?.(event, {
identity_id: identityId,
sender_id: senderId,
sender_name: 'Me',
payload,
timestamp: ts + 10,
message_hash: '11'.repeat(32),
replaces_message_hash: pendingHash,
delivery_status: 'sending',
}),
).toThrow('insert boom');
spy.mockRestore();

const rows = db!
.prepareOnce(
'SELECT message_hash, delivery_status FROM reticulum_messages WHERE identity_id = ?',
)
.all(identityId) as { message_hash: string; delivery_status: string }[];
expect(rows).toEqual([{ message_hash: pendingHash, delivery_status: 'sending' }]);
});

it('pruneReticulumIdentityActivityByAge deletes stale millisecond last_seen rows', () => {
const nowMs = Date.now();
db!
Expand Down
Loading