Skip to content

Commit 53e1658

Browse files
committed
Bundle micromamba binaries in Electron build
1 parent 8c15448 commit 53e1658

File tree

3 files changed

+286
-13
lines changed

3 files changed

+286
-13
lines changed

electron/electron-builder.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"output": "dist",
66
"buildResources": "resources"
77
},
8+
"afterPack": "scripts/after-pack.cjs",
89
"files": [
910
"assets/**/*",
1011
"dist-electron/**/*",

electron/scripts/after-pack.cjs

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
const fs = require("fs");
2+
const fsp = fs.promises;
3+
const path = require("path");
4+
const os = require("os");
5+
const https = require("https");
6+
const { pipeline } = require("stream/promises");
7+
const tar = require("tar-fs");
8+
const gunzip = require("gunzip-maybe");
9+
10+
const MICROMAMBA_API_BASE = "https://micro.mamba.pm/api/micromamba";
11+
const MICROMAMBA_DIR_NAME = "micromamba";
12+
const MICROMAMBA_BINARY_NAME = {
13+
win32: "micromamba.exe",
14+
default: "micromamba",
15+
};
16+
17+
const ARCH_MAPPING = {
18+
0: "x64",
19+
1: "ia32",
20+
2: "armv7l",
21+
3: "arm64",
22+
4: "universal",
23+
};
24+
25+
function getArchName(arch) {
26+
if (typeof arch === "string") {
27+
return arch;
28+
}
29+
return ARCH_MAPPING[arch] || "";
30+
}
31+
32+
function resolveMicromambaTarget(platform, arch) {
33+
const archName = getArchName(arch);
34+
35+
if (platform === "darwin") {
36+
if (archName === "arm64") {
37+
return "osx-arm64";
38+
}
39+
if (archName === "x64") {
40+
return "osx-64";
41+
}
42+
return null;
43+
}
44+
45+
if (platform === "linux") {
46+
if (archName === "x64") {
47+
return "linux-64";
48+
}
49+
if (archName === "arm64") {
50+
return "linux-aarch64";
51+
}
52+
return null;
53+
}
54+
55+
if (platform === "win32") {
56+
if (archName === "x64") {
57+
return "win-64";
58+
}
59+
return null;
60+
}
61+
62+
return null;
63+
}
64+
65+
function downloadFile(url, destinationPath) {
66+
return new Promise((resolve, reject) => {
67+
const request = https.get(url, (response) => {
68+
if (
69+
response.statusCode &&
70+
response.statusCode >= 300 &&
71+
response.statusCode < 400 &&
72+
response.headers.location
73+
) {
74+
const redirectUrl = new URL(response.headers.location, url).toString();
75+
response.destroy();
76+
downloadFile(redirectUrl, destinationPath).then(resolve).catch(reject);
77+
return;
78+
}
79+
80+
if (response.statusCode !== 200) {
81+
reject(
82+
new Error(
83+
`Failed to download micromamba (status ${response.statusCode})`
84+
)
85+
);
86+
response.resume();
87+
return;
88+
}
89+
90+
const fileStream = fs.createWriteStream(destinationPath);
91+
pipeline(response, fileStream)
92+
.then(resolve)
93+
.catch((error) => reject(error));
94+
});
95+
96+
request.on("error", (error) => {
97+
reject(error);
98+
});
99+
});
100+
}
101+
102+
async function extractArchive(archivePath, destinationDir) {
103+
await fsp.mkdir(destinationDir, { recursive: true });
104+
const extractStream = tar.extract(destinationDir);
105+
await pipeline(fs.createReadStream(archivePath), gunzip(), extractStream);
106+
}
107+
108+
async function findBinary(startDir, binaryName) {
109+
const stack = [startDir];
110+
111+
while (stack.length > 0) {
112+
const currentDir = stack.pop();
113+
if (!currentDir) {
114+
continue;
115+
}
116+
117+
const entries = await fsp.readdir(currentDir, { withFileTypes: true });
118+
for (const entry of entries) {
119+
const fullPath = path.join(currentDir, entry.name);
120+
if (entry.isDirectory()) {
121+
stack.push(fullPath);
122+
} else if (entry.isFile() && entry.name === binaryName) {
123+
return fullPath;
124+
}
125+
}
126+
}
127+
128+
return null;
129+
}
130+
131+
function resolveResourcesDir(context) {
132+
const { electronPlatformName, appOutDir, packager } = context;
133+
if (electronPlatformName === "darwin") {
134+
const appName = `${packager.appInfo.productFilename}.app`;
135+
return path.join(appOutDir, appName, "Contents", "Resources");
136+
}
137+
138+
return path.join(appOutDir, "resources");
139+
}
140+
141+
async function ensureMicromambaBundled(context) {
142+
const { electronPlatformName, arch } = context;
143+
const target = resolveMicromambaTarget(electronPlatformName, arch);
144+
145+
if (!target) {
146+
console.warn(
147+
`Skipping micromamba bundling for unsupported target ${electronPlatformName}-${getArchName(
148+
arch
149+
)}`
150+
);
151+
return;
152+
}
153+
154+
const resourcesDir = resolveResourcesDir(context);
155+
const binaryName =
156+
MICROMAMBA_BINARY_NAME[electronPlatformName] ||
157+
MICROMAMBA_BINARY_NAME.default;
158+
const micromambaDir = path.join(resourcesDir, MICROMAMBA_DIR_NAME);
159+
const targetPath = path.join(micromambaDir, binaryName);
160+
161+
try {
162+
await fsp.access(targetPath);
163+
console.info(`micromamba already bundled at ${targetPath}`);
164+
return;
165+
} catch {
166+
// Continue to download
167+
}
168+
169+
console.info(
170+
`Bundling micromamba for ${electronPlatformName}-${getArchName(
171+
arch
172+
)} from ${MICROMAMBA_API_BASE}/${target}/latest`
173+
);
174+
175+
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "micromamba-build-"));
176+
const archivePath = path.join(tempDir, "micromamba.tar.bz2");
177+
const extractDir = path.join(tempDir, "extracted");
178+
179+
try {
180+
await downloadFile(
181+
`${MICROMAMBA_API_BASE}/${target}/latest`,
182+
archivePath
183+
);
184+
await extractArchive(archivePath, extractDir);
185+
186+
const binaryPath = await findBinary(extractDir, binaryName);
187+
if (!binaryPath) {
188+
throw new Error("Micromamba binary not found after extraction");
189+
}
190+
191+
await fsp.mkdir(micromambaDir, { recursive: true });
192+
await fsp.copyFile(binaryPath, targetPath);
193+
194+
if (electronPlatformName !== "win32") {
195+
await fsp.chmod(targetPath, 0o755);
196+
}
197+
198+
console.info(`micromamba bundled to ${targetPath}`);
199+
} finally {
200+
await fsp.rm(tempDir, { recursive: true, force: true });
201+
}
202+
}
203+
204+
module.exports = async function afterPack(context) {
205+
try {
206+
await ensureMicromambaBundled(context);
207+
} catch (error) {
208+
console.error("Failed to bundle micromamba", error);
209+
throw error;
210+
}
211+
};

