diff --git a/electron/main/updater.ts b/electron/main/updater.ts index c04174b..1efa277 100644 --- a/electron/main/updater.ts +++ b/electron/main/updater.ts @@ -1,45 +1,24 @@ import { app, BrowserWindow } from 'electron' import { autoUpdater } from 'electron-updater' -import { existsSync, rmSync, writeFileSync, readFileSync } from 'fs' -import { join } from 'path' import { logger } from './logger' type WindowGetter = () => BrowserWindow | null -function pendingFilePath(): string { - return join(app.getPath('userData'), '.last-update-installer') -} - -function cleanupLastInstaller(): void { - const marker = pendingFilePath() - if (!existsSync(marker)) return - try { - const installerPath = readFileSync(marker, 'utf-8').trim() - if (existsSync(installerPath)) { - rmSync(installerPath) - logger.info(`[updater] Cleaned up installer: ${installerPath}`) - } - rmSync(marker) - } catch {} -} - export function initAutoUpdater(getWindow: WindowGetter): void { - cleanupLastInstaller() - // eslint-disable-next-line @typescript-eslint/no-explicit-any autoUpdater.logger = logger as any - autoUpdater.autoDownload = false - autoUpdater.autoInstallOnAppQuit = true + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false autoUpdater.on('update-available', (info) => { - const running = app.getVersion() + const running = app.getVersion() const incoming = info.version const [rMaj, rMin] = running.split('.').map(Number) const [iMaj, iMin] = incoming.split('.').map(Number) const isPatch = rMaj === iMaj && rMin === iMin if (isPatch) { - logger.info(`[updater] Patch update ${incoming} available — downloading silently`) + logger.info(`[updater] Patch ${incoming} available — downloading`) autoUpdater.downloadUpdate().catch((err: Error) => { logger.error(`[updater] Download failed: ${err.message}`) }) @@ -50,11 +29,12 @@ export function initAutoUpdater(getWindow: WindowGetter): void { }) autoUpdater.on('update-downloaded', (info) => { - logger.info(`[updater] Patch update ${info.version} downloaded — showing badge`) - try { - writeFileSync(pendingFilePath(), info.downloadedFile, 'utf-8') - } catch {} - getWindow()?.webContents.send('updater:patch-ready', { version: info.version }) + logger.info(`[updater] Patch ${info.version} downloaded — applying now`) + getWindow()?.webContents.send('updater:applying', { version: info.version }) + // Small delay so the renderer can render the "Applying…" panel before quit + setTimeout(() => { + autoUpdater.quitAndInstall(true, true) + }, 800) }) autoUpdater.on('update-not-available', () => { @@ -64,4 +44,14 @@ export function initAutoUpdater(getWindow: WindowGetter): void { autoUpdater.on('error', (err: Error) => { logger.error(`[updater] Error: ${err.message}`) }) + + // Check immediately on startup so it can apply during the setup screen + autoUpdater.checkForUpdates().catch((err: Error) => { + logger.error(`[updater] Initial check failed: ${err.message}`) + }) + + // Re-check every 2 hours for long-running sessions + setInterval(() => { + autoUpdater.checkForUpdates().catch(() => {}) + }, 2 * 60 * 60 * 1000) } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index fac0dd2..69bd94d 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -174,10 +174,10 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.invoke('updater:check'), quitAndInstall: (): Promise => ipcRenderer.invoke('updater:quitAndInstall'), - onPatchReady: (cb: (data: { version: string }) => void) => { - ipcRenderer.on('updater:patch-ready', (_event, data) => cb(data)) + onApplying: (cb: (data: { version: string }) => void) => { + ipcRenderer.on('updater:applying', (_event, data) => cb(data)) }, - offPatchReady: () => ipcRenderer.removeAllListeners('updater:patch-ready'), + offApplying: () => ipcRenderer.removeAllListeners('updater:applying'), onMajorMinorAvailable: (cb: (data: { version: string }) => void) => { ipcRenderer.on('updater:major-minor-available', (_event, data) => cb(data)) }, diff --git a/package.json b/package.json index 77a0a14..addcf47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modly", - "version": "0.2.1", + "version": "0.3.0", "description": "Local AI-powered 3D mesh generation from images", "main": "./out/main/index.js", "author": "Modly", @@ -75,8 +75,7 @@ }, "nsis": { "oneClick": false, - "allowToChangeInstallationDirectory": true, - "disableWebInstaller": true + "allowToChangeInstallationDirectory": true }, "win": { "target": "nsis", diff --git a/src/App.tsx b/src/App.tsx index e115217..82180ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,22 +6,18 @@ import { UpdateModal } from '@shared/components/ui/UpdateModal' import { ErrorModal } from '@shared/components/ui/ErrorModal' export default function App(): JSX.Element { - const { checkSetup, setupStatus, initApp, backendStatus, showError, setPatchUpdateReady } = useAppStore() + const { checkSetup, setupStatus, initApp, backendStatus, showError } = useAppStore() const [updateVersion, setUpdateVersion] = useState(null) const [currentVersion, setCurrentVersion] = useState('') useEffect(() => { checkSetup() window.electron.app.onError((message) => showError(message)) - window.electron.updater.onPatchReady(() => { - setPatchUpdateReady(true) - }) window.electron.updater.onMajorMinorAvailable(({ version }) => { setUpdateVersion(`v${version}`) }) return () => { window.electron.app.offError() - window.electron.updater.offPatchReady() window.electron.updater.offMajorMinorAvailable() } }, []) @@ -32,10 +28,7 @@ export default function App(): JSX.Element { useEffect(() => { if (backendStatus !== 'ready') return - window.electron.app.info().then(({ version }) => { - setCurrentVersion(version) - window.electron.updater.check() - }) + window.electron.app.info().then(({ version }) => setCurrentVersion(version)) }, [backendStatus]) if (backendStatus === 'ready') return ( diff --git a/src/areas/setup/FirstRunSetup.tsx b/src/areas/setup/FirstRunSetup.tsx index 720067e..52a24cd 100644 --- a/src/areas/setup/FirstRunSetup.tsx +++ b/src/areas/setup/FirstRunSetup.tsx @@ -170,6 +170,28 @@ function StartingPanel(): JSX.Element { ) } +function ApplyingUpdatePanel({ version }: { version: string }): JSX.Element { + return ( +
+
+
+ + + + +
+
+

Applying update {version}

+

The app will restart automatically

+
+
+
+
+
+
+ ) +} + function ErrorPanel({ message }: { message: string | null }): JSX.Element { return (
@@ -190,8 +212,15 @@ function ErrorPanel({ message }: { message: string | null }): JSX.Element { export default function FirstRunSetup(): JSX.Element { const { setupStatus, setupProgress, setupError, saveDataDir, defaultDataDir, backendStatus, backendError } = useAppStore() + const [applyingVersion, setApplyingVersion] = useState(null) + + useEffect(() => { + window.electron.updater.onApplying(({ version }) => setApplyingVersion(`v${version}`)) + return () => { window.electron.updater.offApplying() } + }, []) const renderPanel = () => { + if (applyingVersion) return switch (setupStatus) { case 'idle': case 'checking': diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index 3df6af0..ff49d54 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -56,36 +56,87 @@ function newId(): string { return crypto.randomUUID() } function newWorkflow(): Workflow { const now = new Date().toISOString() - const id = newId() + return { id: newId(), name: 'New Workflow', description: '', nodes: [], edges: [], createdAt: now, updatedAt: now } +} + +function newWorkflowFromTemplate(): Workflow { + const now = new Date().toISOString() + const imageNodeId = newId() + const outputNodeId = newId() return { - id, + id: newId(), name: 'New Workflow', description: '', - nodes: [], - edges: [], + nodes: [ + { id: imageNodeId, type: 'imageNode', position: { x: 150, y: 180 }, data: { enabled: true, params: {}, showInGenerate: true } }, + { id: outputNodeId, type: 'outputNode', position: { x: 500, y: 180 }, data: { enabled: true, params: {} } }, + ], + edges: [ + { id: newId(), source: imageNodeId, target: outputNodeId, type: 'workflowEdge' }, + ], createdAt: now, updatedAt: now, } } -// ─── Workflow card (sidebar) ────────────────────────────────────────────────── +// ─── New workflow modal ─────────────────────────────────────────────────────── -function WorkflowCard({ workflow, active, onClick }: { workflow: Workflow; active: boolean; onClick: () => void }) { - const extCount = workflow.nodes.filter((n) => n.type === 'extensionNode').length +function NewWorkflowModal({ onBlank, onTemplate, onClose }: { + onBlank: () => void + onTemplate: () => void + onClose: () => void +}) { return ( - + + {/* Starter template */} + +
- + ) } @@ -537,7 +588,7 @@ function NodePalette({ // ─── Workflow canvas (inner, requires ReactFlowProvider) ────────────────────── function WorkflowCanvasInner({ - workflow, allExtensions, onSave, onDelete, onExport, panelOpen, onTogglePanel, onRunInGenerate, + workflow, allExtensions, onSave, onDelete, onExport, panelOpen, onTogglePanel, onRunInGenerate, onNew, onImport, }: { workflow: Workflow allExtensions: WorkflowExtension[] @@ -547,13 +598,14 @@ function WorkflowCanvasInner({ panelOpen: boolean onTogglePanel: () => void onRunInGenerate: (wf: Workflow) => void + onNew: () => void + onImport: () => void }) { const { screenToFlowPosition, updateNodeData } = useReactFlow() const [nodes, setNodes, onNodesChange] = useNodesState(workflow.nodes as Node[]) const [edges, setEdges, onEdgesChange] = useEdgesState(workflow.edges as Edge[]) const [name, setName] = useState(workflow.name) - const [editingName, setEditingName] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false) // Pending connection: set when user drags a handle and releases on empty canvas @@ -699,24 +751,48 @@ function WorkflowCanvasInner({ )} {/* Header toolbar */} -
- {editingName ? ( - setName(e.target.value)} - onBlur={() => setEditingName(false)} - onKeyDown={(e) => e.key === 'Enter' && setEditingName(false)} - className="flex-1 bg-transparent border-b border-accent/60 text-sm font-semibold text-zinc-200 focus:outline-none pb-0.5" - /> - ) : ( - - )} +
+ + {/* New */} + + + {/* Import */} + + +
+ + {/* Name input */} + setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} + placeholder="Workflow name…" + className="flex-1 min-w-0 bg-zinc-800 border border-zinc-700/80 rounded-md px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-accent/60" + /> + +
- {/* Run / Cancel */} + {/* Run */} - -
-
-
- {loading ? ( -

Loading…

- ) : workflows.length === 0 ? ( -
- - - - -

No workflows yet.
Create one to get started.

+
+ + {newModalOpen && ( + setNewModalOpen(false)} + /> + )} + + {/* Tab bar */} + {!loading && ( +
+ {workflows.map((wf) => ( +
setActive(wf.id)} + onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); remove(wf.id) } }} + className={`relative flex items-center gap-1.5 pl-3 pr-1.5 h-full text-[11px] font-medium shrink-0 transition-colors border-b-2 cursor-pointer group + ${wf.id === activeId + ? 'text-zinc-100 border-accent bg-zinc-900/50' + : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/20 border-transparent' + }`} + > + {wf.name || 'Untitled'} +
- ) : workflows.map((wf) => ( - setActive(wf.id)} /> ))}
-
+ )} - {/* Center: canvas area */} + {/* Canvas + extensions panel */}
{activeWorkflow ? ( @@ -874,6 +960,8 @@ export default function WorkflowsPage(): JSX.Element { panelOpen={panelOpen} onTogglePanel={() => setPanelOpen((o) => !o)} onRunInGenerate={(wf) => { save(wf); setActive(wf.id); navigate('generate') }} + onNew={() => setNewModalOpen(true)} + onImport={handleImport} /> ) : ( @@ -883,18 +971,23 @@ export default function WorkflowsPage(): JSX.Element {
-

Open a workflow

-

or create a new one

+

No workflows yet

+

Create one to get started

+
+
+ +
-
)} -
- {/* Right: Extensions panel */} - + {/* Extensions panel */} + +
) } diff --git a/src/areas/workflows/nodes/BaseNode.tsx b/src/areas/workflows/nodes/BaseNode.tsx index 5c634ac..d6b7edf 100644 --- a/src/areas/workflows/nodes/BaseNode.tsx +++ b/src/areas/workflows/nodes/BaseNode.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useRef, useLayoutEffect } from 'react' import { NodeResizer, useReactFlow } from '@xyflow/react' import type { ReactNode } from 'react' @@ -48,11 +48,22 @@ export default function BaseNode({ }: BaseNodeProps) { const { updateNodeData, deleteElements } = useReactFlow() const [expanded, setExpanded] = useState(defaultExpanded) + const rootRef = useRef(null) + const [minW, setMinW] = useState(minWidth) + const [minH, setMinH] = useState(minHeight) + + useLayoutEffect(() => { + if (rootRef.current) { + setMinW(rootRef.current.offsetWidth) + setMinH(rootRef.current.offsetHeight) + } + }, []) const isDisabled = enabled === false return (
diff --git a/src/areas/workflows/nodes/ExtensionNode.tsx b/src/areas/workflows/nodes/ExtensionNode.tsx index 90e993b..8f0d4cb 100644 --- a/src/areas/workflows/nodes/ExtensionNode.tsx +++ b/src/areas/workflows/nodes/ExtensionNode.tsx @@ -14,6 +14,12 @@ const HANDLE_COLOR: Record = { text: '#fbbf24', } +const TAG_CLS: Record = { + image: 'border-sky-500/30 bg-sky-500/10 text-sky-400', + mesh: 'border-violet-500/30 bg-violet-500/10 text-violet-400', + text: 'border-amber-500/30 bg-amber-500/10 text-amber-400', +} + // ─── Param control ──────────────────────────────────────────────────────────── const inputCls = 'w-full bg-zinc-800 border border-zinc-700 rounded-lg px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60' @@ -92,20 +98,23 @@ export default function ExtensionNode({ id, data, selected }: { id: string; data id={id} selected={selected} title={ext?.name ?? data.extensionId ?? 'Unknown extension'} - badge={ext?.builtin ? 'built-in' : undefined} enabled={data.enabled} showInGenerate={data.showInGenerate ?? false} collapsible={hasParams} minWidth={200} subheader={ -
- {ext?.input ?? '—'} +
+ + {ext?.input ?? '—'} + {!isTerminal && ( <> - + - {ext?.output ?? '—'} + + {ext?.output ?? '—'} + )}
diff --git a/src/shared/stores/workflowsStore.ts b/src/shared/stores/workflowsStore.ts index 93b5fcc..35ea60c 100644 --- a/src/shared/stores/workflowsStore.ts +++ b/src/shared/stores/workflowsStore.ts @@ -100,8 +100,11 @@ export const useWorkflowsStore = create((set) => ({ const result = await window.electron.workflows.save(workflow) if (result.success) { set((s) => { - const filtered = s.workflows.filter((w) => w.id !== workflow.id) - return { workflows: [workflow, ...filtered] } + const idx = s.workflows.findIndex((w) => w.id === workflow.id) + if (idx === -1) return { workflows: [workflow, ...s.workflows] } + const next = [...s.workflows] + next[idx] = workflow + return { workflows: next } }) } return result diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index 9560eeb..83458c4 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -196,8 +196,8 @@ declare global { updater: { check: () => Promise<{ success: boolean }> quitAndInstall: () => Promise - onPatchReady: (cb: (data: { version: string }) => void) => void - offPatchReady: () => void + onApplying: (cb: (data: { version: string }) => void) => void + offApplying: () => void onMajorMinorAvailable: (cb: (data: { version: string }) => void) => void offMajorMinorAvailable: () => void }