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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"check:core-boundaries": "node scripts/check-core-boundaries.mjs",
"check:core-boundaries:test": "node --test scripts/check-core-boundaries.test.mjs",
"check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs && node --test scripts/check-github-config.test.mjs",
"test:release-packaging": "node --test scripts/release-channel.test.mjs scripts/desktop-tauri-build.test.mjs scripts/server-build.test.mjs scripts/version-generation.test.mjs scripts/tauri-release-manifest.test.mjs BitFun-Installer/scripts/build-installer.test.cjs",
"test:release-packaging": "node --test scripts/release-channel.test.mjs scripts/desktop-tauri-build.test.mjs scripts/server-build.test.mjs scripts/version-generation.test.mjs scripts/generate-frontend-revision.test.mjs scripts/tauri-release-manifest.test.mjs BitFun-Installer/scripts/build-installer.test.cjs",
"fmt:rs": "node scripts/format-changed-rust.mjs",
"lint:rs": "cargo clippy --workspace --exclude bitfun-desktop --all-targets",
"lint:rs:desktop": "pnpm run prepare:mobile-web && cargo clippy -p bitfun-desktop --all-targets",
Expand All @@ -84,7 +84,7 @@
"verify:monaco-assets": "node scripts/verify-monaco-assets.cjs",
"verify:webkit-compatibility": "node scripts/verify-webkit-compatibility.cjs",
"verify:webkit-compatibility:test": "node --test scripts/verify-webkit-compatibility.test.mjs",
"build:web": "pnpm run appearance:contract-audit && node scripts/build-web-parallel.mjs && pnpm run verify:monaco-assets && pnpm run verify:webkit-compatibility",
"build:web": "pnpm run appearance:contract-audit && node scripts/build-web-parallel.mjs && node scripts/generate-frontend-revision.mjs && pnpm run verify:monaco-assets && pnpm run verify:webkit-compatibility",
"build:mobile-web": "pnpm --dir src/mobile-web build",
"build:miniapp-market": "pnpm --dir src/miniapp-market-web build",
"type-check:miniapp-market": "pnpm --dir src/miniapp-market-web type-check",
Expand Down
2 changes: 1 addition & 1 deletion scripts/frontend-build-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

