/** * Task Actions - Action implementations for individual task operations * Handles app installations, management operations, etc. */ class TaskActions { constructor(tasksManager, commands) { this.tasksManager = tasksManager; this.commands = commands; } /** * Install an application */ async installApp(appName, config = '', resetNetwork = false) { try { this.commands.validateCommand('install', { appName }); if (resetNetwork) { const parts = ['libreportal', 'app', 'install', appName]; if (config) parts.push(`'${config.replace(/'/g, "'\\''")}'`); parts.push('--reset-network'); return await this.executeTask('install', appName, parts.join(' ')); } return await this.executeTask('install', appName, config); } catch (error) { throw new Error(`Failed to install ${appName}: ${error.message}`); } } /** * Uninstall an application */ async uninstallApp(appName, deleteImage = false, deleteTasks = false) { try { this.commands.validateCommand('uninstall', { appName }); // Build a verbatim command when the user opted into any flag so the // --delete-images / --delete-tasks switches survive executeTask's // standard command-builder (which would otherwise quote them as config args). const flags = []; if (deleteImage) flags.push('--delete-images'); if (deleteTasks) flags.push('--delete-tasks'); if (flags.length) { const command = `libreportal app uninstall ${appName} ${flags.join(' ')}`; return await this.executeTask('uninstall', appName, command); } return await this.executeTask('uninstall', appName); } catch (error) { throw new Error(`Failed to uninstall ${appName}: ${error.message}`); } } /** * Restart an application */ async restartApp(appName) { try { this.commands.validateCommand('restart', { appName }); return await this.executeTask('restart', appName); } catch (error) { throw new Error(`Failed to restart ${appName}: ${error.message}`); } } /** * Start an application */ async startApp(appName) { try { this.commands.validateCommand('start', { appName }); return await this.executeTask('start', appName); } catch (error) { throw new Error(`Failed to start ${appName}: ${error.message}`); } } /** * Stop an application */ async stopApp(appName) { try { this.commands.validateCommand('stop', { appName }); return await this.executeTask('stop', appName); } catch (error) { throw new Error(`Failed to stop ${appName}: ${error.message}`); } } /** * Create backup for an application */ async backupApp(appName, customPassword = '') { try { this.commands.validateCommand('backup', { appName }); return await this.executeTask('backup', appName, customPassword); } catch (error) { throw new Error(`Failed to backup ${appName}: ${error.message}`); } } async restoreApp(appName, location, backupFile, password) { try { if (!appName) throw new Error('appName is required'); if (!backupFile) throw new Error('backupFile is required'); const parts = ['libreportal', 'app', 'restore', appName]; if (location) parts.push(location); parts.push(backupFile); if (password) parts.push(password); return await this.executeTask('restore', appName, parts.join(' ')); } catch (error) { throw new Error(`Failed to restore ${appName}: ${error.message}`); } } /** * Delete backup file for an application */ async deleteBackup(appName, backupFile, deleteRemote1 = 'false', deleteRemote2 = 'false') { try { // Build the command with all parameters const command = `libreportal backup app delete ${appName} ${backupFile} ${deleteRemote1} ${deleteRemote2}`; // Create task directly with the full command // createTask will handle monitoring const task = await this.tasksManager.taskManager.createTask(command, 'delete', appName, backupFile); // Emit taskCreated event for AppTabbedManager to track the task // This is needed for tab re-enabling on task completion window.dispatchEvent(new CustomEvent('taskCreated', { detail: { taskId: task.id, appName: appName, action: 'delete', timestamp: Date.now() } })); return task; } catch (error) { throw new Error(`Failed to delete backup ${backupFile}: ${error.message}`); } } /** * Delete all backup files for an application */ async deleteAllBackups(appName, deleteRemote1 = 'false', deleteRemote2 = 'false') { try { // Build the command with all parameters const command = `libreportal backup app delete_all ${appName} ${deleteRemote1} ${deleteRemote2}`; // Create task directly with the full command // createTask will handle monitoring const task = await this.tasksManager.taskManager.createTask(command, 'delete_all', appName, 'all'); // Emit taskCreated event for AppTabbedManager to track the task // This is needed for tab re-enabling on task completion window.dispatchEvent(new CustomEvent('taskCreated', { detail: { taskId: task.id, appName: appName, action: 'delete_all', timestamp: Date.now() } })); return task; } catch (error) { throw new Error(`Failed to delete all backups for ${appName}: ${error.message}`); } } /** * Update application configuration */ async updateConfig(appName) { try { this.commands.validateCommand('update_config', { appName }); await this.executeTask('update_config', appName); return; } catch (error) { throw new Error(`Failed to update config for ${appName}: ${error.message}`); } } async configUpdate(changes) { try { if (!changes) throw new Error('No changes to save'); const command = `libreportal config update ${changes}`; return await this.executeTask('config_update', 'system', command); } catch (error) { throw new Error(`Failed to update configuration: ${error.message}`); } } /** * Run a per-app tool from the Tools tab. Builds: * libreportal app tool '' * argsString is pipe-encoded: key=value|key=value (matches the install * config pattern so the bash side can use the same parser). */ async runTool(appName, toolName, toolArgs = '', toolLabel = '') { try { if (!appName) throw new Error('appName is required'); if (!toolName) throw new Error('toolName is required'); const safeTool = String(toolName).replace(/[^A-Za-z0-9_.-]/g, ''); if (!safeTool) throw new Error('Invalid tool name'); const parts = ['libreportal', 'app', 'tool', appName, safeTool]; if (toolArgs) { parts.push(`'${String(toolArgs).replace(/'/g, "'\\''")}'`); } return await this.executeTask('tool', appName, parts.join(' '), toolLabel); } catch (error) { throw new Error(`Failed to run tool ${toolName} on ${appName}: ${error.message}`); } } /** * System operations */ async systemUpdate() { try { await this.executeTask('system_update', 'system'); return; } catch (error) { throw new Error(`Failed to update system: ${error.message}`); } } async systemReclaim() { try { // Full command passed verbatim (executeTask uses it as-is when it // starts with 'libreportal'), so it routes to the CLI's system handler. await this.executeTask('system_reclaim', 'system', 'libreportal system reclaim'); return; } catch (error) { throw new Error(`Failed to reclaim space: ${error.message}`); } } // Remove Docker images by id. ids: array of image refs (the Storage page // sends full sha256:… ids). Routes through the CLI's `system image rm` as a // task — no direct API. Returns the created task so the caller can await its // completion. force=true passes --force (in-use images). async removeImages(ids, force = false) { try { const list = Array.isArray(ids) ? ids.filter(Boolean) : []; if (!list.length) throw new Error('No images selected'); const flag = force ? ' --force' : ''; // Ids joined with commas so they stay a single CLI token. const command = `libreportal system image rm${flag} ${list.join(',')}`; const label = list.length === 1 ? 'Remove Image' : `Remove ${list.length} Images`; return await this.executeTask('system_image_rm', 'system', command, label); } catch (error) { throw new Error(`Failed to remove images: ${error.message}`); } } // App Updater actions — each runs the locked-down `libreportal updater` CLI // as a task. apply/rollback snapshot-before-update on the host side (DR). async updaterCheck() { try { return await this.executeTask('updater_check', 'updater', 'libreportal updater check', 'Check for updates'); } catch (error) { throw new Error(`Failed to check for updates: ${error.message}`); } } async updaterApply(app) { if (!app) throw new Error('No app specified'); try { return await this.executeTask('updater_apply', app, `libreportal updater apply ${app}`, `Update ${app}`); } catch (error) { throw new Error(`Failed to update ${app}: ${error.message}`); } } async updaterApplyAll(apps) { try { const list = (apps || '').toString(); const cmd = list ? `libreportal updater apply-all ${list}` : 'libreportal updater apply-all'; return await this.executeTask('updater_apply_all', 'updater', cmd, 'Update all apps'); } catch (error) { throw new Error(`Failed to update apps: ${error.message}`); } } async updaterRollback(app) { if (!app) throw new Error('No app specified'); try { return await this.executeTask('updater_rollback', app, `libreportal updater rollback ${app}`, `Roll back ${app}`); } catch (error) { throw new Error(`Failed to roll back ${app}: ${error.message}`); } } /** * Create a task object */ createTask(type, command, metadata = {}) { const task = { id: Date.now().toString(), command: command, type: type, status: 'running', createdAt: new Date().toISOString(), output: `Starting ${type} operation...`, error: null, ...metadata }; return task; } /** * Execute a task with enhanced event emission */ async executeTask(action, appName, config = '', displayLabel = '') { try { // If config is a full `libreportal …` command, use it verbatim. Otherwise // build the standard form and single-quote the config arg. let command; if (config && config.startsWith('libreportal')) { command = config; } else { command = `libreportal app ${action} ${appName}`; if (config) command += ` '${config.replace(/'/g, "'\\''")}'`; } // Create task — POSTs to /api/tasks and returns the authoritative task. const task = await this.tasksManager.taskManager.createTask(command, action, appName, config); // Hook the UI side-effects (auto-expand, log streaming, status interval). // The bus also delivers `taskCreated` from the SSE feed; AppTabbedManager // dedupes by appName|action so the double-fire is harmless. if (this.tasksManager && typeof this.tasksManager.monitorTask === 'function') { this.tasksManager.monitorTask(task.id, appName, action); } window.dispatchEvent(new CustomEvent('taskCreated', { detail: { taskId: task.id, appName: appName, action: action, timestamp: Date.now() } })); // Show success notification let appData = null; try { appData = this.commands.getAppData ? this.commands.getAppData(appName) : null; } catch (error) { console.warn('Could not get app data:', error); } const appIcon = appData?.icon || `/icons/apps/${task.app}.svg`; let taskUrl; const currentUrl = window.location.href; if (currentUrl.includes('/app/') && appName) { taskUrl = window.appPath(appName, 'tasks', null, task.id); } else { taskUrl = `/tasks/all?task=${task.id}`; } if (window.notificationSystem) { // `appName === 'system'` is a category sentinel (config_update, // system_update — no real app). Resolve it to the LibrePortal // logo + name so the toast doesn't read "App: System" with a // broken /icons/apps/system.svg. const isSystem = appName === 'system'; const displayName = isSystem ? 'LibrePortal' : (window.getAppDisplayName ? window.getAppDisplayName(appName) : appName); const notifIcon = isSystem ? '/icons/libreportal.svg' : appIcon; const typeIcon = (window.tasksManager && window.tasksManager.getTaskTypeIcon ? window.tasksManager.getTaskTypeIcon({ type: action }) : null)?.icon || ''; const customIcon = typeIcon ? `${typeIcon}` : null; const friendly = (this.tasksManager && typeof this.tasksManager.formatActionTitle === 'function') ? this.tasksManager.formatActionTitle(action) : (action.charAt(0).toUpperCase() + action.slice(1)); const headline = displayLabel ? `${displayLabel} task started!` : `${friendly} task started!`; window.notificationSystem.show( `${displayName}
${headline}`, 'success', appName, taskUrl, notifIcon, customIcon ); } return task; } catch (error) { console.error(`❌ Failed to execute ${action} task for ${appName}:`, error); if (window.notificationSystem) { window.notificationSystem.error(`Failed to start ${action} task for ${appName}: ${error.message}`); } throw error; } } /** * Update task in the manager */ updateTaskInManager(updatedTask) { const taskIndex = this.tasksManager.tasks.findIndex(t => t.id === updatedTask.id); if (taskIndex !== -1) { this.tasksManager.tasks[taskIndex] = updatedTask; this.tasksManager.renderTasks(); this.tasksManager.updateStats(); this.tasksManager.updateSidebarCounts(); this.tasksManager.generateAppCategories(); } } } // Export for use window.TaskActions = TaskActions;