Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
1e20ba7
dump version v0.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/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
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
16 changes: 9 additions & 7 deletions src/areas/models/components/ExtensionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ interface Props {
installedIds: string[]
downloading: Record<string, number>
loadError?: string
disabled?: boolean
onInstall: (variant: ExtensionVariant) => void
onUninstall: (extId: string) => void
}

export function ExtensionCard({ ext, installedIds, downloading, loadError, onInstall, onUninstall }: Props): JSX.Element {
export function ExtensionCard({ ext, installedIds, downloading, loadError, disabled, onInstall, onUninstall }: Props): JSX.Element {
return (
<div className="flex flex-col gap-2.5 px-3.5 py-4 rounded-2xl border border-zinc-800 bg-zinc-900/60 hover:border-zinc-700 transition-all">

Expand Down Expand Up @@ -59,8 +60,9 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, onIns
{/* Uninstall extension button */}
<button
onClick={() => onUninstall(ext.id)}
title="Uninstall extension"
className="shrink-0 p-1 rounded text-zinc-700 hover:text-red-400 hover:bg-red-950/30 transition-colors"
disabled={disabled}
title={disabled ? 'Cannot uninstall while an install is in progress' : 'Uninstall extension'}
className="shrink-0 p-1 rounded text-zinc-700 hover:text-red-400 hover:bg-red-950/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:text-zinc-700 disabled:hover:bg-transparent"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="3 6 5 6 21 6"/>
Expand Down Expand Up @@ -136,11 +138,11 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, onIns
</div>
) : (
<button
onClick={() => ext.trusted && onInstall(variant)}
disabled={!ext.trusted}
title={!ext.trusted ? 'Unverified source — installation blocked' : `Install ${variant.name}`}
onClick={() => ext.trusted && !disabled && onInstall(variant)}
disabled={!ext.trusted || disabled}
title={!ext.trusted ? 'Unverified source — installation blocked' : disabled ? 'A download is already in progress' : `Install ${variant.name}`}
className={`w-full flex items-center justify-center gap-1 px-2 py-1 rounded-lg border text-[10px] font-semibold transition-all ${
ext.trusted
ext.trusted && !disabled
? 'bg-accent/15 border-accent/25 text-accent-light hover:bg-accent/25 hover:border-accent/40 cursor-pointer'
: 'bg-zinc-800/40 border-zinc-700/30 text-zinc-600 cursor-not-allowed'
}`}
Expand Down
8 changes: 5 additions & 3 deletions src/areas/models/components/ModelCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ interface Props {
model: LocalModel
onDelete: () => void
onGenerate: () => void
disabled?: boolean
}

export function ModelCard({ model, onDelete, onGenerate }: Props): JSX.Element {
export function ModelCard({ model, onDelete, onGenerate, disabled }: Props): JSX.Element {
return (
<div className="flex flex-col gap-2 px-3.5 py-4 rounded-2xl border transition-all min-h-[110px] bg-zinc-900/60 border-zinc-800 hover:border-zinc-700">
{/* Name */}
Expand All @@ -34,8 +35,9 @@ export function ModelCard({ model, onDelete, onGenerate }: Props): JSX.Element {
</button>
<button
onClick={onDelete}
title="Uninstall"
className="flex items-center justify-center w-7 h-7 rounded-lg bg-red-950/40 border border-red-900/30 text-red-500 hover:bg-red-900/50 hover:text-red-300 hover:border-red-700/50 transition-all shrink-0"
disabled={disabled}
title={disabled ? 'Cannot delete while an install is in progress' : 'Uninstall'}
className="flex items-center justify-center w-7 h-7 rounded-lg bg-red-950/40 border border-red-900/30 text-red-500 hover:bg-red-900/50 hover:text-red-300 hover:border-red-700/50 transition-all shrink-0 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-red-950/40 disabled:hover:text-red-500 disabled:hover:border-red-900/30"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<polyline points="3 6 5 6 21 6" />
Expand Down
Loading
Loading