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
49 changes: 31 additions & 18 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,19 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe

// Setup handlers — skipped in dev (uses .venv instead of python-embed)
ipcMain.handle('setup:check', async () => {
if (!app.isPackaged) return { needed: false }
const defaultDataDir = join(app.getPath('documents'), 'LocalMeshy')
if (!app.isPackaged) return { needed: false, defaultDataDir }
const userData = app.getPath('userData')
return { needed: checkSetupNeeded(userData) }
return { needed: checkSetupNeeded(userData), defaultDataDir }
})

ipcMain.handle('setup:saveDataDir', async (_event, { baseDir }: { baseDir: string }) => {
const userData = app.getPath('userData')
setSettings(userData, {
modelsDir: join(baseDir, 'models'),
workspaceDir: join(baseDir, 'workspace'),
extensionsDir: join(baseDir, 'extensions'),
})
})

ipcMain.handle('setup:run', async () => {
Expand Down Expand Up @@ -97,7 +107,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
})

ipcMain.handle('model:delete', async (_, modelId: string): Promise<{ success: boolean; error?: string }> => {
const modelDir = join(app.getPath('userData'), 'models', modelId)
const modelDir = join(getSettings(app.getPath('userData')).modelsDir, modelId)
try {
await axios.post(`${API_BASE_URL}/model/unload/${encodeURIComponent(modelId)}`, {}, { timeout: 5000 })
} catch {
Expand All @@ -119,12 +129,12 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe

// Model management
ipcMain.handle('model:listDownloaded', () => {
const modelsDir = join(app.getPath('userData'), 'models')
const modelsDir = getSettings(app.getPath('userData')).modelsDir
return listDownloadedModels(modelsDir)
})

ipcMain.handle('model:isDownloaded', (_, modelId: string): boolean => {
const modelsDir = join(app.getPath('userData'), 'models')
const modelsDir = getSettings(app.getPath('userData')).modelsDir
return isModelDownloaded(modelsDir, modelId)
})

Expand Down Expand Up @@ -170,7 +180,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
ipcMain.handle('app:info', () => ({
version: app.getVersion(),
userData: app.getPath('userData'),
modelsDir: join(app.getPath('userData'), 'models'),
modelsDir: getSettings(app.getPath('userData')).modelsDir,
apiUrl: API_BASE_URL
}))

Expand All @@ -179,7 +189,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
return getSettings(app.getPath('userData'))
})

ipcMain.handle('settings:set', (_event, patch: { modelsDir?: string; workspaceDir?: string }) => {
ipcMain.handle('settings:set', (_event, patch: { modelsDir?: string; workspaceDir?: string; extensionsDir?: string }) => {
return setSettings(app.getPath('userData'), patch)
})

Expand Down Expand Up @@ -211,7 +221,8 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
})

// Workspace filesystem-based persistence
const workspacePath = (...parts: string[]) => join(app.getPath('userData'), 'workspace', ...parts)
const workspacePath = (...parts: string[]) =>
join(getSettings(app.getPath('userData')).workspaceDir, ...parts)

ipcMain.handle('workspace:listCollections', async () => {
const base = workspacePath()
Expand Down Expand Up @@ -276,10 +287,11 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe

ipcMain.handle('fs:deleteDirectory', async (_, dirPath: string) => {
const userData = app.getPath('userData')
const settings = getSettings(userData)
const allowedRoots = [
join(userData, 'models'),
join(userData, 'workspace'),
join(userData, 'extensions'),
settings.modelsDir,
settings.workspaceDir,
settings.extensionsDir,
join(userData, 'gen-cache'),
]
const resolved = join(dirPath)
Expand Down Expand Up @@ -358,9 +370,9 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
}
}

// Extensions — reads %appdata%/Modly/extensions
// Extensions — reads configured extensions directory
ipcMain.handle('extensions:list', async () => {
const extensionsDir = join(app.getPath('userData'), 'extensions')
const extensionsDir = getSettings(app.getPath('userData')).extensionsDir
try {
if (!existsSync(extensionsDir)) return []
const [entries, trustedRepos] = await Promise.all([
Expand Down Expand Up @@ -449,7 +461,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')

// 5. Copy to extensions directory (overwrite if already present)
const extensionsDir = join(app.getPath('userData'), 'extensions')
const extensionsDir = getSettings(app.getPath('userData')).extensionsDir
await mkdir(extensionsDir, { recursive: true })
const destDir = join(extensionsDir, manifest.id)

Expand Down Expand Up @@ -481,7 +493,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe

// Uninstall an extension — deletes its directory and reloads Python
ipcMain.handle('extensions:uninstall', async (_, extensionId: string) => {
const extensionsDir = join(app.getPath('userData'), 'extensions')
const extensionsDir = getSettings(app.getPath('userData')).extensionsDir
const extPath = join(extensionsDir, extensionId)
try {
await rmAsync(extPath, { recursive: true, force: true })
Expand All @@ -506,11 +518,12 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
})

// Update FastAPI paths at runtime (without restarting)
ipcMain.handle('api:updatePaths', async (_event, patch: { modelsDir?: string; workspaceDir?: string }) => {
ipcMain.handle('api:updatePaths', async (_event, patch: { modelsDir?: string; workspaceDir?: string; extensionsDir?: string }) => {
try {
await axios.post(`${API_BASE_URL}/settings/paths`, {
models_dir: patch.modelsDir,
workspace_dir: patch.workspaceDir,
models_dir: patch.modelsDir,
workspace_dir: patch.workspaceDir,
extensions_dir: patch.extensionsDir,
})
return { success: true }
} catch (err) {
Expand Down
6 changes: 3 additions & 3 deletions electron/main/python-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,9 @@ export class PythonBridge {
}

private resolveExtensionsDir(): string {
const dir = join(app.getPath('userData'), 'extensions')
mkdirSync(dir, { recursive: true })
return dir
const s = getSettings(app.getPath('userData'))
mkdirSync(s.extensionsDir, { recursive: true })
return s.extensionsDir
}

}
10 changes: 6 additions & 4 deletions electron/main/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'

export interface AppSettings {
modelsDir: string
workspaceDir: string
modelsDir: string
workspaceDir: string
extensionsDir: string
}

function settingsPath(userData: string): string {
Expand All @@ -12,8 +13,9 @@ function settingsPath(userData: string): string {

export function getSettings(userData: string): AppSettings {
const defaults: AppSettings = {
modelsDir: join(userData, 'models'),
workspaceDir: join(userData, 'workspace'),
modelsDir: join(userData, 'models'),
workspaceDir: join(userData, 'workspace'),
extensionsDir: join(userData, 'extensions'),
}

const file = settingsPath(userData)
Expand Down
12 changes: 7 additions & 5 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ contextBridge.exposeInMainWorld('electron', {

// Settings
settings: {
get: (): Promise<{ modelsDir: string; workspaceDir: string }> =>
get: (): Promise<{ modelsDir: string; workspaceDir: string; extensionsDir: string }> =>
ipcRenderer.invoke('settings:get'),
set: (patch: { modelsDir?: string; workspaceDir?: string }): Promise<{ modelsDir: string; workspaceDir: string }> =>
set: (patch: { modelsDir?: string; workspaceDir?: string; extensionsDir?: string }): Promise<{ modelsDir: string; workspaceDir: string; extensionsDir: string }> =>
ipcRenderer.invoke('settings:set', patch),
},

Expand All @@ -59,7 +59,7 @@ contextBridge.exposeInMainWorld('electron', {

// API helpers (calls FastAPI from the main process)
api: {
updatePaths: (patch: { modelsDir?: string; workspaceDir?: string }): Promise<{ success: boolean; error?: string }> =>
updatePaths: (patch: { modelsDir?: string; workspaceDir?: string; extensionsDir?: string }): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('api:updatePaths', patch),
},

Expand Down Expand Up @@ -144,10 +144,12 @@ contextBridge.exposeInMainWorld('electron', {

// First-run setup
setup: {
check: (): Promise<{ needed: boolean }> =>
check: (): Promise<{ needed: boolean; defaultDataDir: string }> =>
ipcRenderer.invoke('setup:check'),
run: (): Promise<{ success: boolean; error?: string }> =>
run: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('setup:run'),
saveDataDir: (baseDir: string): Promise<void> =>
ipcRenderer.invoke('setup:saveDataDir', { baseDir }),
onProgress: (cb: (data: { step: string; percent: number; currentPackage?: string }) => void) => {
ipcRenderer.on('setup:progress', (_e, data) => cb(data))
},
Expand Down
64 changes: 57 additions & 7 deletions src/areas/setup/FirstRunSetup.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useAppStore, SetupProgress } from '@shared/stores/appStore'

// ─── Logo (shared) ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -47,6 +47,59 @@ function CheckingPanel(): JSX.Element {
)
}

function ChoosePathPanel({
defaultPath,
onConfirm,
}: {
defaultPath: string
onConfirm: (path: string) => void
}): JSX.Element {
const [selectedPath, setSelectedPath] = useState(defaultPath || '')

// Sync if defaultPath arrives after mount (async IPC)
useEffect(() => {
if (defaultPath && !selectedPath) setSelectedPath(defaultPath)
}, [defaultPath])

async function handleBrowse() {
const picked = await window.electron.fs.selectDirectory()
if (picked) setSelectedPath(picked)
}

return (
<div className="w-80 bg-surface-300 rounded-xl p-6">
<p className="text-sm font-medium text-zinc-100 mb-1">Choose a data folder</p>
<p className="text-xs text-zinc-500 mb-4">
Models can be several GB each. Choose a folder on a drive with plenty of free space —
preferably not your system drive (C:).
</p>

{/* Path display */}
<div className="flex items-center gap-2 mb-4">
<div className="flex-1 min-w-0 bg-zinc-900 rounded-lg px-3 py-2">
<p className="text-xs font-mono text-zinc-400 truncate" title={selectedPath}>
{selectedPath || 'No folder selected'}
</p>
</div>
<button
onClick={handleBrowse}
className="shrink-0 px-3 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-xs font-medium transition-colors"
>
Browse…
</button>
</div>

<button
onClick={() => onConfirm(selectedPath)}
disabled={!selectedPath}
className="w-full py-2 bg-accent hover:bg-accent-dark disabled:opacity-40 disabled:cursor-not-allowed rounded-lg text-sm font-medium text-white transition-colors"
>
Continue
</button>
</div>
)
}

const STEPS = [
{ key: 'enabling-site', label: 'Preparing Python' },
{ key: 'pip', label: 'Installing pip' },
Expand Down Expand Up @@ -135,21 +188,18 @@ function ErrorPanel({ message }: { message: string | null }): JSX.Element {
// ─── Main component ─────────────────────────────────────────────────────────

export default function FirstRunSetup(): JSX.Element {
const { setupStatus, setupProgress, setupError, runSetup, backendStatus, backendError } =
const { setupStatus, setupProgress, setupError, saveDataDir, defaultDataDir, backendStatus, backendError } =
useAppStore()

// Auto-trigger installation when setup is needed
useEffect(() => {
if (setupStatus === 'needed') runSetup()
}, [setupStatus])

const renderPanel = () => {
switch (setupStatus) {
case 'idle':
case 'checking':
return <CheckingPanel />

case 'needed':
return <ChoosePathPanel defaultPath={defaultDataDir} onConfirm={saveDataDir} />

case 'installing':
return <InstallingPanel progress={setupProgress} />

Expand Down
22 changes: 15 additions & 7 deletions src/shared/stores/appStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,13 @@ interface AppState {
toggleWorkspacePanel: () => void

// Setup
setupStatus: SetupStatus
setupProgress: SetupProgress | null
setupError: string | null
checkSetup: () => Promise<void>
runSetup: () => Promise<void>
setupStatus: SetupStatus
setupProgress: SetupProgress | null
setupError: string | null
defaultDataDir: string
checkSetup: () => Promise<void>
runSetup: () => Promise<void>
saveDataDir: (baseDir: string) => Promise<void>

// Actions
initApp: () => Promise<void>
Expand All @@ -104,11 +106,17 @@ export const useAppStore = create<AppState>()(
setupStatus: 'idle',
setupProgress: null,
setupError: null,
defaultDataDir: '',

checkSetup: async () => {
set({ setupStatus: 'checking' })
const { needed } = await window.electron.setup.check()
set({ setupStatus: needed ? 'needed' : 'done' })
const { needed, defaultDataDir } = await window.electron.setup.check()
set({ setupStatus: needed ? 'needed' : 'done', defaultDataDir })
},

saveDataDir: async (baseDir: string) => {
await window.electron.setup.saveDataDir(baseDir)
get().runSetup()
},

runSetup: async () => {
Expand Down
23 changes: 12 additions & 11 deletions src/shared/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ declare global {
deleteDirectory: (dirPath: string) => Promise<{ success: boolean; error?: string }>
}
settings: {
get: () => Promise<{ modelsDir: string; workspaceDir: string }>
set: (patch: { modelsDir?: string; workspaceDir?: string }) => Promise<{ modelsDir: string; workspaceDir: string }>
get: () => Promise<{ modelsDir: string; workspaceDir: string; extensionsDir: string }>
set: (patch: { modelsDir?: string; workspaceDir?: string; extensionsDir?: string }) => Promise<{ modelsDir: string; workspaceDir: string; extensionsDir: string }>
}
cache: {
clear: () => Promise<{ success: boolean; error?: string }>
}
api: {
updatePaths: (patch: { modelsDir?: string; workspaceDir?: string }) => Promise<{ success: boolean; error?: string }>
updatePaths: (patch: { modelsDir?: string; workspaceDir?: string; extensionsDir?: string }) => Promise<{ success: boolean; error?: string }>
}
model: {
export: (args: { outputUrl: string; format: string }) => Promise<{ success: boolean; error?: string }>
Expand Down Expand Up @@ -67,14 +67,15 @@ declare global {
deleteJob: (collection: string, filename: string) => Promise<void>
}
setup: {
check: () => Promise<{ needed: boolean }>
run: () => Promise<{ success: boolean; error?: string }>
onProgress: (cb: (data: { step: string; percent: number; currentPackage?: string }) => void) => void
offProgress: () => void
onComplete: (cb: () => void) => void
offComplete: () => void
onError: (cb: (data: { message: string }) => void) => void
offError: () => void
check: () => Promise<{ needed: boolean; defaultDataDir: string }>
run: () => Promise<{ success: boolean; error?: string }>
saveDataDir: (baseDir: string) => Promise<void>
onProgress: (cb: (data: { step: string; percent: number; currentPackage?: string }) => void) => void
offProgress: () => void
onComplete: (cb: () => void) => void
offComplete: () => void
onError: (cb: (data: { message: string }) => void) => void
offError: () => void
}
extensions: {
list: () => Promise<Array<{
Expand Down