Skip to content

Commit 937ef90

Browse files
Userclaude
authored andcommitted
refactor(gateway): decouple the runtime from Electron via a runtime-env path seam
Prep for extracting @offgrid/gateway as a standalone product. The runtime layer (llm, imagegen, tts, embeddings, active-models, mflux, rag/extractors) no longer imports 'electron' — all data/binary/resource paths resolve through runtime-env, which uses Electron's app when embedded and env vars / cwd when standalone. Behavior-preserving in the desktop app (verified: paths + gateway resolve). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fc1a077 commit 937ef90

8 files changed

Lines changed: 149 additions & 61 deletions

File tree

src/main/active-models.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
// speech (TTS), transcription (STT) — are stateless per-call runtimes, so we just
44
// record the chosen model id here and each runtime reads it (falling back to its
55
// own heuristic when nothing is chosen). One file: active-modalities.json.
6-
import { app } from 'electron';
76
import fs from 'fs';
87
import path from 'path';
8+
import { modelsDir } from './runtime-env';
99

1010
export type Modality = 'image' | 'speech' | 'transcription';
1111

1212
function storeFile(): string {
13-
return path.join(app.getPath('userData'), 'models', 'active-modalities.json');
13+
return path.join(modelsDir(), 'active-modalities.json');
1414
}
1515

