diff --git a/build-helper/lib/dashboard/data.js b/build-helper/lib/dashboard/data.js index a0d04c3..6ac70db 100644 --- a/build-helper/lib/dashboard/data.js +++ b/build-helper/lib/dashboard/data.js @@ -3,8 +3,8 @@ // artifact disk usage. The client computes KPIs/series/filters locally from `builds`. const fs = require('fs'); const path = require('path'); -const { ARTIFACTS_DIR, BUILDS_JSON, readJson, readConfig, projectsRoot, running } = require('../state'); -const { scanProjects, appHealth } = require('../project'); +const { ARTIFACTS_DIR, BUILDS_JSON, readJson, readConfig, projectsRoot, projectSources, running } = require('../state'); +const { scanAll, appHealth } = require('../project'); // Total + per-project artifact bytes under artifacts/. function storageData() { @@ -21,15 +21,16 @@ function storageData() { // The per-app scan is the expensive part; cache it briefly so nav-backs and feed refreshes reuse it. let _dashCache = { key: '', t: 0, projects: null }; -function dashboardData(root) { - const rt = root || projectsRoot(); +function dashboardData() { + const sources = projectSources(); + const key = JSON.stringify(sources); const all = readJson(BUILDS_JSON, []); const apps = readConfig().apps || {}; const now = Date.now(); let projects; - if (_dashCache.projects && _dashCache.key === rt && now - _dashCache.t < 4000) { projects = _dashCache.projects; } + if (_dashCache.projects && _dashCache.key === key && now - _dashCache.t < 4000) { projects = _dashCache.projects; } else { - projects = scanProjects(rt).map((p) => { + projects = scanAll(sources).map((p) => { const pb = all.filter((b) => b.path === p.path); const last = pb[0] || null; const h = appHealth(p.path, { needsConfig: p.needsConfig, firebase: p.firebase }); @@ -43,9 +44,9 @@ function dashboardData(root) { || ((a.order == null ? 1e9 : a.order) - (b.order == null ? 1e9 : b.order)) || ((b.lastBuild ? new Date(b.lastBuild.time) : 0) - (a.lastBuild ? new Date(a.lastBuild.time) : 0)) || a.name.localeCompare(b.name)); - _dashCache = { key: rt, t: now, projects }; + _dashCache = { key, t: now, projects }; } - return { builds: all.slice(0, 500), projects, storage: storageData(), running: { busy: running.busy, info: running.info }, root: projectsRoot() }; + return { builds: all.slice(0, 500), projects, storage: storageData(), running: { busy: running.busy, info: running.info }, root: projectsRoot(), sources }; } function cleanupArtifacts(body) { diff --git a/build-helper/lib/dashboard/share.js b/build-helper/lib/dashboard/share.js index 9373b05..8cf9867 100644 --- a/build-helper/lib/dashboard/share.js +++ b/build-helper/lib/dashboard/share.js @@ -3,9 +3,9 @@ // upload a generated card for a build. Single-user + local rclone (no team log to attach to). const fs = require('fs'); const path = require('path'); -const { ROOT, RCLONE, ARTIFACTS_DIR, readConfig, projectsRoot, sendJson } = require('../state'); +const { ROOT, RCLONE, ARTIFACTS_DIR, readConfig, projectSources, sendJson } = require('../state'); const { run, captureCmd, rc } = require('../shell'); -const { scanProjects, detectApp } = require('../project'); +const { scanAll, detectApp } = require('../project'); async function handleShareImage(req, res, body) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); @@ -36,7 +36,7 @@ async function handleMkdirs(req, res) { const cfg = readConfig(); const base = `${cfg.onedrive.remote}:${cfg.onedrive.base}`; const mk = async (p) => { try { await run(RCLONE, rc(['mkdir', p]), ROOT, () => {}); log('✓ ' + p); } catch (e) { log('✖ ' + p + ' — ' + (e.message || '').split('\n')[0]); } }; - const projects = scanProjects(projectsRoot()); + const projects = scanAll(projectSources()); log(`Creating folders for ${projects.length} project(s)…`); for (const pr of projects) { const a = detectApp(pr.path); const name = a.repo || a.name; diff --git a/build-helper/lib/project/scan.js b/build-helper/lib/project/scan.js index ea7aceb..d784f45 100644 --- a/build-helper/lib/project/scan.js +++ b/build-helper/lib/project/scan.js @@ -16,18 +16,39 @@ function findAppIcon(projectPath) { return null; } -function scanProjects(root) { +// Dirs that never contain a sibling app — pruned so recursive scans stay fast. +const PRUNE = new Set(['node_modules', '.dart_tool', 'build', '.git', 'ios', 'android', 'macos', 'windows', 'linux', 'web', '.symlinks', '.fvm', '.idea', 'Pods']); +const isFlutter = (p) => fs.existsSync(path.join(p, 'pubspec.yaml')) && fs.existsSync(path.join(p, 'lib')); + +// One directory → the dashboard card projection, or null if it isn't a Flutter app. +function detectCard(p) { + if (!isFlutter(p)) return null; + const a = detectApp(p); + return { name: a.name, path: a.path, version: a.version, currentMode: a.currentMode, modes: Object.keys(a.modes), needsConfig: a.needsConfig, firebase: !!a.firebase, hasIcon: !!findAppIcon(p) }; +} + +// Scan a root for Flutter apps. recursive → descend up to `depth` levels (a Flutter dir is a leaf). +function scanProjects(root, recursive, depth = 3) { let ents; try { ents = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; } const out = []; for (const e of ents) { - if (!e.isDirectory()) continue; + if (!e.isDirectory() || PRUNE.has(e.name) || e.name.startsWith('.')) continue; const p = path.join(root, e.name); - if (!fs.existsSync(path.join(p, 'pubspec.yaml'))) continue; - if (!fs.existsSync(path.join(p, 'lib'))) continue; - const a = detectApp(p); - out.push({ name: a.name, path: a.path, version: a.version, currentMode: a.currentMode, modes: Object.keys(a.modes), needsConfig: a.needsConfig, firebase: !!a.firebase, hasIcon: !!findAppIcon(p) }); + const card = detectCard(p); + if (card) out.push(card); + else if (recursive && depth > 1) out.push(...scanProjects(p, true, depth - 1)); } return out.sort((x, y) => x.name.localeCompare(y.name)); } -module.exports = { findAppIcon, scanProjects }; +// Every card across all scan roots + pinned project paths, deduped by absolute path. +function scanAll(sources) { + const { roots = [], pinned = [], recursive = false } = sources || {}; + const seen = new Set(); const out = []; + for (const c of [...roots.flatMap((r) => scanProjects(r, recursive)), ...pinned.map(detectCard)]) { + if (c && !seen.has(c.path)) { seen.add(c.path); out.push(c); } + } + return out; +} + +module.exports = { findAppIcon, scanProjects, detectCard, scanAll }; diff --git a/build-helper/lib/routes/apps.js b/build-helper/lib/routes/apps.js index 95279e0..fa56272 100644 --- a/build-helper/lib/routes/apps.js +++ b/build-helper/lib/routes/apps.js @@ -2,6 +2,7 @@ // Project + per-app routes: the dashboard payload, project list/detail, per-app config saves, // health, git metadata, changelog, and the app icon image. const fs = require('fs'); +const { spawn } = require('child_process'); const { BUILDS_JSON, readJson, readConfig, projectsRoot } = require('../state'); const { dashboardData, handleAppSave, handleFavorite, handleReorder } = require('../dashboard'); const { detectApp, scanProjects, gitBranches, gitLastCommit, gitChangelog, groupConventional, nextBuildNumber, appHealth, findAppIcon } = require('../project'); @@ -11,8 +12,16 @@ const { appleAuth, playAccount } = require('../signing-config'); function register(app) { const J = app.sendJson; - app.r('GET', '/api/dashboard', ({ res, q }) => J(res, 200, dashboardData(q.get('root')))); + app.r('GET', '/api/dashboard', ({ res }) => J(res, 200, dashboardData())); app.r('GET', '/api/projects', ({ res, q }) => { const root = q.get('root') || projectsRoot(); return J(res, 200, { root, projects: scanProjects(root) }); }); + // Open a project's folder in the OS file manager (Finder / Explorer / xdg). Args array — no shell. + app.r('POST', '/api/reveal', ({ res, body }) => { + const p = String((body || {}).path || '').trim(); + if (!p || !fs.existsSync(p)) return J(res, 400, { error: 'path not found' }); + const cmd = process.platform === 'darwin' ? 'open' : (process.platform === 'win32' ? 'explorer' : 'xdg-open'); + try { spawn(cmd, [p], { detached: true, stdio: 'ignore' }).unref(); } catch {} + return J(res, 200, { ok: true }); + }, { body: true }); app.r('GET', '/api/project', ({ res, q }) => { const p = q.get('path'); if (!p || !fs.existsSync(p)) return J(res, 404, { error: 'not found' }); diff --git a/build-helper/lib/routes/setup.js b/build-helper/lib/routes/setup.js index 29b3a09..0bbc961 100644 --- a/build-helper/lib/routes/setup.js +++ b/build-helper/lib/routes/setup.js @@ -3,8 +3,11 @@ // build-notification + email config, the two OAuth/login SSE flows (OneDrive, Firebase CLI), and the // environment preflight (doctor). Issue-tracker config is NOT re-wired here — the shared kit // connectors layer (/api/connectors*) already owns that store; setup.js handleSetupTracker is unused. +const os = require('os'); +const fs = require('fs'); +const path = require('path'); const { setupStatus, handleSetupPlay, handleSetupApple, handleSetupFirebaseApp, handleSetupFirebaseLogin, handleSetupNotify, handleSetupEmail } = require('../setup'); -const { setProjectsRoot } = require('../state'); +const { setSources, validateSource } = require('../state'); const { handleSetupRclone } = require('../onedrive'); const { doctorData, doctorInstallCmd } = require('../dashboard'); const { run } = require('../shell'); @@ -12,7 +15,17 @@ const { run } = require('../shell'); function register(app) { const J = app.sendJson; app.r('GET', '/api/setup', ({ res }) => J(res, 200, setupStatus())); - app.r('POST', '/api/setup/root', ({ res, body }) => J(res, 200, { ok: true, root: setProjectsRoot((body || {}).root) }), { body: true }); + // Persist project sources (roots + pinned paths + recursive). Validates each entry; returns the + // saved set plus any `invalid` ones the UI should flag (kept out of the persisted config). + app.r('POST', '/api/setup/sources', ({ res, body }) => { + const s = body || {}; const invalid = []; + const keep = (arr, pinned) => (arr || []).filter((p) => { const v = validateSource(p, pinned); if (!v.ok) invalid.push({ path: p, kind: pinned ? 'pinned' : 'root', reason: v.reason }); return v.ok; }); + const sources = setSources({ roots: keep(s.roots, false), pinned: keep(s.pinned, true), recursive: s.recursive }); + return J(res, 200, { ok: true, sources, invalid }); + }, { body: true }); + app.r('GET', '/api/setup/suggest-roots', ({ res }) => J(res, 200, { roots: suggestRoots() })); + // Back-compat single-root save (superseded by the sources manager UI). ponytail: drop once no caller. + app.r('POST', '/api/setup/root', ({ res, body }) => { const r = String((body || {}).root || '').trim(); const s = setSources({ roots: r ? [r] : [], pinned: [], recursive: false }); return J(res, 200, { ok: true, root: s.roots[0] || '' }); }, { body: true }); app.r('POST', '/api/setup/play', ({ res, body }) => handleSetupPlay(res, body), { body: true }); app.r('POST', '/api/setup/apple', ({ res, body }) => handleSetupApple(res, body), { body: true }); app.r('POST', '/api/setup/firebase-app', ({ res, body }) => handleSetupFirebaseApp(res, body), { body: true }); @@ -37,4 +50,11 @@ function installTool(res, body) { .catch((e) => { send('error', { message: e.message }); res.end(); }); } +// Common Flutter-project locations under $HOME that actually exist — one-click add suggestions. +function suggestRoots() { + const home = os.homedir(); + return ['Projects', 'dev', 'Development', 'StudioProjects', 'AndroidStudioProjects', 'mobileApps', 'code', 'src'] + .map((d) => path.join(home, d)).filter((p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } }); +} + module.exports = { register }; diff --git a/build-helper/lib/state.js b/build-helper/lib/state.js index 4c92790..d040afd 100644 --- a/build-helper/lib/state.js +++ b/build-helper/lib/state.js @@ -33,9 +33,28 @@ function readConfig() { return c; } function writeConfig(c) { writeJson(CONFIG_JSON, c); } -function projectsRoot() { return readConfig().root || process.env.FLUTTER_PROJECTS || path.join(os.homedir(), 'mobileApps'); } -// Persist (or clear) the scanned projects root. Blank → fall back to FLUTTER_PROJECTS / ~/mobileApps. -function setProjectsRoot(root) { const c = readConfig(); const v = (root || '').trim(); if (v) c.root = v; else delete c.root; writeConfig(c); return projectsRoot(); } +// Where Build Helper looks for apps: N scan roots + individually pinned project paths, plus a +// recursive flag. Migrates the legacy single `root` (or FLUTTER_PROJECTS / ~/mobileApps) into roots[]. +function projectSources() { + const c = readConfig(); + const roots = Array.isArray(c.roots) ? c.roots : [c.root || process.env.FLUTTER_PROJECTS || path.join(os.homedir(), 'mobileApps')]; + return { roots, pinned: Array.isArray(c.pinned) ? c.pinned : [], recursive: !!c.recursive }; +} +function projectsRoot() { return projectSources().roots[0] || path.join(os.homedir(), 'mobileApps'); } // back-compat single root +const _uniq = (a) => [...new Set((a || []).map((x) => String(x || '').trim()).filter(Boolean))]; +function setSources(s) { + const c = readConfig(); + c.roots = _uniq(s.roots); c.pinned = _uniq(s.pinned); c.recursive = !!s.recursive; delete c.root; // migrated + writeConfig(c); return projectSources(); +} +// A source is usable if it exists; a pinned path must also look like a Flutter project (pubspec + lib). +function validateSource(p, isPinned) { + const v = String(p || '').trim(); + if (!v) return { ok: false, reason: 'empty' }; + if (!fs.existsSync(v)) return { ok: false, reason: 'not found' }; + if (isPinned && !(fs.existsSync(path.join(v, 'pubspec.yaml')) && fs.existsSync(path.join(v, 'lib')))) return { ok: false, reason: 'not a Flutter project' }; + return { ok: true }; +} // ---------- running-build tracker (for Stop + one-at-a-time lock) ---------- const running = { children: new Set(), aborted: false, busy: false, info: null, log: [], seq: 0 }; @@ -62,6 +81,6 @@ function sendJson(res, code, obj) { module.exports = { ID, DATA, ROOT, PORT, FLUTTER, RCLONE, ONEDRIVE_REMOTE, ONEDRIVE_BASE, CONFIG_JSON, BUILDS_JSON, CREDS_DIR, ARTIFACTS_DIR, LOGS_DIR, CHANGELOGS_DIR, LOGICAL_ENVS, - readJson, writeJson, esc, readConfig, writeConfig, projectsRoot, setProjectsRoot, + readJson, writeJson, esc, readConfig, writeConfig, projectsRoot, projectSources, setSources, validateSource, running, stopRunning, feedClients, feedHistory, broadcast, sendJson, }; diff --git a/build-helper/public/js/api.js b/build-helper/public/js/api.js index ea2d92e..0dd5343 100644 --- a/build-helper/public/js/api.js +++ b/build-helper/public/js/api.js @@ -16,7 +16,9 @@ window.API = { signing: (path) => _get('/api/signing/status?path=' + encodeURIComponent(path)), changelog: (path, env) => _get('/api/changelog?' + _q({ path, env, grouped: 1 })), - saveRoot: (root) => postJson('/api/setup/root', { root }), // persist the projects root + saveSources: (sources) => postJson('/api/setup/sources', sources), // { roots, pinned, recursive } + suggestRoots: () => _get('/api/setup/suggest-roots').then((r) => r.roots || []).catch(() => []), + reveal: (path) => postJson('/api/reveal', { path }), // open a folder in Finder post: (url, body) => postJson(url, body), // kit.js stream: (url, body, box, done) => streamSSE(url, body, box, done), // kit.js stopBuild: () => postJson('/api/build/stop', {}), diff --git a/build-helper/public/js/components.js b/build-helper/public/js/components.js index fb85d54..3aec5e2 100644 --- a/build-helper/public/js/components.js +++ b/build-helper/public/js/components.js @@ -42,6 +42,7 @@ window.C = { ${C.dot(health)} ${esc(p.name)} ${p.favorite ? '★' : ''} + ${ICON.folder} v${esc(verName(p.version))} ${C.envChips(p)} @@ -50,6 +51,37 @@ window.C = { `; }, + // The project-sources manager: scan roots + pinned projects + recursive toggle + suggestions. + sourcesPanel(BH, suggest) { + const s = BH.sources || { roots: [], pinned: [], recursive: false }; + const row = (val, kind) => `