electron/src/installer.ts

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const MICROMAMBA_ENV_VAR = "MICROMAMBA_EXE";
2929
const MICROMAMBA_BIN_DIR_NAME = "bin";
3030
const MICROMAMBA_EXECUTABLE_NAME =
3131
process.platform === "win32" ? "micromamba.exe" : "micromamba";
32+
const MICROMAMBA_BUNDLED_DIR_NAME = "micromamba";
3233
const PYTHON_PACKAGES_SETTING_KEY = "PYTHON_PACKAGES";
3334

3435
interface InstallationPreferences {
@@ -135,28 +136,72 @@ function sanitizeProcessEnv(): NodeJS.ProcessEnv {
135136
return env;
136137
}
137138

138-
function detectMicromambaExecutable(): string {
139+
function validateMicromambaExecutableCandidate(
140+
candidate: string | undefined | null
141+
): string {
142+
if (!candidate) {
143+
return "";
144+
}
145+
146+
try {
147+
const resolved = spawnSync(candidate, ["--version"], {
148+
stdio: "ignore",
149+
});
150+
151+
if (resolved.status === 0) {
152+
return candidate;
153+
}
154+
} catch {
155+
// Ignore and continue searching
156+
}
157+
158+
return "";
159+
}
160+
161+
function resolveExplicitMicromambaExecutable(): string {
139162
const explicit = process.env[MICROMAMBA_ENV_VAR]?.trim();
140-
const candidates = [explicit, "micromamba"]
141-
.filter(Boolean)
142-
.map((candidate) => candidate as string);
163+
return validateMicromambaExecutableCandidate(explicit);
164+
}
165+
166+
function detectMicromambaExecutable(): string {
167+
const candidates = ["micromamba"];
143168

144169
for (const executable of candidates) {
145-
try {
146-
const resolved = spawnSync(executable, ["--version"], {
147-
stdio: "ignore",
148-
});
149-
if (resolved.status === 0) {
150-
return executable;
151-
}
152-
} catch {
153-
// Continue searching
170+
const resolved = validateMicromambaExecutableCandidate(executable);
171+
if (resolved) {
172+
return resolved;
154173
}
155174
}
156175

157176
return "";
158177
}
159178

179+
function getCandidateMicromambaResourceDirs(): string[] {
180+
const dirs = new Set<string>();
181+
182+
if (app.isPackaged) {
183+
dirs.add(process.resourcesPath);
184+
} else {
185+
dirs.add(path.join(app.getAppPath(), "resources"));
186+
dirs.add(path.resolve(__dirname, "..", "resources"));
187+
}
188+
189+
return Array.from(dirs);
190+
}
191+
192+
async function findBundledMicromambaExecutable(): Promise<string | null> {
193+
const binaryName = MICROMAMBA_EXECUTABLE_NAME;
194+
195+
for (const baseDir of getCandidateMicromambaResourceDirs()) {
196+
const candidate = path.join(baseDir, MICROMAMBA_BUNDLED_DIR_NAME, binaryName);
197+
if (await fileExists(candidate)) {
198+
return candidate;
199+
}
200+
}
201+
202+
return null;
203+
}
204+
160205
function getMicromambaDownloadUrl(): string | null {
161206
const platform = process.platform;
162207
const arch = process.arch;
@@ -271,13 +316,29 @@ async function extractMicromambaArchive(
271316
}
272317

273318
async function ensureMicromambaAvailable(): Promise<string> {
319+
const explicitExecutable = resolveExplicitMicromambaExecutable();
320+
if (explicitExecutable) {
321+
logMessage(`Using micromamba from ${explicitExecutable}`);
322+
return explicitExecutable;
323+
}
324+
325+
const bundledExecutable = await findBundledMicromambaExecutable();
326+
if (bundledExecutable) {
327+
logMessage(`Using bundled micromamba at ${bundledExecutable}`);
328+
process.env[MICROMAMBA_ENV_VAR] = bundledExecutable;
329+
return bundledExecutable;
330+
}
331+
274332
const detectedExecutable = detectMicromambaExecutable();
275333
if (detectedExecutable) {
334+
logMessage(`Using micromamba from system PATH: ${detectedExecutable}`);
276335
return detectedExecutable;
277336
}
278337

279338
const localExecutable = getMicromambaExecutablePath();
280339
if (await fileExists(localExecutable)) {
340+
logMessage(`Using previously installed micromamba at ${localExecutable}`);
341+
process.env[MICROMAMBA_ENV_VAR] = localExecutable;
281342
return localExecutable;
282343
}
283344

0 commit comments

Comments
 (0)