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:
2026-07-01 21:28:53 +05:30
parent e5c94ebf6d
commit 899770ed02
11 changed files with 143 additions and 4 deletions
+1
View File
@@ -41,6 +41,7 @@ mobile/ios/
server/recordings/
server/transcripts/
server/uploads/
server/downloads/
# OS files
.DS_Store
+19
View File
@@ -10,6 +10,25 @@
// Server origin is configurable so the same build works against prod or a dev server.
const { app, BrowserWindow, session, desktopCapturer, shell, Menu, ipcMain, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
// A stable per-install id (persisted in userData), so the server can count installs and
// associate them with the user who signs in. Created once, then reused across launches.
function getInstallId() {
try {
const p = path.join(app.getPath('userData'), 'install-id');
if (fs.existsSync(p)) { const v = fs.readFileSync(p, 'utf8').trim(); if (v) return v; }
const id = crypto.randomUUID();
fs.writeFileSync(p, id);
return id;
} catch (_) { return 'unknown'; }
}
// The renderer (web app) reads this synchronously to report telemetry after login.
ipcMain.on('get-install-info', (e) => {
e.returnValue = { installId: getInstallId(), appVersion: app.getVersion(), os: process.platform + ' ' + os.release() };
});
// Auto-update: only NATIVE shell changes (this .exe) need this — all web/UI changes arrive
// live from the server. Checks the self-hosted feed (publish config in package.json →
// https://remote.bizgaze.com/downloads/latest.yml), downloads in the background, and installs
+7 -1
View File
@@ -3,10 +3,16 @@
// or prefer native push). No Node APIs are exposed to page JS.
const { contextBridge, ipcRenderer } = require('electron');
// Pull the stable install id + version/os from main (synchronous, one-time at preload).
let info = { installId: '', appVersion: '', os: '' };
try { info = ipcRenderer.sendSync('get-install-info') || info; } catch (_) {}
contextBridge.exposeInMainWorld('__NATIVE__', 'desktop');
contextBridge.exposeInMainWorld('bizConnectNative', Object.freeze({
platform: 'desktop',
version: process.env.npm_package_version || '0.1.0',
version: info.appVersion || '0.1.0',
installId: info.installId || '',
os: info.os || '',
// Bring the app window to the foreground (e.g. when a notification is clicked) — the web
// Notification's window.focus() can't raise an Electron window; the main process must.
focusApp: () => ipcRenderer.send('focus-window'),
+5
View File
@@ -6,9 +6,13 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
const REC_DIR = path.join(__dirname, 'recordings');
const TRANS_DIR = path.join(__dirname, 'transcripts');
const UPLOADS_DIR = path.join(__dirname, 'uploads');
// Desktop installers + auto-update feed (latest.yml). Override with DOWNLOADS_DIR to point at a
// mounted volume in production; IT drops the electron-builder dist/ output here.
const DOWNLOADS_DIR = process.env.DOWNLOADS_DIR || path.join(__dirname, 'downloads');
try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(DOWNLOADS_DIR, { recursive: true }); } catch (e) {}
module.exports = {
PORT: process.env.PORT || 8090,
@@ -17,6 +21,7 @@ module.exports = {
REC_DIR,
TRANS_DIR,
UPLOADS_DIR,
DOWNLOADS_DIR,
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h access-token / cookie lifetime
REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients)
};
+19
View File
@@ -321,6 +321,25 @@ CREATE TABLE IF NOT EXISTS device_tokens (
CREATE INDEX IF NOT EXISTS idx_devtok_user ON device_tokens(user_id);
`);
// App installs (desktop/mobile clients): one row per install, associated with the user once
// they sign in. Lets admins see who installed the app, which version, and when it was last used.
db.exec(`
CREATE TABLE IF NOT EXISTS app_installs (
id TEXT PRIMARY KEY,
install_id TEXT NOT NULL UNIQUE,
user_id TEXT,
user_email TEXT,
tenant_id TEXT,
platform TEXT,
app_version TEXT,
os TEXT,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_installs_tenant ON app_installs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_installs_user ON app_installs(user_id);
`);
// Favourite conversations (per user). target = 'dm:<userId>' or 'group:<groupId>'.
db.exec(`
CREATE TABLE IF NOT EXISTS favorites (
+9 -1
View File
@@ -731,7 +731,7 @@
</head>
<body>
<script src="/icons.js?v=4"></script>
<script>window.__BUILD='2026-07-01-batch19';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
<script>window.__BUILD='2026-07-01-batch20';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
<div class="loading" id="loading">Loading…</div>
<header>
@@ -1831,6 +1831,13 @@ function urlB64ToUint8(base64){ const pad='='.repeat((4-base64.length%4)%4); con
// FCM/APNs (see server/push.js). No bundler needed — plugins live on Capacitor.Plugins.
function capPlugin(name){ const C=window.Capacitor; return (C && C.isNativePlatform && C.isNativePlatform() && C.Plugins && C.Plugins[name]) ? C.Plugins[name] : null; }
function nativePlatform(){ const C=window.Capacitor; if(C && C.getPlatform){ const p=C.getPlatform(); if(p==='ios'||p==='android') return p; } if(window.__NATIVE__==='desktop') return 'desktop'; return ''; }
// Record this install against the signed-in user (desktop shell exposes a stable installId).
// Fires post-login; the request carries the session so admins can see who installed the app.
function reportInstall(){
try{ const n=window.bizConnectNative; if(!n||!n.installId) return;
postJSON('/api/v1/telemetry/install',{ installId:n.installId, platform:n.platform||'desktop', appVersion:n.version||'', os:n.os||'' }).catch(()=>{});
}catch(_){}
}
let _nativeTok=null;
async function setupNativePush(){
const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
@@ -2756,6 +2763,7 @@ async function doRegister(){
loadNotifs(); wireBell(); wireProfile();
placeHdrRight(); window.addEventListener('resize', placeHdrRight); // mobile: bell+profile live in the chat-list header (no top bar)
connectChatWs();
reportInstall(); // desktop/mobile shell: record this install against the signed-in user
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
+3
View File
@@ -67,6 +67,7 @@
<!-- Team member path BELOW: log in to the full app. Stub SSO -> /home for now. -->
<div class="divider" style="margin-top:1.6rem">BizGaze team member?</div>
<a class="ssobtn" id="ssoBtn" href="/home"><span class="bmark">B</span> Log in with BizGaze</a>
<a class="dl-win" id="dlWin" href="/download/windows" style="display:inline-flex;align-items:center;gap:.5rem;margin-top:1rem;color:var(--blue);font-size:.88rem;text-decoration:none;font-weight:600"><span data-ic="download" data-sz="16"></span> Download the Windows desktop app</a>
</div>
</div>
<footer>© BizGaze · Remote Support</footer>
@@ -76,6 +77,8 @@ function profileHTML(name){return '<div class="profile"><button class="pbtn" id=
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
makeBrandClickable();
// No "download" link when we're already inside the desktop app.
if(window.__NATIVE__){var _dl=document.getElementById('dlWin');if(_dl)_dl.style.display='none';}
// Already signed in -> skip this landing entirely and go straight to the app (no redundant
// "Open Biz Connect" page). This landing only shows when logged out.
(async function(){try{const r=await fetch('/api/me');if(r.ok){ location.replace('/home'); return; }}catch(_){}})();
+17 -1
View File
@@ -299,4 +299,20 @@ const deviceTokens = {
removeByToken: (token) => db.prepare('DELETE FROM device_tokens WHERE token=?').run(token),
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens };
const appInstalls = {
// Upsert by install_id: each launch refreshes version/os/last_seen and fills in the user once
// they sign in (COALESCE keeps a known user if a later anonymous ping arrives).
record: ({ id, installId, userId, userEmail, tenantId, platform, appVersion, os }) =>
db.prepare(`INSERT INTO app_installs (id,install_id,user_id,user_email,tenant_id,platform,app_version,os,first_seen,last_seen)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(install_id) DO UPDATE SET
user_id=COALESCE(excluded.user_id, app_installs.user_id),
user_email=COALESCE(excluded.user_email, app_installs.user_email),
tenant_id=COALESCE(excluded.tenant_id, app_installs.tenant_id),
platform=excluded.platform, app_version=excluded.app_version, os=excluded.os,
last_seen=excluded.last_seen`)
.run(id, installId, userId || null, userEmail || null, tenantId || null, platform || null, appVersion || null, os || null, now(), now()),
listForTenant: (tenantId) => db.prepare('SELECT * FROM app_installs WHERE tenant_id=? ORDER BY last_seen DESC').all(tenantId),
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens, appInstalls };
+18
View File
@@ -316,6 +316,24 @@ route('POST', '/api/devices/remove', async (req, res) => {
json(res, 200, { ok: true });
});
// --- App install telemetry: records each install and, once the user signs in, who's using it. ---
route('POST', '/api/telemetry/install', async (req, res) => {
const { installId, platform, appVersion, os } = await readBody(req);
if (!installId || typeof installId !== 'string') return json(res, 400, { error: 'installId required' });
const u = currentUser(req); // may be null on a pre-login launch — still counted
try {
R.appInstalls.record({ id: A.id(), installId: installId.slice(0, 64), userId: u && u.id, userEmail: u && u.email, tenantId: u && u.team_id, platform: (platform || '').slice(0, 20), appVersion: (appVersion || '').slice(0, 20), os: (os || '').slice(0, 60) });
} catch (_) {}
json(res, 200, { ok: true });
});
// Admin: who installed the app (this tenant).
route('GET', '/api/admin/installs', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.role !== 'admin') return json(res, 403, { error: 'admin only' });
json(res, 200, R.appInstalls.listForTenant(u.team_id));
});
// ---------- BizGaze SSO: agent arrives already logged in ----------
route('GET', '/sso', async (req, res) => {
if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
+39 -1
View File
@@ -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' });
+6
View File
@@ -105,6 +105,12 @@ function nextMsg(ws, type, timeout = 3000) {
const devRm = await call('/api/v1/devices/remove', { token: 'fcm-tok-123' }, cookie);
check('device token removed', devRm.status === 200);
// 3b''. App install telemetry (track who installed) + admin listing
const tel = await call('/api/v1/telemetry/install', { installId: 'inst-e2e-1', platform: 'desktop', appVersion: '0.1.1', os: 'win32' }, cookie);
check('install telemetry recorded', tel.status === 200 && tel.data.ok === true);
const insts = await get('/api/v1/admin/installs', cookie);
check('admin sees the install tied to the user', insts.status === 200 && Array.isArray(insts.data) && insts.data.some((i) => i.install_id === 'inst-e2e-1' && i.app_version === '0.1.1' && i.user_email));
// 3c. API keys (machine-to-machine integration), scoped + revocable
const mkKey = await call('/api/v1/keys', { name: 'ci', scopes: ['report:read'] }, cookie);
check('admin creates API key (bzc_ prefix)', mkKey.status === 200 && /^bzc_/.test(mkKey.data.key || ''));