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
125 changes: 122 additions & 3 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ipcMain, BrowserWindow, dialog, app, shell } from 'electron'
import { buildSync } from 'esbuild'
import { autoUpdater } from 'electron-updater'
import { join } from 'path'
import { rm as rmAsync, readFile, writeFile, mkdir, readdir, rename, cp } from 'fs/promises'
import { rm as rmAsync, readFile, writeFile, mkdir, readdir, rename, cp, symlink, lstat } from 'fs/promises'
import { existsSync, readdirSync, statSync } from 'fs'
import axios from 'axios'
import * as tar from 'tar'
Expand Down Expand Up @@ -398,6 +398,9 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe

// Read local file → base64 (bypasses file:// restrictions in the renderer)
ipcMain.handle('fs:readFileBase64', async (_, filePath: string) => {
if (typeof filePath !== 'string' || filePath.trim().length === 0) {
throw new Error('fs:readFileBase64 requires a non-empty file path')
}
const buffer = await readFile(filePath)
return buffer.toString('base64')
})
Expand Down Expand Up @@ -802,15 +805,38 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
if (!existsSync(dir)) return []
try {
const entries = await readdir(dir, { withFileTypes: true })
const dirs = entries.filter(e => e.isDirectory())
// On Windows, junction points are reported by Node.js as isSymbolicLink()=true,
// isDirectory()=false. Use statSync (which follows links) as the authoritative check.
const dirs = entries.filter(e => {
if (e.isDirectory()) return true
if (e.isSymbolicLink()) {
try { return statSync(join(dir, e.name)).isDirectory() } catch { return false }
}
return false
})
return Promise.all(dirs.map(async (entry) => {
const base = { type: 'model' as const, id: entry.name, name: entry.name, trusted: isBuiltin, builtin: isBuiltin, nodes: [] }
const entryPath = join(dir, entry.name)

// Detect local extensions: check for .modly-local sentinel
let localSourcePath: string | undefined
if (!isBuiltin) {
const sentinelPath = join(entryPath, '.modly-local')
if (existsSync(sentinelPath)) {
try {
localSourcePath = (await readFile(sentinelPath, 'utf-8')).trim()
} catch { /* ignore */ }
}
}

for (const manifestFile of ['manifest.json', 'package.json']) {
const p = join(dir, entry.name, manifestFile)
const p = join(entryPath, manifestFile)
if (existsSync(p)) {
try {
const raw = await readFile(p, 'utf-8')
const parsed = JSON.parse(raw) as ParsedManifest
// Inject local:// source so the UI shows the Local badge
if (localSourcePath) parsed.source = `local://${localSourcePath}`
return parseExtensionManifest(parsed, entry.name, trustedRepos, isBuiltin)
} catch { /* ignore parse errors, fall through */ }
}
Expand Down Expand Up @@ -1060,6 +1086,99 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
}
})

// Install a local extension by creating a symlink/junction to a local folder
ipcMain.handle('extensions:installFromLocal', async (event) => {
const win = getWindow()
const emit = (data: object) => win?.webContents.send('extensions:installProgress', data)

// 1. Open folder picker
const pickResult = await dialog.showOpenDialog(win!, {
title: 'Select local extension folder',
properties: ['openDirectory'],
})
if (pickResult.canceled || pickResult.filePaths.length === 0) {
return { success: false, cancelled: true }
}
const localPath = pickResult.filePaths[0]

try {
emit({ step: 'validating' })

// 2. Read and validate manifest.json
const manifestPath = join(localPath, 'manifest.json')
if (!existsSync(manifestPath)) {
throw new Error('manifest.json not found in the selected folder')
}
const manifestRaw = await readFile(manifestPath, 'utf-8')
const manifest = JSON.parse(manifestRaw) as ParsedManifest

const { id: rawManifestId } = validateInstallManifest(
manifest,
{
hasEntryFile: (candidate) => existsSync(join(localPath, candidate)),
hasGeneratorFile: () => existsSync(join(localPath, 'generator.py')),
},
'local folder',
)
const extensionId = assertSafeExtensionId(rawManifestId)

// 3. Create symlink / junction in extensionsDir
const userData = app.getPath('userData')
const extensionsDir = getSettings(userData).extensionsDir
await mkdir(extensionsDir, { recursive: true })

const linkPath = resolveExtensionPathWithinRoot(extensionsDir, extensionId)

// Remove any existing symlink/dir at that location
if (existsSync(linkPath)) {
// Check if it's already linked to the same path
try {
const stat = await lstat(linkPath)
if (stat.isSymbolicLink() || (process.platform === 'win32' && stat.isDirectory())) {
await rmAsync(linkPath, { recursive: true, force: true })
} else {
throw new Error(`A non-symlink folder named "${extensionId}" already exists in extensionsDir. Remove it first.`)
}
} catch (e: any) {
if (e.message?.includes('already exists')) throw e
await rmAsync(linkPath, { recursive: true, force: true })
}
}

emit({ step: 'setting_up', message: 'Linking local folder…' })

if (process.platform === 'win32') {
// Windows: junction (no elevation needed, works for directories)
await symlink(localPath, linkPath, 'junction')
} else {
// macOS / Linux: standard symlink
await symlink(localPath, linkPath)
}

// Write sentinel so extensions:list can detect this as a local extension
// The sentinel lives in the linked folder (= the original local folder), so
// it persists even if Modly is restarted. The content is the absolute path.
await writeFile(join(linkPath, '.modly-local'), localPath, 'utf-8')

// 4. Hot-reload Python registry so it picks up the new extension
try {
await axios.post(`${API_BASE_URL}/extensions/reload`, {}, { timeout: 10_000 })
} catch { /* Python might not be running yet */ }

emit({ step: 'done', extensionId })

const trustedRepos = await fetchTrustedRepos()
// Build manifest with localPath marker so the UI can identify local extensions
const annotatedManifest = { ...manifest, source: `local://${localPath}` }
const ext = parseExtensionManifest(annotatedManifest, extensionId, trustedRepos)
return { success: true, extensionId, extension: ext, localPath }

} catch (err) {
emit({ step: 'error', message: String(err) })
return { success: false, error: String(err) }
}
})

// Trigger Python extension reload (without touching the filesystem)
ipcMain.handle('extensions:reload', async () => {
terminateAllProcessRunners()
Expand Down
9 changes: 8 additions & 1 deletion electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,18 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.invoke('extensions:list'),

installFromGitHub: (url: string): Promise<{
success: boolean; error?: string
success: boolean; error?: string; cancelled?: boolean
extensionId?: string
extension?: unknown
}> => ipcRenderer.invoke('extensions:installFromGitHub', url),

installFromLocal: (): Promise<{
success: boolean; error?: string; cancelled?: boolean
extensionId?: string
extension?: unknown
localPath?: string
}> => ipcRenderer.invoke('extensions:installFromLocal'),

uninstall: (extensionId: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('extensions:uninstall', extensionId),

Expand Down
30 changes: 29 additions & 1 deletion src/areas/models/ModelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default function ModelsPage(): JSX.Element {
const loadErrors = useExtensionsStore((s) => s.loadErrors)
const loadExtensions = useExtensionsStore((s) => s.loadExtensions)
const installFromGH = useExtensionsStore((s) => s.installFromGitHub)
const installFromLocal = useExtensionsStore((s) => s.installFromLocal)
const uninstallExt = useExtensionsStore((s) => s.uninstall)
const reloadExtensions = useExtensionsStore((s) => s.reload)
const clearInstall = useExtensionsStore((s) => s.clearInstallState)
Expand Down Expand Up @@ -139,7 +140,17 @@ export default function ModelsPage(): JSX.Element {
}
}

// ── Uninstall extension ────────────────────────────────────────────────────
// ── Local extension install ──────────────────────────────────────────

async function handleLocalInstall() {
setGhErr(null)
clearInstall()
const result = await installFromLocal()
if ('cancelled' in result && result.cancelled) return // user dismissed dialog
if (!result.success) setGhErr(result.error ?? 'Installation failed')
}

// ── Uninstall extension ──────────────────────────────────────────

function openUninstallModal(extId: string) {
const ext = allExtensions.find((e) => e.id === extId)
Expand Down Expand Up @@ -200,6 +211,7 @@ export default function ModelsPage(): JSX.Element {
<div className="flex items-center justify-between mb-4">
<h1 className="text-base font-semibold text-zinc-100">Extensions</h1>
<div className="flex items-center gap-2">
{/* GitHub install button */}
<button
onClick={() => {
setShowGHForm((v) => !v)
Expand All @@ -213,6 +225,21 @@ export default function ModelsPage(): JSX.Element {
</svg>
{showGHForm ? 'Cancel' : 'Install from GitHub'}
</button>

{/* Local folder link button */}
<button
onClick={handleLocalInstall}
disabled={isInstalling}
title="Link a local extension folder"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-800/80 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200 transition-all border border-zinc-700/60 disabled:opacity-40 disabled:cursor-not-allowed"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
<line x1="12" y1="11" x2="12" y2="17"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
Link local folder
</button>
</div>
</div>

Expand Down Expand Up @@ -406,6 +433,7 @@ export default function ModelsPage(): JSX.Element {
}}
onUninstall={(extId) => openUninstallModal(extId)}
onRepaired={() => reloadExtensions()}
onSynced={() => reloadExtensions()}
/>
))}
</div>
Expand Down
111 changes: 94 additions & 17 deletions src/areas/models/components/ExtensionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface Props {
onUninstall: (extId: string) => void
onUninstallNode?: (fullId: string) => void
onRepaired?: () => void
onSynced?: () => void
}

const TYPE_BADGE: Record<string, { label: string; cls: string }> = {
Expand All @@ -43,9 +44,14 @@ function TruncatedText({
return <span className={className}>{text}</span>
}

export function ExtensionCard({ ext, installedIds, downloading, loadError, disabled, onInstall, onPauseDownload, onCancelDownload, onUninstall, onUninstallNode, onRepaired }: Props): JSX.Element {
export function ExtensionCard({ ext, installedIds, downloading, loadError, disabled, onInstall, onPauseDownload, onCancelDownload, onUninstall, onUninstallNode, onRepaired, onSynced }: Props): JSX.Element {
const [repairing, setRepairing] = useState(false)
const [repairError, setRepairError] = useState<string | null>(null)
const [syncing, setSyncing] = useState(false)
const [syncError, setSyncError] = useState<string | null>(null)

const isLocal = typeof ext.source === 'string' && ext.source.startsWith('local://')
const localPath = isLocal ? ext.source!.replace('local://', '') : null

const badge = TYPE_BADGE[ext.type] ?? TYPE_BADGE.model

Expand All @@ -61,6 +67,23 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab
}
}

async function handleSync() {
setSyncing(true)
setSyncError(null)
try {
const result = await window.electron.extensions.reload()
if (!result.success) {
setSyncError(result.error ?? 'Sync failed')
} else {
onSynced?.()
}
} catch (e) {
setSyncError(String(e))
} finally {
setSyncing(false)
}
}

function formatBytes(bytes?: number): string {
if (!bytes || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
Expand Down Expand Up @@ -95,6 +118,19 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab
{badge.label}
</span>

{/* Local badge */}
{isLocal && (
<span
title={localPath ?? ''}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-orange-500/15 border border-orange-500/25 text-orange-400 text-[10px] font-semibold shrink-0 cursor-default"
>
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
Local
</span>
)}

{/* Trust badge — only shown for official extensions */}
{ext.trusted && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-zinc-800/80 border border-zinc-700/40 text-zinc-400 text-[10px] font-medium shrink-0">
Expand All @@ -119,29 +155,70 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab

{/* Uninstall button */}
{!ext.builtin && (
<button
onClick={() => onUninstall(ext.id)}
disabled={disabled}
title={disabled ? 'Cannot uninstall while an install is in progress' : 'Uninstall extension'}
className="shrink-0 p-1.5 rounded-lg 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="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/>
</svg>
</button>
<div className="flex items-center gap-1 shrink-0">
{/* Sync button — only for local extensions */}
{isLocal && (
<button
onClick={handleSync}
disabled={syncing || disabled}
title={syncing ? 'Syncing…' : 'Sync — reload extension from local folder'}
className="p-1.5 rounded-lg text-zinc-600 hover:text-orange-400 hover:bg-orange-950/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<svg
width="11" height="11" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
className={syncing ? 'animate-spin' : ''}
>
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/>
</svg>
</button>
)}
<button
onClick={() => onUninstall(ext.id)}
disabled={disabled}
title={isLocal
? (disabled ? 'Cannot unlink while an install is in progress' : 'Unlink local extension (removes symlink only, keeps source folder)')
: (disabled ? 'Cannot uninstall while an install is in progress' : 'Uninstall extension')}
className="shrink-0 p-1.5 rounded-lg 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"
>
{isLocal ? (
/* Unlink icon (chain-break) */
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>
<line x1="2" y1="2" x2="22" y2="22"/>
</svg>
) : (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/>
</svg>
)}
</button>
</div>
)}
</div>

{/* Load error */}
{(loadError || repairError) && (
{/* Load error / repair error / sync error */}
{(loadError || repairError || syncError) && (
<div className="flex items-start gap-1.5 px-2.5 py-2 rounded-lg bg-red-950/30 border border-red-800/30">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-red-400 shrink-0 mt-px">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<p className="text-[10px] text-red-400 break-all">{repairError ?? loadError}</p>
<p className="text-[10px] text-red-400 break-all">{syncError ?? repairError ?? loadError}</p>
</div>
)}

{/* Local path display */}
{isLocal && localPath && (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-orange-950/20 border border-orange-800/20">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-orange-500/70 shrink-0">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
<p className="text-[10px] text-orange-400/70 truncate font-mono" title={localPath}>{localPath}</p>
</div>
)}

Expand Down
Loading