From ea0261a1923fbc692ab0480374f7232759446dc7 Mon Sep 17 00:00:00 2001 From: Elysia Date: Wed, 28 Jan 2026 18:14:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B7=B2=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E5=BA=94=E7=94=A8=E5=92=8C=E5=8F=AF=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E7=9A=84=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=8D=B8=E8=BD=BD=E5=92=8C=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/backend/install-manager.ts | 147 +++++++++++++++++++++- src/App.vue | 133 +++++++++++++++++++- src/components/InstalledAppsModal.vue | 148 ++++++++++++++++++++++ src/components/UpdateAppsModal.vue | 154 +++++++++++++++++++++++ src/global/typedefinition.ts | 1 + src/modeuls/processInstall.ts | 30 +++++ src/style.css | 90 ------------- 7 files changed, 606 insertions(+), 97 deletions(-) create mode 100644 src/components/InstalledAppsModal.vue create mode 100644 src/components/UpdateAppsModal.vue delete mode 100644 src/style.css diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index b35c656b..4ae6e442 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -37,10 +37,87 @@ const checkSuperUserCommand = async (): Promise => { return superUserCmd; } +const runCommandCapture = async (execCommand: string, execParams: string[], envOverride?: NodeJS.ProcessEnv) => { + return await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { + const child = spawn(execCommand, execParams, { + shell: true, + env: { ...process.env, ...(envOverride || {}) } + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('error', (err) => { + resolve({ code: -1, stdout, stderr: err.message }); + }); + + child.on('close', (code) => { + resolve({ code: typeof code === 'number' ? code : -1, stdout, stderr }); + }); + }); +}; + +const parseInstalledList = (output: string) => { + const apps: Array<{ pkgname: string; version: string; arch: string; flags: string; raw: string }> = []; + const lines = output.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('Listing')) continue; + if (trimmed.startsWith('[INFO]')) continue; + + const match = trimmed.match(/^(\S+)\/\S+,\S+\s+(\S+)\s+(\S+)\s+\[(.+)\]$/); + if (!match) continue; + apps.push({ + pkgname: match[1], + version: match[2], + arch: match[3], + flags: match[4], + raw: trimmed + }); + } + return apps; +}; + +const parseUpgradableList = (output: string) => { + const apps: Array<{ pkgname: string; newVersion: string; currentVersion: string; raw: string }> = []; + const lines = output.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('Listing')) continue; + if (trimmed.startsWith('[INFO]')) continue; + if (trimmed.includes('=') && !trimmed.includes('/')) continue; + + if (!trimmed.includes('/')) continue; + + const tokens = trimmed.split(/\s+/); + if (tokens.length < 2) continue; + const pkgToken = tokens[0]; + const pkgname = pkgToken.split('/')[0]; + const newVersion = tokens[1] || ''; + const currentMatch = trimmed.match(/\[(?:upgradable from|from):\s*([^\]\s]+)\]/i); + const currentToken = tokens[5] || ''; + const currentVersion = currentMatch?.[1] || currentToken.replace('[', '').replace(']', ''); + + if (!pkgname) continue; + apps.push({ pkgname, newVersion, currentVersion, raw: trimmed }); + } + return apps; +}; + // Listen for download requests from renderer process ipcMain.on('queue-install', async (event, download_json) => { const download = JSON.parse(download_json); - const { id, pkgname } = download || {}; + const { id, pkgname, upgradeOnly } = download || {}; if (!id || !pkgname) { logger.warn('passed arguments missing id or pkgname'); @@ -250,4 +327,72 @@ ipcMain.on('remove-installed', async (_event, pkgname: string) => { message: JSON.stringify(messageJSONObj) }); }); +}); + +ipcMain.handle('list-upgradable', async () => { + const listCommand = 'source /opt/apm-store/transhell.sh; load_transhell_debug; amber-pm-debug aptss list --upgradable'; + const { code, stdout, stderr } = await runCommandCapture( + '/bin/bash', + ['-lc', listCommand], + { LANGUAGE: 'en_US' } + ); + if (code !== 0) { + logger.error(`list-upgradable failed: ${stderr || stdout}`); + return { + success: false, + message: stderr || stdout || `list-upgradable failed with code ${code}`, + apps: [] + }; + } + + const apps = parseUpgradableList(stdout); + return { success: true, apps }; +}); + +ipcMain.handle('list-installed', async () => { + const superUserCmd = await checkSuperUserCommand(); + const execCommand = superUserCmd.length > 0 ? superUserCmd : '/usr/bin/apm'; + const execParams = superUserCmd.length > 0 + ? ['/usr/bin/apm', 'list', '--installed'] + : ['list', '--installed']; + + const { code, stdout, stderr } = await runCommandCapture(execCommand, execParams); + if (code !== 0) { + logger.error(`list-installed failed: ${stderr || stdout}`); + return { + success: false, + message: stderr || stdout || `list-installed failed with code ${code}`, + apps: [] + }; + } + + const apps = parseInstalledList(stdout); + return { success: true, apps }; +}); + +ipcMain.handle('uninstall-installed', async (_event, pkgname: string) => { + if (!pkgname) { + logger.warn('uninstall-installed missing pkgname'); + return { success: false, message: 'missing pkgname' }; + } + + const superUserCmd = await checkSuperUserCommand(); + const execCommand = superUserCmd.length > 0 ? superUserCmd : '/usr/bin/apm'; + const execParams = superUserCmd.length > 0 + ? ['/usr/bin/apm', 'remove', '-y', pkgname] + : ['remove', '-y', pkgname]; + + const { code, stdout, stderr } = await runCommandCapture(execCommand, execParams); + const success = code === 0; + + if (success) { + logger.info(`卸载完成: ${pkgname}`); + } else { + logger.error(`卸载失败: ${pkgname} ${stderr || stdout}`); + } + + return { + success, + message: success ? '卸载完成' : (stderr || stdout || `卸载失败,退出码 ${code}`) + }; }); \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index ca7b4e48..da878172 100644 --- a/src/App.vue +++ b/src/App.vue @@ -24,6 +24,15 @@ + + + + @@ -38,9 +47,11 @@ import AppDetailModal from './components/AppDetailModal.vue'; import ScreenPreview from './components/ScreenPreview.vue'; import DownloadQueue from './components/DownloadQueue.vue'; import DownloadDetail from './components/DownloadDetail.vue'; +import InstalledAppsModal from './components/InstalledAppsModal.vue'; +import UpdateAppsModal from './components/UpdateAppsModal.vue'; import { APM_STORE_ARCHITECTURE, APM_STORE_BASE_URL, currentApp, currentAppIsInstalled } from './global/storeConfig'; import { downloads } from './global/downloadStatus'; -import { handleInstall, handleRetry, handleRemove } from './modeuls/processInstall'; +import { handleInstall, handleRetry, handleRemove, handleUpgrade } from './modeuls/processInstall'; const logger = pino(); @@ -63,6 +74,14 @@ const screenshots = ref([]); const loading = ref(true); const showDownloadDetailModal = ref(false); const currentDownload = ref(null); +const showInstalledModal = ref(false); +const installedApps = ref([]); +const installedLoading = ref(false); +const installedError = ref(''); +const showUpdateModal = ref(false); +const upgradableApps = ref([]); +const updateLoading = ref(false); +const updateError = ref(''); // 计算属性 const filteredApps = computed(() => { @@ -96,6 +115,10 @@ const categoryCounts = computed(() => { return counts; }); +const hasSelectedUpgrades = computed(() => { + return upgradableApps.value.some(app => app.selected); +}); + // 方法 const initTheme = () => { const savedTheme = localStorage.getItem('theme'); @@ -183,17 +206,115 @@ const nextScreen = () => { }; const handleUpdate = () => { - openApmStoreUrl('apmstore://action?cmd=update', { - fallbackText: 'apm-update-tool' - }); + openUpdateModal(); }; const handleList = () => { - openApmStoreUrl('apmstore://action?cmd=list', { - fallbackText: '/usr/bin/apm-installer --list' + openInstalledModal(); +}; + +const openUpdateModal = () => { + showUpdateModal.value = true; + refreshUpgradableApps(); +}; + +const closeUpdateModal = () => { + showUpdateModal.value = false; +}; + +const refreshUpgradableApps = async () => { + updateLoading.value = true; + updateError.value = ''; + try { + const result = await window.ipcRenderer.invoke('list-upgradable'); + if (!result?.success) { + upgradableApps.value = []; + updateError.value = result?.message || '检查更新失败'; + return; + } + upgradableApps.value = (result.apps || []).map(app => ({ + ...app, + selected: false, + upgrading: false + })); + } catch (error) { + upgradableApps.value = []; + updateError.value = error?.message || '检查更新失败'; + } finally { + updateLoading.value = false; + } +}; + +const toggleAllUpgrades = () => { + const shouldSelectAll = !hasSelectedUpgrades.value || upgradableApps.value.some(app => !app.selected); + upgradableApps.value = upgradableApps.value.map(app => ({ + ...app, + selected: shouldSelectAll ? true : false + })); +}; + +const upgradeSingleApp = (app) => { + if (!app?.pkgname) return; + handleUpgrade(app.pkgname, app.newVersion || ''); +}; + +const upgradeSelectedApps = () => { + const selectedApps = upgradableApps.value.filter(app => app.selected); + selectedApps.forEach(app => { + upgradeSingleApp(app); }); }; +const openInstalledModal = () => { + showInstalledModal.value = true; + refreshInstalledApps(); +}; + +const closeInstalledModal = () => { + showInstalledModal.value = false; +}; + +const refreshInstalledApps = async () => { + installedLoading.value = true; + installedError.value = ''; + try { + const result = await window.ipcRenderer.invoke('list-installed'); + if (!result?.success) { + installedApps.value = []; + installedError.value = result?.message || '读取已安装应用失败'; + return; + } + installedApps.value = (result.apps || []).map((app) => ({ + ...app, + removing: false + })); + } catch (error) { + installedApps.value = []; + installedError.value = error?.message || '读取已安装应用失败'; + } finally { + installedLoading.value = false; + } +}; + +const uninstallInstalledApp = async (app) => { + if (!app?.pkgname) return; + const target = installedApps.value.find(item => item.pkgname === app.pkgname); + if (target) target.removing = true; + try { + const result = await window.ipcRenderer.invoke('uninstall-installed', app.pkgname); + if (!result?.success) { + installedError.value = result?.message || '卸载失败'; + } else { + installedApps.value = installedApps.value.filter(item => item.pkgname !== app.pkgname); + } + } catch (error) { + installedError.value = error?.message || '卸载失败'; + } finally { + const restore = installedApps.value.find(item => item.pkgname === app.pkgname); + if (restore) restore.removing = false; + } +}; + const openApmStoreUrl = (url, { fallbackText } = {}) => { try { window.location.href = url; diff --git a/src/components/InstalledAppsModal.vue b/src/components/InstalledAppsModal.vue new file mode 100644 index 00000000..a38d1c8d --- /dev/null +++ b/src/components/InstalledAppsModal.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/src/components/UpdateAppsModal.vue b/src/components/UpdateAppsModal.vue new file mode 100644 index 00000000..34e12513 --- /dev/null +++ b/src/components/UpdateAppsModal.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index dfac0520..05e340e7 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -30,4 +30,5 @@ export interface DownloadItem { }>; source: string; // 例如 'APM Store' retry: boolean; // 当前是否为重试下载 + upgradeOnly?: boolean; // 是否为仅升级任务 } \ No newline at end of file diff --git a/src/modeuls/processInstall.ts b/src/modeuls/processInstall.ts index 22e4f31f..0c9c44ab 100644 --- a/src/modeuls/processInstall.ts +++ b/src/modeuls/processInstall.ts @@ -9,6 +9,7 @@ import { downloads } from "../global/downloadStatus"; import { InstallLog, DownloadItem, DownloadResult } from '../global/typedefinition'; let downloadIdCounter = 0; +const fallbackIcon = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23f0f0f0" width="100" height="100"/%3E%3Ctext x="50" y="50" text-anchor="middle" dy=".3em" fill="%23999" font-size="14"%3EAPM%3C/text%3E%3C/svg%3E'; export const handleInstall = () => { if (!currentApp.value?.Pkgname) return; @@ -53,6 +54,35 @@ export const handleRetry = (download_: DownloadItem) => { window.ipcRenderer.send('queue-install', JSON.stringify(download_)); }; +export const handleUpgrade = (pkgname: string, newVersion = '') => { + if (!pkgname) return; + + downloadIdCounter += 1; + const download: DownloadItem = { + id: downloadIdCounter, + name: pkgname, + pkgname: pkgname, + version: newVersion, + icon: fallbackIcon, + status: 'queued', + progress: 0, + downloadedSize: 0, + totalSize: 0, + speed: 0, + timeRemaining: 0, + startTime: Date.now(), + logs: [ + { time: Date.now(), message: '开始更新...' } + ], + source: 'APM Update', + retry: false, + upgradeOnly: true + }; + + downloads.value.push(download); + window.ipcRenderer.send('queue-install', JSON.stringify(download)); +}; + export const handleRemove = () => { if (!currentApp.value?.Pkgname) return; window.ipcRenderer.send('remove-installed', currentApp.value.Pkgname); diff --git a/src/style.css b/src/style.css deleted file mode 100644 index 64aafe81..00000000 --- a/src/style.css +++ /dev/null @@ -1,90 +0,0 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -code { - background-color: #1a1a1a; - padding: 2px 4px; - margin: 0 4px; - border-radius: 4px; -} - -.card { - padding: 2em; -} - -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } - code { - background-color: #f9f9f9; - } -}