/**
* Runs the independent pre-bundle build pipelines in parallel:
* - build:web (type-check + vite build + monaco asset verify)
* - build:web (type-check + Vite build + revision manifest + asset verification)
* - prepare:mobile-web (mobile-web install/build with mtime short-circuit)
* - prepare:dsh-profile (the DeepSeek Harness bridge official desktop:build ships)
*
Expand Down
102 changes: 102 additions & 0 deletions scripts/generate-frontend-revision.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env node

import { createHash } from 'node:crypto';
import { lstat, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

export const FRONTEND_REVISION_MANIFEST = 'frontend-revision.json';
export const FRONTEND_REVISION_ALGORITHM = 'sha256-path-content-v1';

const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const DEFAULT_DIST_DIR = path.join(ROOT_DIR, 'dist');

function uint64LittleEndian(value) {
const bytes = Buffer.allocUnsafe(8);
bytes.writeBigUInt64LE(BigInt(value));
return bytes;
}

async function collectFiles(root, directory = root, files = []) {
const entries = await readdir(directory, { withFileTypes: true });
entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));

for (const entry of entries) {
if (
directory === root &&
(entry.name === FRONTEND_REVISION_MANIFEST ||
(entry.name.startsWith(`${FRONTEND_REVISION_MANIFEST}.`) && entry.name.endsWith('.tmp')))
) {
continue;
}
const absolutePath = path.join(directory, entry.name);
const metadata = await lstat(absolutePath);
if (metadata.isSymbolicLink()) {
throw new Error(`Frontend bundles cannot contain symbolic links: ${absolutePath}`);
}
if (metadata.isDirectory()) {
await collectFiles(root, absolutePath, files);
} else if (metadata.isFile()) {
files.push({
absolutePath,
relativePath: path.relative(root, absolutePath).split(path.sep).join('/'),
});
}
}
return files;
}

export async function generateFrontendRevisionManifest(distDir = DEFAULT_DIST_DIR) {
const indexPath = path.join(distDir, 'index.html');
const indexMetadata = await stat(indexPath).catch(() => null);
if (!indexMetadata?.isFile()) {
throw new Error(`Frontend build output has no index.html: ${distDir}`);
}

const files = await collectFiles(distDir);
files.sort((left, right) =>
left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0,
);
const hasher = createHash('sha256');
let totalBytes = 0;

for (const file of files) {
const relativePath = Buffer.from(file.relativePath, 'utf8');
const contents = await readFile(file.absolutePath);
hasher.update(uint64LittleEndian(relativePath.byteLength));
hasher.update(relativePath);
hasher.update(uint64LittleEndian(contents.byteLength));
hasher.update(contents);
totalBytes += contents.byteLength;
}

const digest = hasher.digest('hex');
const manifest = {
schemaVersion: 1,
revision: `bundled-${digest.slice(0, 16)}`,
algorithm: FRONTEND_REVISION_ALGORITHM,
digest,
fileCount: files.length,
totalBytes,
};
const destination = path.join(distDir, FRONTEND_REVISION_MANIFEST);
const temporary = `${destination}.${process.pid}.tmp`;
await writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
await rm(destination, { force: true });
await rename(temporary, destination);
return manifest;
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const distDir = process.argv[2] ? path.resolve(process.argv[2]) : DEFAULT_DIST_DIR;
generateFrontendRevisionManifest(distDir)
.then((manifest) => {
process.stdout.write(
`[frontend-revision] generated ${manifest.revision} (${manifest.fileCount} files, ${manifest.totalBytes} bytes)\n`,
);
})
.catch((error) => {
process.stderr.write(`[frontend-revision] ${error.message}\n`);
process.exitCode = 1;
});
}
53 changes: 53 additions & 0 deletions scripts/generate-frontend-revision.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
FRONTEND_REVISION_ALGORITHM,
FRONTEND_REVISION_MANIFEST,
generateFrontendRevisionManifest,
} from './generate-frontend-revision.mjs';

async function frontendFixture() {
const root = await mkdtemp(path.join(tmpdir(), 'bitfun-frontend-revision-'));
await mkdir(path.join(root, 'assets'));
await writeFile(path.join(root, 'index.html'), '<main>BitFun</main>');
await writeFile(path.join(root, 'assets', 'app.js'), 'export const value = 1;');
return root;
}

test('frontend revision manifest is stable and excludes itself', async () => {
const root = await frontendFixture();
try {
const first = await generateFrontendRevisionManifest(root);
const second = await generateFrontendRevisionManifest(root);
const written = JSON.parse(
await readFile(path.join(root, FRONTEND_REVISION_MANIFEST), 'utf8'),
);

assert.deepEqual(second, first);
assert.deepEqual(written, first);
assert.equal(first.schemaVersion, 1);
assert.equal(first.algorithm, FRONTEND_REVISION_ALGORITHM);
assert.equal(first.fileCount, 2);
assert.equal(first.digest.length, 64);
assert.equal(first.revision, `bundled-${first.digest.slice(0, 16)}`);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test('frontend revision covers assets beyond index.html', async () => {
const root = await frontendFixture();
try {
const first = await generateFrontendRevisionManifest(root);
await writeFile(path.join(root, 'assets', 'app.js'), 'export const value = 2;');
const second = await generateFrontendRevisionManifest(root);

assert.notEqual(second.revision, first.revision);
assert.notEqual(second.digest, first.digest);
} finally {
await rm(root, { recursive: true, force: true });
}
});
6 changes: 6 additions & 0 deletions src/apps/desktop/src/appearance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,8 @@ pub fn create_main_window(
_ => "other",
};

#[cfg(not(debug_assertions))]
let materialization_workbench = Arc::clone(&frontend_workbench);
#[allow(unused_mut)]
let mut builder = tauri::WebviewWindowBuilder::new(app_handle, "main", main_url)
.title("BitFun")
Expand Down Expand Up @@ -591,6 +593,10 @@ pub fn create_main_window(
payload.url(),
total_started_at.elapsed().as_millis()
);
#[cfg(not(debug_assertions))]
if matches!(payload.event(), PageLoadEvent::Finished) {
materialization_workbench.materialize_bundled_revision_in_background();
}
}
});

Expand Down
Loading
Loading