f9e340dbec
App productName becomes Netralax (driving exe name and window title);
existing installs keep their %APPDATA%\ChatApp profile via an explicit
app.setPath('userData', appData/ChatApp) so no Login/Sounds/Secret store
data is lost.
The Windows taskbar overlay now renders a red bubble with the actual
unread count (Discord parity) instead of just a static red dot. Renderer
paints a 64×64 PNG via canvas — full-bleed red circle, white bold count
with a "99+" cap, no outer ring — and passes the data URL through the
existing setTrayUnread IPC. Main decodes via nativeImage and applies it
as the BrowserWindow overlay icon. Falls back to the static dot if the
renderer canvas pipeline is unavailable.
Also: app.setName('Netralax') + setAppUserModelId('cloud.netralax.desktop')
for Windows taskbar grouping and notification source attribution, and
release.mjs now reads productName dynamically from package.json so the
artifact lookup stays correct after the rename.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
203 lines
7.3 KiB
JavaScript
203 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
|
// Electron release script — bumps the app version, builds the Windows
|
|
// installer via electron-builder, and uploads the produced artifacts
|
|
// (latest.yml + .exe + .blockmap) to the update host over scp. Reads
|
|
// credentials from .env.release (not committed). electron-updater on
|
|
// installed clients then sees the new latest.yml on the next launch
|
|
// poll and triggers the update toast.
|
|
//
|
|
// We deliberately push to the same host the Tauri build pushed to
|
|
// (`update.netralax.cloud:/var/www/updates/windows/`) but use a
|
|
// different manifest filename (`latest.yml` for electron-updater vs
|
|
// `latest.json` for Tauri), so the legacy Tauri manifest can stay in
|
|
// place untouched and any users still on Tauri don't get pushed a
|
|
// release they can't verify.
|
|
//
|
|
// Usage:
|
|
// pnpm release <x.y.z> "Multi-line release notes…"
|
|
|
|
import { execSync } from 'node:child_process';
|
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
|
|
|
|
const [, , versionArg, ...notesParts] = process.argv;
|
|
if (!versionArg || !/^\d+\.\d+\.\d+$/.test(versionArg)) {
|
|
console.error('Usage: pnpm release <x.y.z> "release notes"');
|
|
process.exit(1);
|
|
}
|
|
// Bash + cmd.exe pass `\n` from CLI args as the two literal chars `\` `n`,
|
|
// not as a newline. Normalize so multi-line release notes render correctly
|
|
// in the in-app updater toast (which uses whitespace-pre-line) and in the
|
|
// Changelog page.
|
|
const notes = (notesParts.join(' ').trim() || `Release ${versionArg}`)
|
|
.replace(/\\r\\n|\\n/g, '\n');
|
|
|
|
const envPath = join(ROOT, '.env.release');
|
|
if (!existsSync(envPath)) {
|
|
console.error('.env.release missing. Copy .env.release.example and fill it in.');
|
|
process.exit(1);
|
|
}
|
|
const env = Object.fromEntries(
|
|
readFileSync(envPath, 'utf8')
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter((l) => l && !l.startsWith('#'))
|
|
.map((l) => {
|
|
const i = l.indexOf('=');
|
|
return [l.slice(0, i).trim(), l.slice(i + 1).trim()];
|
|
}),
|
|
);
|
|
|
|
const required = ['UPDATE_HOST', 'UPDATE_SSH_USER', 'UPDATE_REMOTE_PATH'];
|
|
for (const key of required) {
|
|
if (!env[key]) {
|
|
console.error(`Missing ${key} in .env.release`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const gitStatus = execSync('git status --porcelain', { cwd: ROOT, encoding: 'utf8' });
|
|
if (gitStatus.trim()) {
|
|
console.error('Working tree not clean. Commit or stash first.');
|
|
console.error(gitStatus);
|
|
process.exit(1);
|
|
}
|
|
|
|
// --- Bump version --------------------------------------------------------
|
|
|
|
const pkgJsonPath = join(ROOT, 'apps/desktop/package.json');
|
|
|
|
function bumpJson(path, version) {
|
|
const obj = JSON.parse(readFileSync(path, 'utf8'));
|
|
obj.version = version;
|
|
writeFileSync(path, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
}
|
|
|
|
bumpJson(pkgJsonPath, versionArg);
|
|
console.log(`Version -> ${versionArg}. Building NSIS bundle via electron-builder…`);
|
|
|
|
// --- Build ----------------------------------------------------------------
|
|
|
|
execSync('pnpm --filter @chat-app/desktop run build:win', {
|
|
cwd: ROOT,
|
|
stdio: 'inherit',
|
|
});
|
|
|
|
// --- Locate artifacts -----------------------------------------------------
|
|
|
|
const releaseDir = join(ROOT, 'apps/desktop/release');
|
|
// productName lives in the electron-builder block of the desktop package.
|
|
// Read it back from disk (post-bump) so installer filenames stay in sync
|
|
// after a rebrand (e.g. ChatApp → Netralax) without manual script edits.
|
|
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
const productName = pkgJson?.build?.productName ?? 'ChatApp';
|
|
const exeName = `${productName} Setup ${versionArg}.exe`;
|
|
const blockmapName = `${exeName}.blockmap`;
|
|
const latestYml = 'latest.yml';
|
|
|
|
const exePath = join(releaseDir, exeName);
|
|
const blockmapPath = join(releaseDir, blockmapName);
|
|
const latestYmlPath = join(releaseDir, latestYml);
|
|
|
|
for (const p of [exePath, blockmapPath, latestYmlPath]) {
|
|
if (!existsSync(p)) {
|
|
console.error(`Missing build artifact: ${p}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// --- Inject releaseNotes into latest.yml ----------------------------------
|
|
//
|
|
// electron-builder doesn't write the CLI-supplied notes into the
|
|
// manifest by default — clients then see an empty body in the
|
|
// UpdateToast. Patch the YAML in place: append a block scalar
|
|
// (`releaseNotes: |-`) so multi-line content survives intact.
|
|
// Idempotent — skip if a `releaseNotes:` entry is already present
|
|
// (covers reruns / hand-edited manifests).
|
|
{
|
|
let yml = readFileSync(latestYmlPath, 'utf8');
|
|
if (!/^releaseNotes:/m.test(yml)) {
|
|
const indented = notes
|
|
.split('\n')
|
|
.map((l) => ' ' + l)
|
|
.join('\n');
|
|
yml = yml.replace(/\s*$/, '') + `\nreleaseNotes: |-\n${indented}\n`;
|
|
writeFileSync(latestYmlPath, yml, 'utf8');
|
|
console.log('Injected releaseNotes into latest.yml');
|
|
} else {
|
|
console.log('latest.yml already has releaseNotes — skipping injection');
|
|
}
|
|
}
|
|
|
|
// --- scp helpers ----------------------------------------------------------
|
|
|
|
const sshTarget = `${env.UPDATE_SSH_USER}@${env.UPDATE_HOST}`;
|
|
const keyFlag = env.UPDATE_SSH_KEY ? ` -i "${env.UPDATE_SSH_KEY}"` : '';
|
|
|
|
function scp(localPath) {
|
|
execSync(`scp${keyFlag} "${localPath}" ${sshTarget}:${env.UPDATE_REMOTE_PATH}/`, {
|
|
cwd: ROOT,
|
|
stdio: 'inherit',
|
|
});
|
|
}
|
|
|
|
// --- Upload — manifest LAST so clients never see a stale ref --------------
|
|
|
|
console.log('Uploading artifacts (manifest last)…');
|
|
scp(exePath);
|
|
scp(blockmapPath);
|
|
scp(latestYmlPath);
|
|
|
|
// --- Changelog feed -------------------------------------------------------
|
|
//
|
|
// The in-app changelog page fetches `${UPDATE_HOST}/windows/changelog.json`
|
|
// and renders the list. We fetch the existing list, prepend the new entry
|
|
// (keyed/deduped by version), cap at 200 to keep the file size bounded,
|
|
// then upload the merged list back.
|
|
|
|
const changelogUrl = `https://${env.UPDATE_HOST}/windows/changelog.json`;
|
|
let history = [];
|
|
try {
|
|
const res = await fetch(changelogUrl, { cache: 'no-store' });
|
|
if (res.ok) {
|
|
const parsed = await res.json();
|
|
if (Array.isArray(parsed)) history = parsed;
|
|
} else if (res.status !== 404) {
|
|
console.warn(`changelog.json fetch returned ${res.status} — starting from empty list`);
|
|
}
|
|
} catch (err) {
|
|
console.warn(
|
|
`changelog.json fetch failed (${err.message ?? err}); starting from empty list`,
|
|
);
|
|
}
|
|
const newEntry = {
|
|
version: versionArg,
|
|
pub_date: new Date().toISOString(),
|
|
notes,
|
|
};
|
|
const updatedHistory = [
|
|
newEntry,
|
|
...history.filter((e) => e?.version !== versionArg),
|
|
].slice(0, 200);
|
|
const changelogPath = join(releaseDir, 'changelog.json');
|
|
writeFileSync(changelogPath, JSON.stringify(updatedHistory, null, 2), 'utf8');
|
|
scp(changelogPath);
|
|
|
|
// --- Commit + tag ---------------------------------------------------------
|
|
|
|
execSync(`git add apps/desktop/package.json`, { cwd: ROOT, stdio: 'inherit' });
|
|
execSync(`git commit -m "chore(desktop): release v${versionArg}"`, {
|
|
cwd: ROOT,
|
|
stdio: 'inherit',
|
|
});
|
|
execSync(`git tag v${versionArg}`, { cwd: ROOT, stdio: 'inherit' });
|
|
|
|
console.log(`\nReleased v${versionArg}`);
|
|
console.log(` Manifest: https://${env.UPDATE_HOST}/windows/${latestYml}`);
|
|
console.log(` Installer: https://${env.UPDATE_HOST}/windows/${encodeURIComponent(exeName)}`);
|
|
console.log(` Changelog: https://${env.UPDATE_HOST}/windows/changelog.json`);
|
|
console.log(` Run 'git push && git push --tags' to sync the tag to remote.`);
|