feat: 添加已安装应用和可更新应用的管理功能,支持卸载和升级操作

This commit is contained in:
Elysia
2026-01-28 18:14:04 +08:00
parent ac0dc225bc
commit ea0261a192
7 changed files with 606 additions and 97 deletions

View File

@@ -37,10 +37,87 @@ const checkSuperUserCommand = async (): Promise<string> => {
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');
@@ -251,3 +328,71 @@ ipcMain.on('remove-installed', async (_event, pkgname: string) => {
});
});
});
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}`)
};
});

View File

@@ -24,6 +24,15 @@
<DownloadDetail :show="showDownloadDetailModal" :download="currentDownload" @close="closeDownloadDetail"
@pause="pauseDownload" @resume="resumeDownload" @cancel="cancelDownload" @retry="retryDownload"
@open-app="openDownloadedApp" />
<InstalledAppsModal :show="showInstalledModal" :apps="installedApps" :loading="installedLoading"
:error="installedError" @close="closeInstalledModal" @refresh="refreshInstalledApps"
@uninstall="uninstallInstalledApp" />
<UpdateAppsModal :show="showUpdateModal" :apps="upgradableApps" :loading="updateLoading"
:error="updateError" :has-selected="hasSelectedUpgrades" @close="closeUpdateModal"
@refresh="refreshUpgradableApps" @toggle-all="toggleAllUpgrades"
@upgrade-selected="upgradeSelectedApps" @upgrade-one="upgradeSingleApp" />
</div>
</template>
@@ -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;

View File

@@ -0,0 +1,148 @@
<template>
<div class="modal-backdrop" :style="{ display: show ? 'flex' : 'none' }" role="dialog" aria-hidden="false">
<div class="modal installed-modal">
<div class="modal-header">
<div class="modal-title-section">
<div class="modal-title">已安装应用</div>
<div class="modal-subtitle">来自本机 APM 安装列表</div>
</div>
<div class="modal-actions">
<button class="action-btn" :disabled="loading" @click="$emit('refresh')">
<i class="fas fa-sync-alt"></i> 刷新
</button>
<button class="close-modal" @click="$emit('close')" aria-label="关闭">×</button>
</div>
</div>
<div class="installed-content">
<div v-if="loading" class="installed-empty">正在读取已安装应用</div>
<div v-else-if="error" class="installed-empty error">{{ error }}</div>
<div v-else-if="apps.length === 0" class="installed-empty">暂无已安装应用</div>
<div v-else class="installed-list">
<div v-for="app in apps" :key="app.pkgname" class="installed-item">
<div class="installed-info">
<div class="installed-name">{{ app.pkgname }}</div>
<div class="installed-meta">
<span>{{ app.version }}</span>
<span class="dot">·</span>
<span>{{ app.arch }}</span>
<span v-if="app.flags" class="dot">·</span>
<span v-if="app.flags">{{ app.flags }}</span>
</div>
</div>
<div class="installed-actions">
<button class="action-btn secondary danger" :disabled="app.removing" @click="$emit('uninstall', app)">
<i class="fas fa-trash"></i>
{{ app.removing ? '卸载中…' : '卸载' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
show: {
type: Boolean,
required: true
},
apps: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
error: {
type: String,
default: ''
}
});
defineEmits(['close', 'refresh', 'uninstall']);
</script>
<style scoped>
.installed-modal {
width: min(900px, calc(100% - 40px));
}
.installed-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.installed-empty {
padding: 32px;
text-align: center;
color: var(--muted);
background: var(--glass);
border-radius: var(--radius);
}
.installed-empty.error {
color: #ef4444;
}
.installed-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.installed-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
border-radius: var(--radius);
background: var(--glass);
box-shadow: var(--shadow);
}
.installed-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.installed-name {
font-weight: 600;
font-size: 16px;
}
.installed-meta {
color: var(--muted);
font-size: 13px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.dot {
opacity: 0.6;
}
.installed-actions {
display: flex;
gap: 8px;
}
.action-btn.danger {
color: #ef4444;
border-color: rgba(239, 68, 68, 0.2);
}
.action-btn.danger:hover {
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div class="modal-backdrop" :style="{ display: show ? 'flex' : 'none' }" role="dialog" aria-hidden="false">
<div class="modal update-modal">
<div class="modal-header">
<div class="modal-title-section">
<div class="modal-title">软件更新</div>
<div class="modal-subtitle">可更新的 APM 应用</div>
</div>
<div class="modal-actions">
<button class="action-btn" :disabled="loading" @click="$emit('refresh')">
<i class="fas fa-sync-alt"></i> 刷新
</button>
<button class="action-btn secondary" :disabled="loading || apps.length === 0" @click="$emit('toggle-all')">
<i class="fas fa-check-square"></i> 全选/全不选
</button>
<button class="apm-btn" :disabled="loading || !hasSelected" @click="$emit('upgrade-selected')">
<i class="fas fa-upload"></i> 更新选中
</button>
<button class="close-modal" @click="$emit('close')" aria-label="关闭">×</button>
</div>
</div>
<div class="update-content">
<div v-if="loading" class="update-empty">正在检查可更新应用</div>
<div v-else-if="error" class="update-empty error">{{ error }}</div>
<div v-else-if="apps.length === 0" class="update-empty">暂无可更新应用</div>
<div v-else class="update-list">
<label v-for="app in apps" :key="app.pkgname" class="update-item">
<input type="checkbox" v-model="app.selected" :disabled="app.upgrading" />
<div class="update-info">
<div class="update-name">{{ app.pkgname }}</div>
<div class="update-meta">
<span>当前 {{ app.currentVersion || '-' }}</span>
<span class="dot">·</span>
<span>更新至 {{ app.newVersion || '-' }}</span>
</div>
</div>
<div class="update-actions">
<button class="action-btn secondary" :disabled="app.upgrading" @click.prevent="$emit('upgrade-one', app)">
<i class="fas fa-arrow-up"></i>
{{ app.upgrading ? '更新中…' : '更新' }}
</button>
</div>
</label>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
show: {
type: Boolean,
required: true
},
apps: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
error: {
type: String,
default: ''
},
hasSelected: {
type: Boolean,
default: false
}
});
defineEmits(['close', 'refresh', 'toggle-all', 'upgrade-selected', 'upgrade-one']);
</script>
<style scoped>
.update-modal {
width: min(920px, calc(100% - 40px));
}
.update-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.update-empty {
padding: 32px;
text-align: center;
color: var(--muted);
background: var(--glass);
border-radius: var(--radius);
}
.update-empty.error {
color: #ef4444;
}
.update-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.update-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: var(--radius);
background: var(--glass);
box-shadow: var(--shadow);
}
.update-item input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.update-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.update-name {
font-weight: 600;
font-size: 15px;
}
.update-meta {
color: var(--muted);
font-size: 13px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.dot {
opacity: 0.6;
}
.update-actions {
display: flex;
align-items: center;
}
</style>

View File

@@ -30,4 +30,5 @@ export interface DownloadItem {
}>;
source: string; // 例如 'APM Store'
retry: boolean; // 当前是否为重试下载
upgradeOnly?: boolean; // 是否为仅升级任务
}

View File

@@ -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);

View File

@@ -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;
}
}