Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def filter(self, record):

app = FastAPI(
title="Modly API",
version="0.3.4",
version="0.3.5",
lifespan=lifespan,
)

Expand Down
1 change: 1 addition & 0 deletions api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ huggingface_hub>=0.27.0
hf_xet>=0.1.0

cryptography>=42.0.0
certifi>=2024.0.0
8 changes: 8 additions & 0 deletions api/services/extension_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -71,6 +71,7 @@ function runExtensionSetup(
): Promise<void> {
return new Promise((resolve, reject) => {
const userData = app.getPath('userData')
ensureSslPatch(userData)
const pythonExe = getVenvPythonExe(userData)
const setupPy = join(extDir, 'setup.py')

Expand Down Expand Up @@ -98,6 +99,7 @@ function runExtensionSetup(
}

export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGetter): void {
const activeDownloads = new Map<string, { percent: number; file?: string; fileIndex?: number; totalFiles?: number }>()
// 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'))
Expand Down Expand Up @@ -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)
}
})

Expand Down
69 changes: 67 additions & 2 deletions electron/main/python-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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))
}
})
})
Expand Down
1 change: 1 addition & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
},
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 9 additions & 1 deletion src/areas/models/ModelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
34 changes: 17 additions & 17 deletions src/areas/models/components/ExtensionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,23 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab
</svg>
<span className="text-[10px] font-semibold text-emerald-400">Ready</span>
</div>
) : isDownloading ? (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-500 truncate max-w-[100px]" title={dlFile}>
{dlFile ?? 'Downloading…'}
</span>
<span className="text-[10px] font-mono text-zinc-400 shrink-0 ml-1">
{dlFileIndex && dlTotalFiles ? `${dlFileIndex}/${dlTotalFiles} · ${dlPercent}%` : `${dlPercent}%`}
</span>
</div>
<div className="h-1 rounded-full bg-zinc-800 overflow-hidden">
<div
className="h-full rounded-full bg-accent transition-all duration-300"
style={{ width: `${dlPercent}%` }}
/>
</div>
</div>
) : installed ? (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-emerald-950/40 border border-emerald-800/30">
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-emerald-400 shrink-0">
Expand All @@ -192,23 +209,6 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab
</button>
)}
</div>
) : isDownloading ? (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-500 truncate max-w-[100px]" title={dlFile}>
{dlFile ?? 'Downloading…'}
</span>
<span className="text-[10px] font-mono text-zinc-400 shrink-0 ml-1">
{dlFileIndex && dlTotalFiles ? `${dlFileIndex}/${dlTotalFiles} · ${dlPercent}%` : `${dlPercent}%`}
</span>
</div>
<div className="h-1 rounded-full bg-zinc-800 overflow-hidden">
<div
className="h-full rounded-full bg-accent transition-all duration-300"
style={{ width: `${dlPercent}%` }}
/>
</div>
</div>
) : (
<button
onClick={() => !disabled && onInstall(node, fullId)}
Expand Down
21 changes: 20 additions & 1 deletion src/areas/setup/FirstRunSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,29 @@ function ApplyingUpdatePanel({ version }: { version: string }): JSX.Element {
}

function ErrorPanel({ message }: { message: string | null }): JSX.Element {
const lines = (message ?? 'Check the console for details').split('\n')
const isAntivirusHint = message?.includes('antivirus') ?? false

return (
<div className="w-80 bg-surface-300 rounded-xl p-6">
<p className="text-sm font-medium text-zinc-100">Something went wrong</p>
<p className="text-xs text-zinc-500 mt-1">{message ?? 'Check the console for details'}</p>
<div className="mt-2 space-y-1 max-h-48 overflow-y-auto">
{lines.map((line, i) =>
line === '' ? (
<div key={i} className="h-1" />
) : (
<p key={i} className="text-xs text-zinc-500 font-mono break-all">{line}</p>
)
)}
</div>
{isAntivirusHint && (
<div className="mt-3 p-3 bg-amber-950/40 border border-amber-700/40 rounded-lg">
<p className="text-xs text-amber-400 font-medium">Antivirus detected</p>
<p className="text-xs text-amber-500/80 mt-0.5">
Add the app folder to your antivirus exclusions, then click Retry.
</p>
</div>
)}
<button
onClick={() => window.location.reload()}
className="mt-4 w-full py-2 bg-accent hover:bg-accent-dark rounded-lg text-sm font-medium text-white transition-colors"
Expand Down
Loading