Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c2a701f
fix: re-run python setup after app update
Mar 18, 2026
7d19d96
Merge pull request #8 from lightningpixel/fix/python-setup-rerun-on-u…
lightningpixel Mar 18, 2026
90d0b8a
fix(settings): dynamic version and about page links
Mar 19, 2026
6cf6219
Merge pull request #10 from lightningpixel/fix/fix-about-setting-page
lightningpixel Mar 19, 2026
f9c4f21
feat(update): add update available modal with version check
Mar 19, 2026
fe37430
fix(update): restore version check condition
Mar 19, 2026
8fe5d1f
Merge pull request #11 from lightningpixel/feature/add-updater-notifi…
lightningpixel Mar 19, 2026
63c960b
fix single model download
Mar 19, 2026
a8c2a2a
Merge pull request #13 from lightningpixel/fix/single-model-download
lightningpixel Mar 19, 2026
75f1e40
fix(model): default model ID was not empty
Mar 19, 2026
aa2ae01
Merge pull request #14 from lightningpixel/fix/default-model-id
lightningpixel Mar 19, 2026
f8eba07
tech(logger): improve logging system add more details
Mar 19, 2026
834bdba
Merge pull request #15 from lightningpixel/tech/improve-logging-system
lightningpixel Mar 19, 2026
b39e060
fix(model): unload model from memory before deletion
Mar 19, 2026
af6f9a1
Merge pull request #16 from lightningpixel/fix/model-delete-file-lock
lightningpixel Mar 19, 2026
ccd8efe
update check csp
Mar 19, 2026
c888d39
Merge pull request #17 from lightningpixel/fix/update-check
lightningpixel Mar 19, 2026
6f1fefd
fix(setup): use requirements.txt hash trigger reinstall
Mar 19, 2026
ea23d38
Merge pull request #18 from lightningpixel/fix/setup-requirements-hash
lightningpixel Mar 19, 2026
1e7a1c5
fix(generate): handle missing file path on drag and drop
Mar 19, 2026
50184a3
Merge pull request #21 from lightningpixel/fix/drag-drop-image-detection
lightningpixel Mar 19, 2026
0a40d99
dump version 0.1.3
Mar 19, 2026
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 @@ -22,7 +22,7 @@ async def lifespan(app: FastAPI):

app = FastAPI(
title="Modly API",
version="0.1.0",
version="0.1.3",
lifespan=lifespan,
)

Expand Down
11 changes: 11 additions & 0 deletions api/routers/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ async def switch_model(model_id: str):
raise HTTPException(400, str(e))


@router.post("/unload/{model_id}")
async def unload_model(model_id: str):
"""Unloads a model from memory so its files can be safely deleted."""
try:
gen = generator_registry.get_generator(model_id)
gen.unload()
return {"unloaded": True}
except ValueError:
return {"unloaded": True} # already not loaded, that's fine


