feat: Windows download link + self-served update feed + install tracking
Downloads/updates: - config: DOWNLOADS_DIR (override to a mounted volume in prod). - static.js: serves /downloads/* (installer, latest.yml, .blockmap) with range support for resumable + differential auto-updates; /download/windows redirects to the current .exe (stable link). Landing page gets a "Download the Windows desktop app" button (hidden in-app). Install tracking (who installed the app): - db app_installs + repos.appInstalls (upsert by install_id, fills in the user on sign-in). - POST /api/v1/telemetry/install (records install + user once authenticated); GET /api/v1/admin/installs (admin: list installs with user/version/os/last-seen). - desktop main.js: stable per-install id in userData, exposed via preload (bizConnectNative.installId/version/os); home.html reportInstall() posts it after login. - e2e: +2 checks (telemetry recorded, admin sees it). 119/119. build batch20. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+39
-1
@@ -5,7 +5,7 @@ const path = require('path');
|
||||
const R = require('./repos');
|
||||
const { json } = require('./lib');
|
||||
const { currentUser } = require('./session');
|
||||
const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR } = require('./config');
|
||||
const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR, DOWNLOADS_DIR } = require('./config');
|
||||
|
||||
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
|
||||
|
||||
@@ -47,6 +47,44 @@ function serveStatic(req, res) {
|
||||
// GET fallback: authenticated transcript/recording downloads, else static files.
|
||||
function handleGet(req, res) {
|
||||
const pathOnly = req.url.split('?')[0];
|
||||
// Stable "latest Windows installer" link (used by the site's Download button). Reads the
|
||||
// electron-updater manifest and redirects to the current versioned .exe.
|
||||
if (pathOnly === '/download/windows') {
|
||||
try {
|
||||
const yml = fs.readFileSync(path.join(DOWNLOADS_DIR, 'latest.yml'), 'utf8');
|
||||
const m = yml.match(/^path:\s*(.+)$/m);
|
||||
const file = m ? m[1].trim().replace(/^['"]|['"]$/g, '') : null;
|
||||
if (file) { res.writeHead(302, { Location: '/downloads/' + encodeURIComponent(file), 'Cache-Control': 'no-cache' }); return res.end(); }
|
||||
} catch (_) {}
|
||||
return json(res, 404, { error: 'No Windows build is available yet.' });
|
||||
}
|
||||
// Public download feed: installers (.exe), the update manifest (latest.yml) and .blockmap.
|
||||
// Range support so large installers resume and electron-updater can do differential updates.
|
||||
if (pathOnly.startsWith('/downloads/')) {
|
||||
const name = path.basename(decodeURIComponent(pathOnly));
|
||||
if (!name || name.startsWith('.')) return json(res, 404, { error: 'not found' });
|
||||
const fp = path.join(DOWNLOADS_DIR, name);
|
||||
if (!fp.startsWith(DOWNLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||
return fs.stat(fp, (err, st) => {
|
||||
if (err || !st.isFile()) return json(res, 404, { error: 'not found' });
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const ct = ext === '.yml' ? 'text/yaml; charset=utf-8' : ext === '.dmg' ? 'application/x-apple-diskimage' : 'application/octet-stream';
|
||||
const headers = { 'Content-Type': ct, 'Accept-Ranges': 'bytes', 'Cache-Control': ext === '.yml' ? 'no-cache' : 'public, max-age=86400' };
|
||||
if (ext === '.exe' || ext === '.dmg' || ext === '.appimage') headers['Content-Disposition'] = 'attachment; filename="' + name + '"';
|
||||
const range = req.headers.range;
|
||||
if (range && /^bytes=\d*-\d*$/.test(range)) {
|
||||
const [s, e] = range.replace('bytes=', '').split('-');
|
||||
const start = s ? parseInt(s, 10) : 0; const end = e ? parseInt(e, 10) : st.size - 1;
|
||||
if (start >= st.size || end >= st.size || start > end) { res.writeHead(416, { 'Content-Range': 'bytes */' + st.size }); return res.end(); }
|
||||
headers['Content-Range'] = 'bytes ' + start + '-' + end + '/' + st.size; headers['Content-Length'] = end - start + 1;
|
||||
res.writeHead(206, headers);
|
||||
const rs = fs.createReadStream(fp, { start, end }); rs.on('error', () => { try { res.destroy(); } catch (_) {} }); return rs.pipe(res);
|
||||
}
|
||||
headers['Content-Length'] = st.size;
|
||||
res.writeHead(200, headers);
|
||||
const rs = fs.createReadStream(fp); rs.on('error', () => { try { res.destroy(); } catch (_) {} }); rs.pipe(res);
|
||||
});
|
||||
}
|
||||
if (pathOnly.startsWith('/transcripts/')) {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
|
||||
Reference in New Issue
Block a user