import type { ElectronAPI, BackendHealthStatus } from '../../shared/electron-api-schema' const pendingFiles = new Map() const uploads = new Map>() export function mediaUrl(path: string): string { return path.startsWith('blob:') || path.startsWith('data:') ? path : `/web/media?path=${encodeURIComponent(path)}` } export async function webRequest(path: string, value?: unknown): Promise { const response = await fetch(path, value === undefined ? undefined : { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Deskweb-Request': '1' }, body: JSON.stringify(value), }) const data = await response.json() if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`) return data as T } function status(message: string) { window.dispatchEvent(new CustomEvent('web-status', { detail: message })) } async function upload(file: File): Promise { status(`Uploading ${file.name}…`) try { const data = await new Promise<{ path: string }>((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open('POST', `/web/upload?name=${encodeURIComponent(file.name)}`) xhr.setRequestHeader('X-Deskweb-Request', '1') xhr.upload.onprogress = event => { if (event.lengthComputable) status(`Uploading ${file.name}: ${Math.round(event.loaded / event.total * 100)}%`) } xhr.onload = () => { try { const result = JSON.parse(xhr.responseText); if (xhr.status < 200 || xhr.status >= 300) reject(new Error(result.error)); else resolve(result) } catch { reject(new Error('Invalid upload response')) } } xhr.onerror = () => reject(new Error('Upload connection failed')) xhr.send(file) }) status('Upload complete'); return data.path } catch (e) { status(`Upload failed: ${e instanceof Error ? e.message : String(e)}`); throw e } } async function resolveInput(input: string): Promise { const file = pendingFiles.get(input) if (!file) return input if (!uploads.has(input)) uploads.set(input, upload(file).catch(e => { uploads.delete(input); throw e })) return uploads.get(input)! } function download(blob: Blob, name: string) { const url = URL.createObjectURL(blob), link = document.createElement('a') link.href = url; link.download = name.split('/').pop() || 'download'; link.click() setTimeout(() => URL.revokeObjectURL(url), 60000) } const open = (url: string) => { const parsed = new URL(url); if (!['https:', 'http:'].includes(parsed.protocol)) return false; window.open(url, '_blank', 'noopener,noreferrer'); return true } const fileOperation = (action: string, input: Record) => webRequest('/web/files', { action, ...input }) type Dimensions = { success: true; width: number; height: number } type Thumbnails = { success: true; bigThumbnailPath: string; smallThumbnailPath: string } async function health(): Promise { try { const response = await fetch('/ltx/health', { signal: AbortSignal.timeout(10000) }) if (!response.ok) return { status: 'dead' } // Deck's own health endpoint is not proof that the LTX API is active. const specs = await fetch('/ltx/api/generate/models-specs', { signal: AbortSignal.timeout(10000) }) return { status: specs.ok ? 'alive' : 'dead' } } catch { return { status: 'dead' } } } const bridge: Partial = { platform: 'web', getBackend: async () => ({ url: `${location.origin}/ltx`, token: '' }), getBackendHealthStatus: health, onBackendHealthStatus: callback => { let active = true, timer: ReturnType const poll = async () => { const result = await health(); if (active) { callback(result); timer = setTimeout(poll, 15000) } } void poll(); return () => { active = false; clearTimeout(timer) } }, getPathForFile: file => { const url = URL.createObjectURL(file); pendingFiles.set(url, file); return url }, showOpenFileDialog: options => new Promise((resolve, reject) => { const input = document.createElement('input'); input.type = 'file'; input.multiple = Boolean(options.properties?.includes('multiSelections')) input.accept = options.filters?.flatMap(filter => filter.extensions.map(ext => `.${ext}`)).join(',') || '' input.oncancel = () => resolve(null) input.onchange = () => { Promise.all(Array.from(input.files || []).map(upload)).then(resolve, reject) } input.click() }), prepareGenerationInput: async ({ path }) => ({ success: true, path: await resolveInput(path) }), addGenericAssetToProject: async ({ srcPath }) => fileOperation('copy', { path: await resolveInput(srcPath) }), addVisualAssetToProject: async ({ srcPath }) => { const copied = await fileOperation<{ path: string }>('copy', { path: await resolveInput(srcPath) }) const dimensions = await fileOperation('metadata', copied) const thumbs = await fileOperation('thumbnail', copied) return { ...dimensions, ...thumbs, success: true, path: copied.path } }, makeDimensionsForProjectAsset: async ({ path }) => fileOperation('metadata', { path: await resolveInput(path) }), makeThumbnailsForProjectAsset: async ({ path }) => fileOperation('thumbnail', { path: await resolveInput(path) }), extractVideoFrame: async ({ videoPath, seekTime }) => fileOperation('frame', { path: await resolveInput(videoPath), seekTime }), checkFilesExist: async ({ filePaths }) => fileOperation('exists', { paths: filePaths }), readLocalFile: async ({ filePath }) => { const response = await fetch(mediaUrl(filePath)); if (!response.ok) throw Error('Cannot read media') const buffer = new Uint8Array(await response.arrayBuffer()); let binary = '' for (let offset = 0; offset < buffer.length; offset += 8192) binary += String.fromCharCode(...buffer.subarray(offset, offset + 8192)) return { data: btoa(binary), mimeType: response.headers.get('content-type') || 'application/octet-stream' } }, showSaveDialog: async options => options.defaultPath || 'download', saveFile: async ({ filePath, data, encoding }) => { const content = encoding === 'base64' ? Uint8Array.from(atob(data), c => c.charCodeAt(0)) : data; download(new Blob([content]), filePath); return { success: true, path: filePath } }, saveBinaryFile: async ({ filePath, data }) => { download(new Blob([data]), filePath); return { success: true, path: filePath } }, showItemInFolder: async ({ filePath }) => { const link = document.createElement('a'); link.href = `${mediaUrl(filePath)}&download=1`; link.download = filePath.split('/').pop() || 'clip'; link.click() }, openParentFolderOfFile: async () => { status('The file was downloaded to your browser download folder.') }, openExternalUrl: async ({ url }) => open(url), openHuggingFaceRepo: async ({ repoId }) => open(`https://huggingface.co/${repoId}`), openLtxApiKeyPage: async () => open('https://console.ltx.video'), openLtxBillingPage: async () => open('https://console.ltx.video'), openFalApiKeyPage: async () => open('https://fal.ai/dashboard'), getAppInfo: async () => ({ version: '0.1.0', isPackaged: true, modelsPath: 'Managed by LTX backend', userDataPath: 'Browser project storage' }), getProjectAssetsPath: async () => 'Shared LTX input directory', getModelsPath: async () => 'Managed by LTX backend', getDownloadsPath: async () => '', getResourcePath: async () => null, getNoticesText: async () => (await fetch('/NOTICES.md')).text(), fetchLicenseText: async () => (await fetch('/LICENSE.txt')).text(), notifyGenerationActive: async () => {}, // No process watchdog; Deck owns lifecycle. writeLog: async () => {}, // Never persist prompts or media URLs in the web service. getAnalyticsState: async () => ({ analyticsEnabled: false, installationId: '' }), setAnalyticsEnabled: async () => {}, sendAnalyticsEvent: async () => {}, getUpdateState: async () => ({ status: 'not-available', currentVersion: '0.1.0' }), onUpdateEvent: () => () => {}, getAutoCheckUpdates: async () => ({ enabled: false }), exportNative: async () => ({ success: false, error: 'Timeline rendering is not available in this first web version. Download individual clips or export the timeline as XML.' }), } // Preserve the inherited typed UI boundary while porting. Unsupported desktop operations // fail explicitly, never silently simulate installation, export, or process control. window.electronAPI = new Proxy(bridge, { get(target, property) { if (property in target) return target[property as keyof ElectronAPI] return async () => { const message = `Desktop function “${String(property)}” is not available in LTX DeskWEB.`; status(message); throw new Error(message) } }, }) as ElectronAPI