diff --git a/api/main.py b/api/main.py index 2a1b22e..586763b 100644 --- a/api/main.py +++ b/api/main.py @@ -31,7 +31,7 @@ def filter(self, record): app = FastAPI( title="Modly API", - version="0.3.4", + version="0.3.5", lifespan=lifespan, ) diff --git a/api/requirements.txt b/api/requirements.txt index a3888d5..556a70c 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -12,3 +12,4 @@ huggingface_hub>=0.27.0 hf_xet>=0.1.0 cryptography>=42.0.0 +certifi>=2024.0.0 diff --git a/api/services/extension_process.py b/api/services/extension_process.py index 47653a4..89622a2 100644 --- a/api/services/extension_process.py +++ b/api/services/extension_process.py @@ -72,6 +72,14 @@ def _build_env(self) -> dict: # from manifest["id"] (which is the ext_id, not the composite node id). if self.model_dir is not None: env["MODEL_DIR"] = str(self.model_dir) + # Extension venvs are based on python-embed which ships without a CA bundle. + # Only set SSL_CERT_FILE if not already provided (preserves corporate/custom certs). + if "SSL_CERT_FILE" not in env: + try: + import certifi + env["SSL_CERT_FILE"] = certifi.where() + except ImportError: + pass return env def _start(self) -> None: diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 3871b97..24cb9df 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -13,7 +13,7 @@ import { downloadModelFromHF, } from './model-downloader' import { getSettings, setSettings } from './settings-store' -import { checkSetupNeeded, markSetupDone, runFullSetup, getVenvPythonExe } from './python-setup' +import { checkSetupNeeded, markSetupDone, runFullSetup, getVenvPythonExe, ensureSslPatch } from './python-setup' import { logger } from './logger' import { getProcessRunner, getPythonProcessRunner, getExtPythonExe, terminateProcessRunner, terminateAllProcessRunners } from './process-runner' import { getBuiltinExtensionsDir } from './builtin-sync' @@ -71,6 +71,7 @@ function runExtensionSetup( ): Promise { return new Promise((resolve, reject) => { const userData = app.getPath('userData') + ensureSslPatch(userData) const pythonExe = getVenvPythonExe(userData) const setupPy = join(extDir, 'setup.py') @@ -98,6 +99,7 @@ function runExtensionSetup( } export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGetter): void { + const activeDownloads = new Map() // Logging from renderer ipcMain.on('log:error', (_event, message: string) => logger.error(`[Renderer] ${message}`)) ipcMain.handle('log:getPath', () => join(app.getPath('userData'), 'logs', 'modly.log')) @@ -295,14 +297,22 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe return isModelDownloaded(modelsDir, modelId) }) + ipcMain.handle('model:activeDownloads', () => + [...activeDownloads.entries()].map(([modelId, progress]) => ({ modelId, ...progress })) + ) + ipcMain.handle('model:download', async (event, { repoId, modelId, skipPrefixes }: { repoId: string; modelId: string; skipPrefixes?: string[] }) => { + activeDownloads.set(modelId, { percent: 0 }) try { await downloadModelFromHF(repoId, modelId, (progress) => { + activeDownloads.set(modelId, progress) event.sender.send('model:downloadProgress', { modelId, ...progress }) }, skipPrefixes) return { success: true } } catch (err) { return { success: false, error: String(err) } + } finally { + activeDownloads.delete(modelId) } }) diff --git a/electron/main/python-setup.ts b/electron/main/python-setup.ts index 197a001..5604487 100644 --- a/electron/main/python-setup.ts +++ b/electron/main/python-setup.ts @@ -89,6 +89,59 @@ export function checkSetupNeeded(userData: string): boolean { return false } +/** + * Write a sitecustomize.py into the venv that silently skips malformed certs + * in the Windows certificate store (ssl.SSLError: [ASN1] nested asn1 error). + * Python imports sitecustomize automatically on every startup. + * No-op on non-Windows or if the patch already exists. + */ +/** + * Returns the path to certifi's cacert.pem inside the venv, or undefined if not found. + * Used to set SSL_CERT_FILE / REQUESTS_CA_BUNDLE for python-embed subprocesses, + * which ship without a CA bundle on Windows. + */ +export function getCertifiBundle(userData: string): string | undefined { + const venvDir = getVenvDir(userData) + // Windows venv layout: Lib/site-packages/certifi/cacert.pem + const winPath = join(venvDir, 'Lib', 'site-packages', 'certifi', 'cacert.pem') + if (existsSync(winPath)) return winPath + // Linux/macOS venv layout: lib/pythonX.Y/site-packages/certifi/cacert.pem + const libDir = join(venvDir, 'lib') + if (existsSync(libDir)) { + try { + const { readdirSync } = require('fs') as typeof import('fs') + for (const entry of readdirSync(libDir)) { + const candidate = join(libDir, entry, 'site-packages', 'certifi', 'cacert.pem') + if (existsSync(candidate)) return candidate + } + } catch { /* ignore */ } + } + return undefined +} + +export function ensureSslPatch(userData: string): void { + if (process.platform !== 'win32') return + const venvDir = getVenvDir(userData) + const sitePkg = join(venvDir, 'Lib', 'site-packages') + const target = join(sitePkg, 'sitecustomize.py') + if (!existsSync(sitePkg)) return + if (existsSync(target)) return + writeFileSync(target, [ + 'try:', + ' import ssl as _ssl', + ' _orig = _ssl.SSLContext._load_windows_store_certs', + ' def _safe(self, storename, purpose):', + ' try:', + ' _orig(self, storename, purpose)', + ' except _ssl.SSLError:', + ' pass', + ' _ssl.SSLContext._load_windows_store_certs = _safe', + 'except Exception:', + ' pass', + '', + ].join('\n'), 'utf-8') +} + export function markSetupDone(userData: string): void { writeFileSync( join(userData, 'python_setup.json'), @@ -107,14 +160,26 @@ function createVenv(pythonExe: string, venvDir: string, win: BrowserWindow): Pro stdio: ['ignore', 'pipe', 'pipe'], env: cleanPythonEnv(), }) + let stderrOut = '' proc.stdout?.on('data', (d: Buffer) => console.log('[venv]', d.toString().trim())) - proc.stderr?.on('data', (d: Buffer) => console.error('[venv]', d.toString().trim())) + proc.stderr?.on('data', (d: Buffer) => { + const text = d.toString().trim() + if (text) { console.error('[venv]', text); stderrOut += text + '\n' } + }) proc.on('close', (code) => { if (code === 0) { win.webContents.send('setup:progress', { step: 'venv', percent: 20 }) resolve() } else { - reject(new Error(`python -m venv exited with code ${code}`)) + let msg = `python -m venv exited with code ${code}` + if (stderrOut) msg += `\n\n${stderrOut.trim()}` + if (process.platform === 'win32') { + msg += + '\n\nYour antivirus may be blocking the Python runtime.' + + `\nTry adding an exclusion for:\n ${pythonExe}` + + '\nOr temporarily pause real-time protection, then click Retry.' + } + reject(new Error(msg)) } }) }) diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 09cacf6..c5e455b 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -83,6 +83,7 @@ contextBridge.exposeInMainWorld('electron', { delete: (modelId: string) => ipcRenderer.invoke('model:delete', modelId), unloadAll: () => ipcRenderer.invoke('model:unloadAll'), showInFolder: (modelId: string) => ipcRenderer.invoke('model:showInFolder', modelId), + activeDownloads: (): Promise<{ modelId: string; percent: number; file?: string; fileIndex?: number; totalFiles?: number }[]> => ipcRenderer.invoke('model:activeDownloads'), onProgress: (cb: (data: { modelId: string; percent: number; file?: string; fileIndex?: number; totalFiles?: number; status?: string }) => void) => { ipcRenderer.on('model:downloadProgress', (_event, data) => cb(data)) }, diff --git a/package.json b/package.json index 45b3202..c81592a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modly", - "version": "0.3.4", + "version": "0.3.5", "description": "Local AI-powered 3D mesh generation from images", "main": "./out/main/index.js", "author": "Modly", diff --git a/src/areas/models/ModelsPage.tsx b/src/areas/models/ModelsPage.tsx index 8c97461..793592b 100644 --- a/src/areas/models/ModelsPage.tsx +++ b/src/areas/models/ModelsPage.tsx @@ -64,8 +64,16 @@ export default function ModelsPage(): JSX.Element { } useEffect(() => { - loadExtensions().then(() => { + loadExtensions().then(async () => { const exts = useExtensionsStore.getState().modelExtensions + const active = await window.electron.model.activeDownloads() + if (active.length > 0) { + setDownloading((prev) => { + const next = { ...prev } + for (const { modelId, ...progress } of active) if (!next[modelId]) next[modelId] = progress + return next + }) + } refreshInstalledIds(exts) }) window.electron.model.onProgress(({ modelId: id, percent, file, fileIndex, totalFiles }) => { diff --git a/src/areas/models/components/ExtensionCard.tsx b/src/areas/models/components/ExtensionCard.tsx index c057156..0686c7c 100644 --- a/src/areas/models/components/ExtensionCard.tsx +++ b/src/areas/models/components/ExtensionCard.tsx @@ -172,6 +172,23 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab Ready + ) : isDownloading ? ( +
+
+ + {dlFile ?? 'Downloading…'} + + + {dlFileIndex && dlTotalFiles ? `${dlFileIndex}/${dlTotalFiles} · ${dlPercent}%` : `${dlPercent}%`} + +
+
+
+
+
) : installed ? (
@@ -192,23 +209,6 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab )}
- ) : isDownloading ? ( -
-
- - {dlFile ?? 'Downloading…'} - - - {dlFileIndex && dlTotalFiles ? `${dlFileIndex}/${dlTotalFiles} · ${dlPercent}%` : `${dlPercent}%`} - -
-
-
-
-
) : (