1616
function readAll(): Record<string, string | null> {

src/main/embeddings.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { pipeline, env } from '@xenova/transformers';
2-
import path from 'path';
3-
import { app } from 'electron';
2+
import { modelsDir } from './runtime-env';
43

54
// Configure transformers to look for models locally or cache them properly
6-
env.localModelPath = path.join(app.getPath('userData'), 'models');
5+
env.localModelPath = modelsDir();
76
env.allowRemoteModels = true; // Allow download on first run
87

98
class EmbeddingService {

src/main/imagegen.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,13 @@
44
// img2img, persist the PNG under userData/generated-images, return a data URL.
55

66
import { spawn, type ChildProcess } from 'child_process';
7-
import { app } from 'electron';
87
import path from 'path';
98
import fs from 'fs';
109
import os from 'os';
1110
import { llm } from './llm';
1211
import { isMfluxModelId, mfluxAvailable, getMfluxModel, runMflux, cancelMflux, MFLUX_MODELS } from './mflux';
1312
import { getActiveModal } from './active-models';
14-
15-
function binRoots(): string[] {
16-
return app.isPackaged
17-
? [path.join(process.resourcesPath, 'bin')]
18-
: [path.join(app.getAppPath(), 'resources', 'bin'), path.join(process.cwd(), 'resources', 'bin')];
19-
}
13+
import { binRoots, dataDir, modelsDir } from './runtime-env';
2014

2115
function findSdCli(): string | null {
2216
for (const r of binRoots()) {
@@ -48,10 +42,6 @@ function isCoreMLModelDir(p: string): boolean {
4842
}
4943
}
5044

51-
function modelsDir(): string {
52-
return path.join(app.getPath('userData'), 'models');
53-
}
54-
5545
/** All image models on disk: GGUFs, custom .safetensors checkpoints, Core ML dirs. */
5646
export function listImageModels(): string[] {
5747
const dir = modelsDir();
@@ -85,7 +75,7 @@ export function listImageModels(): string[] {
8575

8676
/** All generated images on disk, newest first (excludes step-preview files). */
8777
export function listGeneratedImages(scope?: { conversationId?: string; projectId?: string | null }): { path: string; name: string; mtime: number; conversationId?: string; projectId?: string | null }[] {
88-
const dir = path.join(app.getPath('userData'), 'generated-images');
78+
const dir = path.join(dataDir(), 'generated-images');
8979
try {
9080
let all = fs
9181
.readdirSync(dir)
@@ -110,7 +100,7 @@ export function listGeneratedImages(scope?: { conversationId?: string; projectId
110100
export function deleteGeneratedImage(p: string): boolean {
111101
try {
112102
// Only allow deleting inside the generated-images dir (safety).
113-
const dir = path.join(app.getPath('userData'), 'generated-images');
103+
const dir = path.join(dataDir(), 'generated-images');
114104
if (!path.resolve(p).startsWith(path.resolve(dir))) return false;
115105
fs.unlinkSync(p);
116106
return true;
@@ -121,7 +111,7 @@ export function deleteGeneratedImage(p: string): boolean {
121111

122112
// --- Style-preset thumbnails (generated on-device, cached; never hotlinked) --
123113
function styleThumbDir(): string {
124-
return path.join(app.getPath('userData'), 'style-thumbs');
114+
return path.join(dataDir(), 'style-thumbs');
125115
}
126116

127117
/** Map of style key -> cached thumbnail path (on-device generated). */
@@ -379,7 +369,7 @@ export async function generateImage(
379369
// delegates the spawn to the mflux module. Returns before the sd-cli path.
380370
if (isMfluxModelId(params.model)) {
381371
const def = getMfluxModel(params.model)!;
382-
const outDir = path.join(app.getPath('userData'), 'generated-images');
372+
const outDir = path.join(dataDir(), 'generated-images');
383373
fs.mkdirSync(outDir, { recursive: true });
384374
const outPath = path.join(outDir, `img-${String(Date.now())}.png`);
385375
running = true;
@@ -491,7 +481,7 @@ export async function generateImage(
491481
params.prompt = `${params.prompt} ${tags}`;
492482
}
493483

494-
const outDir = path.join(app.getPath('userData'), 'generated-images');
484+
const outDir = path.join(dataDir(), 'generated-images');
495485
fs.mkdirSync(outDir, { recursive: true });
496486
const seed = params.seed ?? -1;
497487
const stamp = String(Date.now());

src/main/llm.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,9 @@ import { spawn, execSync, ChildProcess } from "child_process";
22
import { Mutex } from "async-mutex";
33
import { callHook } from "./bootstrap/hookRegistry";
44
import path from "path";
5-
import { app } from "electron";
65
import * as fs from "fs";
76
import * as http from "http";
8-
9-
// Models live under the unified userData dir (pinned in main/index.ts), so this
10-
// stays consistent with the DB + embeddings paths instead of a hardcoded name.
11-
function getModelsDir(): string {
12-
return path.join(app.getPath('userData'), 'models');
13-
}
7+
import { modelsDir as getModelsDir, binRoots, isPackaged, onHostQuit } from "./runtime-env";
148

159
export interface LlmSettings {
1610
temperature?: number;
@@ -177,9 +171,7 @@ export class LLMService {
177171

178172
// Prefer the updated, self-contained llama.cpp build in bin/llama (supports
179173
// newer architectures like gemma4); fall back to the legacy bin/llama-server.
180-
const roots = app.isPackaged
181-
? [path.join(process.resourcesPath, "bin")]
182-
: [path.join(app.getAppPath(), "resources", "bin"), path.join(process.cwd(), "resources", "bin")];
174+
const roots = binRoots();
183175
const candidates = roots.flatMap((r) => [
184176
path.join(r, "llama", "llama-server"),
185177
path.join(r, "llama-server"),
@@ -194,7 +186,7 @@ export class LLMService {
194186
console.log(`[LLMService] Model: ${this.modelPath}`);
195187

196188
// Strip macOS quarantine attributes on production builds (downloaded DMGs get quarantined)
197-
if (app.isPackaged && process.platform === 'darwin') {
189+
if (isPackaged() && process.platform === 'darwin') {
198190
try {
199191
const binDir = path.dirname(serverPath);
200192
execSync(`xattr -cr "${binDir}"`, { stdio: 'ignore' });
@@ -554,6 +546,6 @@ export class LLMService {
554546

555547
export const llm = new LLMService();
556548

557-
app.on("before-quit", () => {
549+
onHostQuit(() => {
558550
llm.stop();
559551
});

src/main/mflux.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,11 @@
44
// (the merge needs f16); mflux runs full/MLX-quantized models and supports LoRA
55
// natively. Python/MLX is bundled (fully offline) at resources/bin/mflux/ — see
66
// scripts/build-mflux-env.sh. Apple Silicon only.
7-
import { app } from 'electron';
87
import { spawn, ChildProcess } from 'child_process';
98
import * as path from 'path';
109
import * as fs from 'fs';
1110
import * as os from 'os';
12-
13-
// Mirror imagegen.ts binRoots(): packaged → process.resourcesPath/bin, dev →
14-
// app source + cwd.
15-
function binRoots(): string[] {
16-
return app.isPackaged
17-
? [path.join(process.resourcesPath, 'bin')]
18-
: [path.join(app.getAppPath(), 'resources', 'bin'), path.join(process.cwd(), 'resources', 'bin')];
19-
}
11+
import { binRoots, dataDir } from './runtime-env';
2012

2113
/** The bundled standalone python3 inside the mflux env, or null if not present. */
2214
export function findMfluxPython(): string | null {
@@ -34,7 +26,7 @@ export function mfluxAvailable(): boolean {
3426

3527
/** Where mflux downloads/caches model weights (its HF_HOME). */
3628
export function mfluxCacheDir(): string {
37-
return path.join(app.getPath('userData'), 'mflux-cache');
29+
return path.join(dataDir(), 'mflux-cache');
3830
}
3931

4032
// Entry point that handles every base model via --base-model. Invoked with -m

src/main/rag/extractors.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ import { promisify } from 'util';
1313
import fs from 'fs';
1414
import os from 'os';
1515
import path from 'path';
16-
import { app } from 'electron';
1716
import type { ExtractionBridges } from '@offgrid/rag';
1817
import { llm } from '../llm';
1918
import { getActiveModal } from '../active-models';
19+
import { binRoots, modelsDir } from '../runtime-env';
2020

2121
const execFileAsync = promisify(execFile);
2222

@@ -33,18 +33,13 @@ function existing(paths: string[]): string | null {
3333

3434
/** Resolve the bundled whisper-cli across dev / packaged layouts. */
3535
export function whisperBin(): string | null {
36-
return existing([
37-
path.join(process.resourcesPath ?? '', 'bin', 'whisper', 'whisper-cli'),
38-
path.join(app.getAppPath(), 'resources', 'bin', 'whisper', 'whisper-cli'),
39-
path.join(process.cwd(), 'resources', 'bin', 'whisper', 'whisper-cli'),
40-
]);
36+
return existing(binRoots().map((r) => path.join(r, 'whisper', 'whisper-cli')));
4137
}
4238

4339
/** Resolve ffmpeg: bundled first, then common system locations. */
4440
function ffmpegBin(): string | null {
4541
return existing([
46-
path.join(process.resourcesPath ?? '', 'bin', 'ffmpeg'),
47-
path.join(app.getAppPath(), 'resources', 'bin', 'ffmpeg'),
42+
...binRoots().map((r) => path.join(r, 'ffmpeg')),
4843
'/opt/homebrew/bin/ffmpeg',
4944
'/usr/local/bin/ffmpeg',
5045
'/usr/bin/ffmpeg',
@@ -55,7 +50,7 @@ function ffmpegBin(): string | null {
5550
* MULTILINGUAL model (the `.en` models can only do English) and a more capable
5651
* size for accuracy, since meetings may be in any language. */
5752
export function whisperModel(): string | null {
58-
const dir = path.join(app.getPath('userData'), 'models');
53+
const dir = modelsDir();
5954
try {
6055
// User-chosen transcription model wins, when it's actually present on disk.
6156
const chosen = getActiveModal('transcription');

src/main/runtime-env.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Host-agnostic path/resource resolution for the gateway runtime.
2+
//
3+
// The runtime (llm, imagegen, tts, embeddings, active-models, model-server,
4+
// rag/extractors, mflux) must run BOTH embedded in Electron AND standalone
5+
// (the future @offgrid/gateway: a Node CLI / Docker image). This is the single
6+
// seam that hides where data + bundled binaries live, so none of those modules
7+
// import `electron` directly.
8+
//
9+
// Resolution order: explicit configure() → env vars → Electron `app` → cwd.
10+
import path from 'path';
11+
12+
interface RuntimeConfig {
13+
dataDir?: string; // writable per-user dir (models, caches, generated output)
14+
binRoots?: string[]; // dirs to search for bundled binaries (llama-server, ffmpeg, …)
15+
resourceDirs?: string[]; // dirs to search for bundled resources (tts-worker.mjs, …)
16+
}
17+
18+
let cfg: RuntimeConfig = {};
19+
20+
/** Host calls this once at startup. Electron host passes app paths; a standalone
21+
* host passes its own. Any field left out falls back to env/electron/cwd. */
22+
export function configureRuntime(c: RuntimeConfig): void {
23+
cfg = { ...cfg, ...c };
24+
}
25+
26+
// Lazily probe Electron without a hard dependency (so the module loads in plain Node).
27+
function electron(): { dataDir: string; binRoots: string[]; resourceDirs: string[] } | null {
28+
try {
29+
// eslint-disable-next-line @typescript-eslint/no-var-requires
30+
const { app } = require('electron');
31+
if (!app?.getPath) return null;
32+
const packaged = app.isPackaged;
33+
return {
34+
dataDir: app.getPath('userData'),
35+
binRoots: packaged
36+
? [path.join(process.resourcesPath, 'bin')]
37+
: [path.join(app.getAppPath(), 'resources', 'bin'), path.join(process.cwd(), 'resources', 'bin')],
38+
resourceDirs: packaged
39+
? [process.resourcesPath]
40+
: [path.join(app.getAppPath(), 'resources'), path.join(process.cwd(), 'resources')],
41+
};
42+
} catch {
43+
return null;
44+
}
45+
}
46+
47+
/** Writable per-user data dir (models, caches, generated images, settings). */
48+
export function dataDir(): string {
49+
if (cfg.dataDir) return cfg.dataDir;
50+
if (process.env.OFFGRID_DATA_DIR) return process.env.OFFGRID_DATA_DIR;
51+
const e = electron();
52+
if (e) return e.dataDir;
53+
return path.join(process.cwd(), '.offgrid');
54+
}
55+
56+
/** The models directory under the data dir. */
57+
export function modelsDir(): string {
58+
return path.join(dataDir(), 'models');
59+
}
60+
61+
/** Dirs to search for bundled binaries (binary lives under one of these). */
62+
export function binRoots(): string[] {
63+
if (cfg.binRoots?.length) return cfg.binRoots;
64+
if (process.env.OFFGRID_BIN_DIR) return [process.env.OFFGRID_BIN_DIR];
65+
const e = electron();
66+
if (e) return e.binRoots;
67+
return [path.join(process.cwd(), 'resources', 'bin')];
68+
}
69+
70+
/** App/package root (cwd for spawned helpers that resolve their own deps). */
71+
export function appRoot(): string {
72+
if (process.env.OFFGRID_APP_ROOT) return process.env.OFFGRID_APP_ROOT;
73+
try {
74+
// eslint-disable-next-line @typescript-eslint/no-var-requires
75+
const { app } = require('electron');
76+
if (app?.getAppPath) return app.getAppPath();
77+
} catch {
78+
/* standalone */
79+
}
80+
return process.cwd();
81+
}
82+
83+
/** Resolve a bundled resource file by name across the resource dirs, or null. */
84+
export function resourceFile(name: string): string | null {
85+
for (const d of resourceDirs()) {
86+
const p = path.join(d, name);
87+
try {
88+
// eslint-disable-next-line @typescript-eslint/no-var-requires
89+
if (require('fs').existsSync(p)) return p;
90+
} catch {
91+
/* ignore */
92+
}
93+
}
94+
return null;
95+
}
96+
97+
/** Whether running inside a packaged app (affects quarantine handling on macOS). */
98+
export function isPackaged(): boolean {
99+
if (process.env.OFFGRID_PACKAGED) return process.env.OFFGRID_PACKAGED === '1';
100+
try {
101+
// eslint-disable-next-line @typescript-eslint/no-var-requires
102+
return !!require('electron')?.app?.isPackaged;
103+
} catch {
104+
return false;
105+
}
106+
}
107+
108+
/** Register a shutdown callback. In Electron, hooks 'before-quit'; standalone
109+
* hosts handle process teardown themselves (no-op here). */
110+
export function onHostQuit(fn: () => void): void {
111+
try {
112+
// eslint-disable-next-line @typescript-eslint/no-var-requires
113+
require('electron')?.app?.on?.('before-quit', fn);
114+
} catch {
115+
/* standalone: host owns shutdown */
116+
}
117+
}
118+
119+
/** Dirs to search for bundled resources (e.g. tts-worker.mjs). */
120+
export function resourceDirs(): string[] {
121+
if (cfg.resourceDirs?.length) return cfg.resourceDirs;
122+
if (process.env.OFFGRID_RESOURCE_DIR) return [process.env.OFFGRID_RESOURCE_DIR];
123+
const e = electron();
124+
if (e) return e.resourceDirs;
125+
return [path.join(process.cwd(), 'resources')];
126+
}

src/main/tts.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,16 @@
99
// swap-in / swap-out rather than a permanent ~330MB resident session.
1010

1111
import { spawn } from 'child_process';
12-
import { app } from 'electron';
1312
import path from 'path';
1413
import fs from 'fs';
1514
import os from 'os';
1615
import { getActiveModal } from './active-models';
16+
import { resourceFile, appRoot } from './runtime-env';
1717

1818
const DEFAULT_VOICE = 'af_heart';
1919

2020
function workerPath(): string {
21-
const candidates = app.isPackaged
22-
? [path.join(process.resourcesPath, 'tts-worker.mjs')]
23-
: [
24-
path.join(app.getAppPath(), 'resources', 'tts-worker.mjs'),
25-
path.join(process.cwd(), 'resources', 'tts-worker.mjs'),
26-
];
27-
const found = candidates.find((p) => fs.existsSync(p));
21+
const found = resourceFile('tts-worker.mjs');
2822
if (!found) throw new Error('tts-worker.mjs not found in resources.');
2923
return found;
3024
}
@@ -37,7 +31,7 @@ function workerPath(): string {
3731
function runWorker(args: string[], stdin?: string): Promise<{ out: string; err: string; code: number }> {
3832
return new Promise((resolve, reject) => {
3933
const child = spawn(process.execPath, [workerPath(), ...args], {
40-
cwd: app.getAppPath(),
34+
cwd: appRoot(),
4135
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
4236
});
4337
let out = '';

0 commit comments

Comments
 (0)