release: prepare v0.9.7

This commit is contained in:
BigBodyCobain
2026-05-01 22:55:04 -06:00
parent ea457f27da
commit 28b3bd5ebf
670 changed files with 187060 additions and 14006 deletions
@@ -0,0 +1,122 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const scriptDir = __dirname;
const tauriDir = path.resolve(scriptDir, '..');
const repoRoot = path.resolve(tauriDir, '..', '..');
const backendDir = path.join(repoRoot, 'backend');
const outputDir = path.join(tauriDir, 'src-tauri', 'backend-runtime');
const venvMarkerPath = path.join(backendDir, '.venv-dir');
const releaseAttestationPath = path.join(backendDir, 'data', 'release_attestation.json');
const stagedReleaseAttestationPath = path.join(
outputDir,
'data',
'release_attestation.json',
);
const excludedNames = new Set([
'.env',
'.pytest_cache',
'__pycache__',
'backend.egg-info',
'build',
'data',
'tests',
]);
const excludedFiles = new Set([
'pytest.ini',
]);
function backendPythonPath() {
let venvDir = 'venv';
try {
const persisted = fs.readFileSync(venvMarkerPath, 'utf8').trim();
if (persisted) {
venvDir = persisted;
}
} catch {}
if (process.platform === 'win32') {
return path.join(backendDir, venvDir, 'Scripts', 'python.exe');
}
return path.join(backendDir, venvDir, 'bin', 'python3');
}
function shouldCopy(srcPath) {
const relativePath = path.relative(backendDir, srcPath);
if (!relativePath) return true;
const parts = relativePath.split(path.sep);
return parts.every((part, index) => {
const isLeaf = index === parts.length - 1;
if (excludedNames.has(part)) return false;
if (isLeaf && excludedFiles.has(part)) return false;
if (/^test_.*\.py$/i.test(part)) return false;
return true;
});
}
function ensureRuntimePrereqs() {
if (!fs.existsSync(path.join(backendDir, 'main.py'))) {
throw new Error(`Missing backend/main.py at ${backendDir}`);
}
if (!fs.existsSync(backendPythonPath())) {
throw new Error(
`Missing bundled backend Python runtime at ${backendPythonPath()}. ` +
'Create the backend venv before packaging the desktop app.',
);
}
if (!fs.existsSync(path.join(backendDir, 'node_modules', 'ws'))) {
throw new Error(
`Missing backend/node_modules/ws at ${path.join(backendDir, 'node_modules', 'ws')}. ` +
'Install backend Node dependencies before packaging the desktop app.',
);
}
}
function stageBackendRuntime() {
fs.rmSync(outputDir, { recursive: true, force: true });
fs.cpSync(backendDir, outputDir, {
recursive: true,
filter: shouldCopy,
});
stageReleaseAttestation();
}
function stageReleaseAttestation() {
if (!fs.existsSync(releaseAttestationPath)) {
console.warn(`backend-runtime staged without release attestation: ${releaseAttestationPath}`);
return;
}
fs.mkdirSync(path.dirname(stagedReleaseAttestationPath), { recursive: true });
fs.copyFileSync(releaseAttestationPath, stagedReleaseAttestationPath);
}
function writeBundleVersion() {
const versionPath = path.join(outputDir, '.bundle-version');
const pkg = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'desktop-shell', 'package.json'), 'utf8'),
);
fs.writeFileSync(versionPath, `${pkg.version || '0.0.0'}\n`, 'utf8');
}
function fileCount(root) {
let count = 0;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const fullPath = path.join(root, entry.name);
if (entry.isDirectory()) {
count += fileCount(fullPath);
} else {
count += 1;
}
}
return count;
}
ensureRuntimePrereqs();
stageBackendRuntime();
writeBundleVersion();
console.log(`backend-runtime staged: ${fileCount(outputDir)} files`);
@@ -0,0 +1,101 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const scriptDir = __dirname;
const tauriDir = path.resolve(scriptDir, '..');
const repoRoot = path.resolve(tauriDir, '..', '..');
const frontendDir = path.join(repoRoot, 'frontend');
const buildRoot = path.join(repoRoot, '.desktop-export-build');
const buildFrontendDir = path.join(buildRoot, 'frontend');
const buildOutDir = path.join(buildFrontendDir, 'out');
const liveOutDir = path.join(frontendDir, 'out');
const excludedPaths = [
'node_modules',
'.next',
'out',
'src/app/api',
'src/middleware.ts',
];
function normalizeRelativePath(target) {
return target.split(path.sep).join('/');
}
function shouldCopy(srcPath) {
const relativePath = path.relative(frontendDir, srcPath);
if (!relativePath) {
return true;
}
const normalized = normalizeRelativePath(relativePath);
return !excludedPaths.some(
(excluded) => normalized === excluded || normalized.startsWith(`${excluded}/`),
);
}
function prepareBuildTree() {
fs.rmSync(buildRoot, { recursive: true, force: true });
fs.cpSync(frontendDir, buildFrontendDir, {
recursive: true,
filter: shouldCopy,
});
const liveNodeModules = path.join(frontendDir, 'node_modules');
const stagedNodeModules = path.join(buildFrontendDir, 'node_modules');
if (!fs.existsSync(liveNodeModules)) {
throw new Error(`Missing frontend/node_modules at ${liveNodeModules}`);
}
fs.symlinkSync(liveNodeModules, stagedNodeModules, 'junction');
}
function runExportBuild() {
const env = {
...process.env,
NEXT_OUTPUT: 'export',
};
const result =
process.platform === 'win32'
? spawnSync(
process.env.ComSpec || 'cmd.exe',
['/d', '/s', '/c', 'npm.cmd run build -- --webpack'],
{
cwd: buildFrontendDir,
env,
stdio: 'inherit',
},
)
: spawnSync('npm', ['run', 'build', '--', '--webpack'], {
cwd: buildFrontendDir,
env,
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (typeof result.status === 'number' && result.status !== 0) {
throw new Error(`Frontend export build failed with exit code ${result.status}.`);
}
}
function syncBuildOutput() {
if (!fs.existsSync(buildOutDir)) {
throw new Error(`Desktop export did not produce ${buildOutDir}`);
}
fs.rmSync(liveOutDir, { recursive: true, force: true });
fs.cpSync(buildOutDir, liveOutDir, {
recursive: true,
});
}
try {
prepareBuildTree();
runExportBuild();
syncBuildOutput();
} finally {
fs.rmSync(buildRoot, { recursive: true, force: true });
}
@@ -0,0 +1,228 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const zlib = require('node:zlib');
const root = path.resolve(__dirname, '..');
const iconsDir = path.join(root, 'src-tauri', 'icons');
const pngOutputs = {
'32x32.png': 32,
'128x128.png': 128,
'128x128@2x.png': 256,
'icon.png': 512,
'Square30x30Logo.png': 30,
'Square44x44Logo.png': 44,
'Square71x71Logo.png': 71,
'Square89x89Logo.png': 89,
'Square107x107Logo.png': 107,
'Square142x142Logo.png': 142,
'Square150x150Logo.png': 150,
'Square284x284Logo.png': 284,
'Square310x310Logo.png': 310,
'StoreLogo.png': 50,
};
const internalPngSizes = [16, 32, 64, 128, 256, 512, 1024];
function clamp(value, lower = 0, upper = 1) {
return Math.max(lower, Math.min(upper, value));
}
function smoothstep(edge0, edge1, value) {
if (edge0 === edge1) return 0;
const t = clamp((value - edge0) / (edge1 - edge0));
return t * t * (3 - 2 * t);
}
function blend(dst, srcRgb, srcAlpha) {
if (srcAlpha <= 0) return dst;
const alpha = clamp(srcAlpha);
const inv = 1 - alpha;
return [
Math.round(dst[0] * inv + srcRgb[0] * alpha),
Math.round(dst[1] * inv + srcRgb[1] * alpha),
Math.round(dst[2] * inv + srcRgb[2] * alpha),
255,
];
}
function roundedRectAlpha(nx, ny, half, radius, feather) {
const qx = Math.abs(nx) - (half - radius);
const qy = Math.abs(ny) - (half - radius);
const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
const inside = Math.min(Math.max(qx, qy), 0);
const signedDistance = outside + inside - radius;
return smoothstep(feather, -feather, signedDistance);
}
function drawIcon(size) {
const pixels = Buffer.alloc(size * size * 4);
const feather = 2.4 / Math.max(size, 1);
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const nx = ((x + 0.5) / size) * 2 - 1;
const ny = ((y + 0.5) / size) * 2 - 1;
const bgAlpha = roundedRectAlpha(nx, ny, 0.93, 0.28, feather);
if (bgAlpha <= 0) continue;
const gradientMix = clamp((nx - ny + 2) / 4);
const bg = [
Math.round(7 + 8 * gradientMix),
Math.round(20 + 26 * (1 - gradientMix)),
Math.round(28 + 42 * gradientMix),
];
let rgba = [bg[0], bg[1], bg[2], Math.round(255 * bgAlpha)];
const r = Math.hypot(nx, ny);
const ringDistance = Math.abs(r - 0.53) - 0.085;
const ringAlpha = smoothstep(feather * 2.2, -feather * 2.2, ringDistance) * bgAlpha;
rgba = blend(rgba, [27, 196, 157], ringAlpha);
const glowAlpha = smoothstep(0.74, 0.16, r) * 0.18 * bgAlpha;
rgba = blend(rgba, [32, 228, 190], glowAlpha);
const diamondDistance = Math.abs(nx) + Math.abs(ny) - 0.34;
const diamondAlpha = smoothstep(feather * 2.6, -feather * 2.6, diamondDistance) * bgAlpha;
rgba = blend(rgba, [13, 41, 45], diamondAlpha);
const barVDistance = Math.max(Math.abs(nx) - 0.055, Math.abs(ny) - 0.44);
const barHDistance = Math.max(Math.abs(ny) - 0.055, Math.abs(nx) - 0.44);
const barAlpha = Math.max(
smoothstep(feather * 2.6, -feather * 2.6, barVDistance),
smoothstep(feather * 2.6, -feather * 2.6, barHDistance),
) * 0.92 * bgAlpha;
rgba = blend(rgba, [183, 251, 239], barAlpha);
const coreDistance = r - 0.108;
const coreAlpha = smoothstep(feather * 2.4, -feather * 2.4, coreDistance) * bgAlpha;
rgba = blend(rgba, [244, 255, 253], coreAlpha);
const index = (y * size + x) * 4;
pixels[index] = rgba[0];
pixels[index + 1] = rgba[1];
pixels[index + 2] = rgba[2];
pixels[index + 3] = rgba[3];
}
}
return pixels;
}
function crc32(buffer) {
let crc = ~0;
for (let i = 0; i < buffer.length; i += 1) {
crc ^= buffer[i];
for (let j = 0; j < 8; j += 1) {
crc = (crc >>> 1) ^ (0xEDB88320 & -(crc & 1));
}
}
return (~crc) >>> 0;
}
function chunk(type, data) {
const out = Buffer.alloc(8 + data.length + 4);
out.writeUInt32BE(data.length, 0);
out.write(type, 4, 4, 'ascii');
data.copy(out, 8);
out.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, 'ascii'), data])), out.length - 4);
return out;
}
function encodePng(size, rgba) {
const stride = size * 4;
const rows = [];
for (let y = 0; y < size; y += 1) {
rows.push(Buffer.from([0]));
rows.push(rgba.subarray(y * stride, (y + 1) * stride));
}
const raw = Buffer.concat(rows);
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
chunk('IHDR', Buffer.from([
(size >>> 24) & 0xff,
(size >>> 16) & 0xff,
(size >>> 8) & 0xff,
size & 0xff,
(size >>> 24) & 0xff,
(size >>> 16) & 0xff,
(size >>> 8) & 0xff,
size & 0xff,
8,
6,
0,
0,
0,
])),
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
function writeIco(targetPath, pngData) {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(1, 4);
const entry = Buffer.alloc(16);
entry.writeUInt8(0, 0);
entry.writeUInt8(0, 1);
entry.writeUInt8(0, 2);
entry.writeUInt8(0, 3);
entry.writeUInt16LE(1, 4);
entry.writeUInt16LE(32, 6);
entry.writeUInt32LE(pngData.length, 8);
entry.writeUInt32LE(22, 12);
fs.writeFileSync(targetPath, Buffer.concat([header, entry, pngData]));
}
function writeIcns(targetPath, pngBySize) {
const typeMap = new Map([
[16, 'icp4'],
[32, 'icp5'],
[64, 'icp6'],
[128, 'ic07'],
[256, 'ic08'],
[512, 'ic09'],
[1024, 'ic10'],
]);
const blocks = [];
for (const [size, type] of typeMap) {
const png = pngBySize.get(size);
if (!png) continue;
const header = Buffer.alloc(8);
header.write(type, 0, 4, 'ascii');
header.writeUInt32BE(png.length + 8, 4);
blocks.push(header, png);
}
const payload = Buffer.concat(blocks);
const icnsHeader = Buffer.alloc(8);
icnsHeader.write('icns', 0, 4, 'ascii');
icnsHeader.writeUInt32BE(payload.length + 8, 4);
fs.writeFileSync(targetPath, Buffer.concat([icnsHeader, payload]));
}
fs.mkdirSync(iconsDir, { recursive: true });
const pngBySize = new Map();
for (const size of internalPngSizes) {
pngBySize.set(size, encodePng(size, drawIcon(size)));
}
for (const [filename, size] of Object.entries(pngOutputs)) {
fs.writeFileSync(path.join(iconsDir, filename), pngBySize.get(size) ?? encodePng(size, drawIcon(size)));
}
writeIco(path.join(iconsDir, 'icon.ico'), pngBySize.get(256));
writeIcns(path.join(iconsDir, 'icon.icns'), pngBySize);
const created = fs.readdirSync(iconsDir).sort();
console.log(`Generated ${created.length} desktop icons in ${iconsDir}`);
for (const name of created) {
console.log(` - ${name}`);
}
@@ -0,0 +1,152 @@
#!/usr/bin/env node
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
if (process.argv.length < 3) {
throw new Error('Usage: write-release-manifest.cjs <bundle_dir>');
}
const bundleDir = path.resolve(process.argv[2]);
const tauriConfigPath = path.resolve(__dirname, '..', 'src-tauri', 'tauri.conf.json');
const updateBaseUrl = (
process.env.SHADOWBROKER_UPDATE_BASE_URL ||
'https://github.com/BigBodyCobain/Shadowbroker/releases/latest/download'
).replace(/\/+$/, '');
const releaseSuffixes = [
'.AppImage',
'.app.tar.gz',
'.deb',
'.dmg',
'.exe',
'.msi',
'.pkg',
'.rpm',
'.sig',
'.tar.gz',
'.zip',
'latest.json',
];
function collectFiles(dir) {
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectFiles(fullPath));
} else if (entry.isFile()) {
files.push(fullPath);
}
}
return files;
}
function sha256File(filePath) {
const hash = crypto.createHash('sha256');
hash.update(fs.readFileSync(filePath));
return hash.digest('hex');
}
function readReleaseVersion() {
try {
const config = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8'));
return String(config.version || '').trim() || '0.0.0';
} catch {
return '0.0.0';
}
}
function updaterPlatformForArtifact(relativePath) {
if (/\.msi$/i.test(relativePath) || /\.exe$/i.test(relativePath)) {
return 'windows-x86_64';
}
if (/\.app\.tar\.gz$/i.test(relativePath) || /\.dmg$/i.test(relativePath)) {
return 'darwin-x86_64';
}
if (/\.AppImage$/i.test(relativePath) || /\.deb$/i.test(relativePath) || /\.rpm$/i.test(relativePath)) {
return 'linux-x86_64';
}
return null;
}
function updaterArtifactPriority(relativePath) {
if (/\.msi$/i.test(relativePath)) return 0;
if (/setup\.exe$/i.test(relativePath) || /\.exe$/i.test(relativePath)) return 1;
if (/\.app\.tar\.gz$/i.test(relativePath)) return 0;
if (/\.AppImage$/i.test(relativePath)) return 0;
return 10;
}
function writeUpdaterManifest(files) {
const signedArtifacts = files
.filter((filePath) => filePath.endsWith('.sig'))
.map((signaturePath) => {
const artifactPath = signaturePath.slice(0, -4);
if (!fs.existsSync(artifactPath)) return null;
const relativePath = path.relative(bundleDir, artifactPath).replaceAll(path.sep, '/');
const platform = updaterPlatformForArtifact(relativePath);
if (!platform) return null;
return {
platform,
relativePath,
signature: fs.readFileSync(signaturePath, 'utf8').trim(),
};
})
.filter(Boolean)
.sort((a, b) => updaterArtifactPriority(a.relativePath) - updaterArtifactPriority(b.relativePath));
const platforms = {};
for (const artifact of signedArtifacts) {
if (platforms[artifact.platform]) continue;
platforms[artifact.platform] = {
signature: artifact.signature,
url: `${updateBaseUrl}/${encodeURIComponent(path.basename(artifact.relativePath))}`,
};
}
if (Object.keys(platforms).length === 0) return false;
const latest = {
version: readReleaseVersion(),
notes: `ShadowBroker ${readReleaseVersion()}`,
pub_date: new Date().toISOString(),
platforms,
};
fs.writeFileSync(path.join(bundleDir, 'latest.json'), `${JSON.stringify(latest, null, 2)}\n`);
return true;
}
fs.mkdirSync(bundleDir, { recursive: true });
let files = collectFiles(bundleDir);
const wroteUpdaterManifest = writeUpdaterManifest(files);
if (wroteUpdaterManifest) {
files = collectFiles(bundleDir);
}
const artifacts = files.filter((file) => releaseSuffixes.some((suffix) => file.endsWith(suffix)));
const releaseFiles = (artifacts.length > 0 ? artifacts : files).sort();
const manifest = releaseFiles.map((filePath) => ({
path: path.relative(bundleDir, filePath).replaceAll(path.sep, '/'),
size_bytes: fs.statSync(filePath).size,
sha256: sha256File(filePath),
}));
fs.writeFileSync(
path.join(bundleDir, 'SHA256SUMS.txt'),
manifest.map((item) => `${item.sha256} ${item.path}`).join('\n') + (manifest.length ? '\n' : ''),
);
fs.writeFileSync(
path.join(bundleDir, 'release-manifest.json'),
`${JSON.stringify({ artifacts: manifest }, null, 2)}\n`,
);
console.log(`Wrote release manifest for ${manifest.length} artifacts in ${bundleDir}`);
if (wroteUpdaterManifest) {
console.log(`Wrote Tauri updater manifest: ${path.join(bundleDir, 'latest.json')}`);
}
for (const item of manifest) {
console.log(` - ${item.path} (${item.size_bytes} bytes)`);
}