@router.get("/hf-download")
async def hf_download(repo_id: str, model_id: str):
"""
Expand Down
13 changes: 9 additions & 4 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,18 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
return result.canceled ? null : result.filePath
})

ipcMain.handle('model:delete', async (_, modelId: string): Promise<boolean> => {
ipcMain.handle('model:delete', async (_, modelId: string): Promise<{ success: boolean; error?: string }> => {
const modelDir = join(app.getPath('userData'), 'models', modelId)
try {
await rmAsync(modelDir, { recursive: true, force: true })
return true
await axios.post(`${API_BASE_URL}/model/unload/${encodeURIComponent(modelId)}`, {}, { timeout: 5000 })
} catch {
return false
// unload is best-effort — proceed with deletion anyway
}
try {
await rmAsync(modelDir, { recursive: true, force: true })
return { success: true }
} catch (err) {
return { success: false, error: String(err) }
}
})

Expand Down
26 changes: 17 additions & 9 deletions electron/main/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { join } from 'path'

const MAX_SIZE_BYTES = 5 * 1024 * 1024 // 5 MB

function getLogPath(): string {
function getLogsDir(): string {
const logsDir = join(app.getPath('userData'), 'logs')
mkdirSync(logsDir, { recursive: true })
return join(logsDir, 'modly.log')
return logsDir
}

function rotate(logPath: string): void {
Expand All @@ -18,18 +18,26 @@ function rotate(logPath: string): void {
} catch {}
}

function write(level: string, message: string): void {
function writeTo(filename: string, line: string): void {
try {
const logPath = getLogPath()
const logPath = join(getLogsDir(), filename)
rotate(logPath)
const line = `[${new Date().toISOString()}] [${level}] ${message}\n`
appendFileSync(logPath, line, 'utf-8')
} catch {}
}

function line(level: string, message: string): string {
return `[${new Date().toISOString()}] [${level}] ${message}\n`
}

export const logger = {
info: (msg: string) => { console.log(msg); write('INFO', msg) },
warn: (msg: string) => { console.warn(msg); write('WARN', msg) },
error: (msg: string) => { console.error(msg); write('ERROR', msg) },
python:(msg: string) => { write('PYTHON', msg) },
info: (msg: string) => { console.log(msg); writeTo('modly.log', line('INFO', msg)) },
warn: (msg: string) => { console.warn(msg); writeTo('modly.log', line('WARN', msg)) },
error: (msg: string) => { console.error(msg); writeTo('modly.log', line('ERROR', msg)); writeTo('errors.log', line('ERROR', msg)) },
python: (msg: string) => {
writeTo('runtime.log', line('RUNTIME', msg))
if (/error|exception|traceback|critical/i.test(msg)) {
writeTo('errors.log', line('RUNTIME', msg))
}
},
}
2 changes: 1 addition & 1 deletion electron/main/python-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class PythonBridge {
MODELS_DIR: this.resolveModelsDir(),
WORKSPACE_DIR: this.resolveWorkspaceDir(),
EXTENSIONS_DIR: this.resolveExtensionsDir(),
SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] ?? 'sf3d',
SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] ?? '',
}
})

Expand Down
30 changes: 25 additions & 5 deletions electron/main/python-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,29 @@ import { BrowserWindow, app } from 'electron'
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs'
import { join } from 'path'
import { spawn, execSync } from 'child_process'
import { createHash } from 'crypto'

const SETUP_VERSION = 1
const SETUP_VERSION = 2
const TOTAL_PACKAGES = 20

interface SetupJson {
version: number
requirementsHash?: string
}

function getRequirementsPath(): string {
return app.isPackaged
? join(process.resourcesPath, 'api', 'requirements.txt')
: join(app.getAppPath(), 'api', 'requirements.txt')
}

function hashRequirements(): string {
try {
const content = readFileSync(getRequirementsPath(), 'utf-8')
return createHash('sha256').update(content).digest('hex')
} catch {
return ''
}
}

// ─── Public helpers ──────────────────────────────────────────────────────────
Expand All @@ -18,6 +35,7 @@ export function checkSetupNeeded(userData: string): boolean {
try {
const data = JSON.parse(readFileSync(jsonPath, 'utf-8')) as SetupJson
if (data.version < SETUP_VERSION) return true
if (data.requirementsHash !== hashRequirements()) return true
} catch {
return true
}
Expand All @@ -30,7 +48,11 @@ export function checkSetupNeeded(userData: string): boolean {

export function markSetupDone(userData: string): void {
const jsonPath = join(userData, 'python_setup.json')
writeFileSync(jsonPath, JSON.stringify({ version: SETUP_VERSION }), 'utf-8')
writeFileSync(
jsonPath,
JSON.stringify({ version: SETUP_VERSION, requirementsHash: hashRequirements() }),
'utf-8'
)
}

/** Path to the venv Python executable created during setup (packaged Unix). */
Expand Down Expand Up @@ -181,9 +203,7 @@ function createVenv(python3: string, venvDir: string, win: BrowserWindow): Promi

export async function runFullSetup(win: BrowserWindow, userData: string): Promise<void> {
try {
const requirementsPath = app.isPackaged
? join(process.resourcesPath, 'api', 'requirements.txt')
: join(app.getAppPath(), 'api', 'requirements.txt')
const requirementsPath = getRequirementsPath()

if (process.platform === 'win32') {
// Windows: use embedded Python bundled with the app
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.1.2",
"version": "0.1.3",
"description": "Local AI-powered 3D mesh generation from images",
"main": "./out/main/index.js",
"author": "Modly",
Expand Down
42 changes: 40 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useAppStore } from '@shared/stores/appStore'
import FirstRunSetup from '@areas/setup/FirstRunSetup'
import MainLayout from '@shared/components/layout/MainLayout'
import { UpdateModal } from '@shared/components/ui/UpdateModal'

function compareSemver(a: string, b: string): number {
const pa = a.replace(/^v/, '').split('.').map(Number)
const pb = b.replace(/^v/, '').split('.').map(Number)
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1
}
return 0
}

export default function App(): JSX.Element {
const { checkSetup, setupStatus, initApp, backendStatus } = useAppStore()
const [updateVersion, setUpdateVersion] = useState<string | null>(null)
const [currentVersion, setCurrentVersion] = useState<string>('')

useEffect(() => {
checkSetup()
Expand All @@ -14,6 +27,31 @@ export default function App(): JSX.Element {
if (setupStatus === 'done') initApp()
}, [setupStatus])

if (backendStatus === 'ready') return <MainLayout />
useEffect(() => {
if (backendStatus !== 'ready') return
window.electron.app.info().then(({ version }) => {
setCurrentVersion(version)
fetch('https://api.github.com/repos/lightningpixel/modly/releases/latest')
.then((r) => r.json())
.then((data) => {
const latest = data?.tag_name as string | undefined
if (latest && compareSemver(latest, version) > 0) setUpdateVersion(latest)
})
.catch(() => {})
})
}, [backendStatus])

if (backendStatus === 'ready') return (
<>
<MainLayout />
{updateVersion && (
<UpdateModal
currentVersion={currentVersion}
latestVersion={updateVersion}
onDismiss={() => setUpdateVersion(null)}
/>
)}
</>
)
return <FirstRunSetup />
}
2 changes: 1 addition & 1 deletion src/areas/generate/components/GenerationHUD.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export default function GenerationHUD(): JSX.Element | null {
<div className="flex items-center gap-2.5">
<div className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse" />
<span className="text-sm font-medium text-zinc-200">
{step ?? (status === 'uploading' ? 'Uploading image…' : 'Generating 3D mesh…')}
{step ?? (status === 'uploading' ? 'Reading image…' : 'Generating 3D mesh…')}
</span>
</div>
<span className="text-xs tabular-nums text-zinc-500">{formatElapsed(elapsed)}</span>
Expand Down
2 changes: 1 addition & 1 deletion src/areas/generate/components/GenerationOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export default function GenerationOptions(): JSX.Element {
window.electron.model.listDownloaded()
.then((list) => {
setModels(list)
if (list.length > 0 && !list.find((m) => m.id === generationOptions.modelId)) {
if (list.length > 0 && (!generationOptions.modelId || !list.find((m) => m.id === generationOptions.modelId))) {
setGenerationOptions({ modelId: list[0].id })
}
})
Expand Down
2 changes: 1 addition & 1 deletion src/areas/generate/components/GenerationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export default function GenerationPanel(): JSX.Element {
const { status: jobStatus, progress, step, error } = currentJob

const statusLabel: Record<string, string> = {
uploading: 'Uploading image…',
uploading: 'Reading image…',
generating: step ?? 'Generating 3D mesh…',
done: 'Done!',
error: 'Generation failed',
Expand Down
28 changes: 22 additions & 6 deletions src/areas/generate/components/ImageUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,48 @@ import { useGeneration } from '@shared/hooks/useGeneration'

export default function ImageUpload(): JSX.Element {
const { currentJob } = useGeneration()
const { setSelectedImagePath, selectedImagePreviewUrl, setSelectedImagePreviewUrl } = useAppStore()
const { setSelectedImagePath, selectedImagePreviewUrl, setSelectedImagePreviewUrl, setSelectedImageData } = useAppStore()
const [isDragging, setIsDragging] = useState(false)

const isGenerating = currentJob?.status === 'uploading' || currentJob?.status === 'generating'

const handleFileSelect = useCallback(async () => {
const path = await window.electron.fs.selectImage()
if (!path) return
setSelectedImageData(null)
setSelectedImagePath(path)

// Read via IPC → blob URL (file:// blocked when served from localhost in dev)
const base64 = await window.electron.fs.readFileBase64(path)
const byteArray = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
const blob = new Blob([byteArray], { type: 'image/png' })
setSelectedImagePreviewUrl(URL.createObjectURL(blob))
}, [setSelectedImagePath, setSelectedImagePreviewUrl])
}, [setSelectedImagePath, setSelectedImagePreviewUrl, setSelectedImageData])

const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (!file || !file.type.startsWith('image/')) return
const url = URL.createObjectURL(file)
setSelectedImagePreviewUrl(url)
setSelectedImagePath((file as File & { path?: string }).path ?? null)
}, [setSelectedImagePath, setSelectedImagePreviewUrl])

setSelectedImagePreviewUrl(URL.createObjectURL(file))

const filePath = (file as File & { path?: string }).path
if (filePath) {
setSelectedImageData(null)
setSelectedImagePath(filePath)
} else {
// file.path unavailable (some Electron configs) — read directly via FileReader
const reader = new FileReader()
reader.onload = (ev) => {
const dataUrl = ev.target?.result as string
const base64 = dataUrl.split(',')[1]
setSelectedImageData(base64)
setSelectedImagePath('__blob__')
}
reader.readAsDataURL(file)
}
}, [setSelectedImagePath, setSelectedImagePreviewUrl, setSelectedImageData])

return (
<div className="flex flex-col p-4 gap-3">
Expand Down
16 changes: 13 additions & 3 deletions src/areas/models/ModelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default function ModelsPage(): JSX.Element {
const [models, setModels] = useState<LocalModel[]>([])
const [downloading, setDownloading] = useState<Record<string, number>>({})
const [deleteTarget, setDeleteTarget] = useState<LocalModel | null>(null)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [uninstallTarget, setUninstallTarget] = useState<string | null>(null)

// GitHub extension install form
Expand Down Expand Up @@ -64,8 +65,13 @@ export default function ModelsPage(): JSX.Element {
}, [installError])

async function handleDelete(model: LocalModel) {
await window.electron.model.delete(model.id)
const result = await window.electron.model.delete(model.id)
if (!result.success) {
setDeleteError('Failed to delete the model. Try restarting the app and deleting again.')
return
}
setDeleteTarget(null)
setDeleteError(null)
refresh()
}

Expand Down Expand Up @@ -105,6 +111,8 @@ export default function ModelsPage(): JSX.Element {
installProgress.step !== 'done' &&
installProgress.step !== 'error'

const isBusy = isInstalling || inProgressIds.length > 0

function installProgressLabel(): string {
if (!installProgress) return ''
switch (installProgress.step) {
Expand Down Expand Up @@ -256,6 +264,7 @@ export default function ModelsPage(): JSX.Element {
ext={ext}
installedIds={models.map((m) => m.id)}
downloading={downloading}
disabled={isBusy}
loadError={
loadErrors[ext.id] ??
ext.models.map((v) => loadErrors[v.id]).find(Boolean)
Expand Down Expand Up @@ -311,6 +320,7 @@ export default function ModelsPage(): JSX.Element {
<ModelCard
key={model.id}
model={model}
disabled={isBusy}
onDelete={() => setDeleteTarget(model)}
onGenerate={() => {
setGenerationOptions({ modelId: model.id })
Expand All @@ -327,12 +337,12 @@ export default function ModelsPage(): JSX.Element {
{deleteTarget && (
<ConfirmModal
title={`Uninstall ${formatModelName(deleteTarget.id)}?`}
description="This will permanently delete the model weights from your disk. You can re-download it anytime."
description={deleteError ?? "This will permanently delete the model weights from your disk. You can re-download it anytime."}
confirmLabel="Uninstall"
cancelLabel="Keep"
variant="danger"
onConfirm={() => handleDelete(deleteTarget)}
onCancel={() => setDeleteTarget(null)}
onCancel={() => { setDeleteTarget(null); setDeleteError(null) }}
/>
)}

Expand Down
Loading
Loading