diff --git a/plugins/system-manager/modules/application-uninstaller/public/preload/core/icon-helper.cjs b/plugins/system-manager/modules/application-uninstaller/public/preload/core/icon-helper.cjs new file mode 100644 index 000000000..1bb54e862 --- /dev/null +++ b/plugins/system-manager/modules/application-uninstaller/public/preload/core/icon-helper.cjs @@ -0,0 +1,105 @@ +/** + * 通用 App 图标提取与高保真矢量回退工具 + */ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const iconCache = new Map(); + +function getAppIconDataUrl(appPath) { + if (!appPath || typeof appPath !== 'string') return ''; + if (iconCache.has(appPath)) return iconCache.get(appPath); + + try { + if (process.platform === 'darwin') { + let bundlePath = ''; + if (appPath.includes('.app')) { + const idx = appPath.indexOf('.app'); + bundlePath = appPath.slice(0, idx + 4); + } else if (appPath.endsWith('.app')) { + bundlePath = appPath; + } + + if (!bundlePath || !fs.existsSync(bundlePath)) { + iconCache.set(appPath, ''); + return ''; + } + + const plistPath = path.join(bundlePath, 'Contents', 'Info.plist'); + let iconFileName = ''; + if (fs.existsSync(plistPath)) { + try { + const jsonStr = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'] }); + const plist = JSON.parse(jsonStr); + if (plist && plist.CFBundleIconFile) { + iconFileName = String(plist.CFBundleIconFile); + if (!iconFileName.endsWith('.icns')) iconFileName += '.icns'; + } + } catch {} + } + + const resDir = path.join(bundlePath, 'Contents', 'Resources'); + let icnsPath = ''; + if (iconFileName && fs.existsSync(path.join(resDir, iconFileName))) { + icnsPath = path.join(resDir, iconFileName); + } else if (fs.existsSync(resDir)) { + try { + const files = fs.readdirSync(resDir); + const candidate = files.find(f => f.endsWith('.icns')); + if (candidate) icnsPath = path.join(resDir, candidate); + } catch {} + } + + if (icnsPath && fs.existsSync(icnsPath)) { + const tmpOut = path.join(require('os').tmpdir(), `app_icon_${Date.now()}_${Math.random().toString(36).slice(2, 6)}.png`); + try { + execFileSync('/usr/bin/sips', ['-s', 'format', 'png', icnsPath, '--out', tmpOut, '-z', '48', '48'], { timeout: 1500, stdio: 'ignore' }); + if (fs.existsSync(tmpOut)) { + const buf = fs.readFileSync(tmpOut); + fs.unlinkSync(tmpOut); + const dataUrl = `data:image/png;base64,${buf.toString('base64')}`; + iconCache.set(appPath, dataUrl); + return dataUrl; + } + } catch { + try { if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut); } catch {} + } + } + } + } catch {} + + iconCache.set(appPath, ''); + return ''; +} + +function getLetterSvgIcon(name, category = '') { + const cleanName = (name || '?').trim(); + const letter = (cleanName[0] || '?').toUpperCase(); + + let hash = 0; + for (let i = 0; i < cleanName.length; i++) { + hash = (hash << 5) - hash + cleanName.charCodeAt(i); + hash |= 0; + } + const hues = [210, 260, 290, 340, 160, 180, 25, 45]; + const hue = hues[Math.abs(hash) % hues.length]; + + const svg = ` + + + + + + + + ${letter} + `; + + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +module.exports = { + getAppIconDataUrl, + getLetterSvgIcon +}; diff --git a/plugins/system-manager/modules/application-uninstaller/public/preload/icon-helper.cjs b/plugins/system-manager/modules/application-uninstaller/public/preload/icon-helper.cjs new file mode 100644 index 000000000..6800501fa --- /dev/null +++ b/plugins/system-manager/modules/application-uninstaller/public/preload/icon-helper.cjs @@ -0,0 +1,100 @@ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const iconCache = new Map(); + +function getAppIconDataUrl(appPath) { + if (!appPath || typeof appPath !== 'string') return ''; + if (iconCache.has(appPath)) return iconCache.get(appPath); + + try { + if (process.platform === 'darwin') { + let bundlePath = appPath; + if (!bundlePath.endsWith('.app')) { + const idx = bundlePath.indexOf('.app'); + if (idx !== -1) { + bundlePath = bundlePath.slice(0, idx + 4); + } + } + if (!fs.existsSync(bundlePath)) { + iconCache.set(appPath, ''); + return ''; + } + const plistPath = path.join(bundlePath, 'Contents', 'Info.plist'); + let iconFileName = ''; + if (fs.existsSync(plistPath)) { + try { + const jsonStr = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { encoding: 'utf8', timeout: 800 }); + const plist = JSON.parse(jsonStr); + if (plist.CFBundleIconFile) { + iconFileName = plist.CFBundleIconFile; + if (!iconFileName.endsWith('.icns')) iconFileName += '.icns'; + } + } catch {} + } + + const resDir = path.join(bundlePath, 'Contents', 'Resources'); + let icnsPath = ''; + if (iconFileName && fs.existsSync(path.join(resDir, iconFileName))) { + icnsPath = path.join(resDir, iconFileName); + } else if (fs.existsSync(resDir)) { + try { + const files = fs.readdirSync(resDir); + const candidate = files.find(f => f.endsWith('.icns')); + if (candidate) icnsPath = path.join(resDir, candidate); + } catch {} + } + + if (icnsPath && fs.existsSync(icnsPath)) { + const tmpOut = path.join(require('os').tmpdir(), `app_icon_${Date.now()}_${Math.random().toString(36).slice(2, 6)}.png`); + try { + execFileSync('/usr/bin/sips', ['-s', 'format', 'png', icnsPath, '--out', tmpOut, '-z', '48', '48'], { timeout: 1500, stdio: 'ignore' }); + if (fs.existsSync(tmpOut)) { + const buf = fs.readFileSync(tmpOut); + fs.unlinkSync(tmpOut); + const dataUrl = `data:image/png;base64,${buf.toString('base64')}`; + iconCache.set(appPath, dataUrl); + return dataUrl; + } + } catch { + try { if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut); } catch {} + } + } + } + } catch {} + + iconCache.set(appPath, ''); + return ''; +} + +function getLetterSvgIcon(name, category = '') { + const cleanName = (name || '?').trim(); + const letter = (cleanName[0] || '?').toUpperCase(); + + // Choose harmonic hue from name + let hash = 0; + for (let i = 0; i < cleanName.length; i++) { + hash = (hash << 5) - hash + cleanName.charCodeAt(i); + hash |= 0; + } + const hues = [210, 225, 260, 280, 160, 185, 340, 25]; + const hue = hues[Math.abs(hash) % hues.length]; + + const svg = ` + + + + + + + + ${letter} + `; + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +module.exports = { + getAppIconDataUrl, + getLetterSvgIcon +}; diff --git a/plugins/system-manager/modules/application-uninstaller/public/preload/platform/darwin.cjs b/plugins/system-manager/modules/application-uninstaller/public/preload/platform/darwin.cjs index 2d5d13f4d..74ad10eef 100644 --- a/plugins/system-manager/modules/application-uninstaller/public/preload/platform/darwin.cjs +++ b/plugins/system-manager/modules/application-uninstaller/public/preload/platform/darwin.cjs @@ -1,3 +1,4 @@ +const { getAppIconDataUrl, getLetterSvgIcon } = require('../icon-helper.cjs'); 'use strict' const path = require('node:path') @@ -99,7 +100,8 @@ async function scanApps(ctx) { const key = bundleId || appPath apps.push({ id: opaqueId('app', `darwin:${root.scope}:${appPath}`, ctx.secret), - platform: 'darwin', name, version: cleanMetadataText(plist.CFBundleShortVersionString || plist.CFBundleVersion, '', 120) || null, + platform: 'darwin', + icon: getAppIconDataUrl(appPath) || getLetterSvgIcon(name), name, version: cleanMetadataText(plist.CFBundleShortVersionString || plist.CFBundleVersion, '', 120) || null, publisher: null, appKey: key, bundleId: bundleId || null, install: { kind: 'bundle', path: appPath, scope: root.scope }, uninstall: { diff --git a/plugins/system-manager/modules/application-uninstaller/src/App.vue b/plugins/system-manager/modules/application-uninstaller/src/App.vue index bc7ada83f..7d492c5ae 100644 --- a/plugins/system-manager/modules/application-uninstaller/src/App.vue +++ b/plugins/system-manager/modules/application-uninstaller/src/App.vue @@ -1,26 +1,40 @@ diff --git a/plugins/system-manager/modules/application-uninstaller/src/styles.css b/plugins/system-manager/modules/application-uninstaller/src/styles.css index 03cd0e3dc..de9b0b1c4 100644 --- a/plugins/system-manager/modules/application-uninstaller/src/styles.css +++ b/plugins/system-manager/modules/application-uninstaller/src/styles.css @@ -867,3 +867,213 @@ p { transition-duration: 0.01ms !important; } } + +/* Batch Toolbar & App List Modern Enhancements */ +.batch-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + background: var(--surface-raised, #ffffff); + border: 1px solid var(--line, #e5e7eb); + padding: 12px 18px; + border-radius: 12px; + margin-bottom: 14px; +} + +.batch-left { + display: flex; + align-items: center; + gap: 20px; + flex-wrap: wrap; +} + +.select-all-btn { + display: inline-flex; + align-items: center; + gap: 8px; + background: transparent; + border: 1px solid var(--line, #e5e7eb); + padding: 7px 12px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + color: var(--ink, #1f2937); + transition: all 0.15s ease; +} + +.select-all-btn:hover { + background: var(--surface, #f9fafb); + border-color: var(--accent, #3b82f6); +} + +.check-icon.checked { + color: var(--accent, #3b82f6); +} + +.check-indeterminate { + width: 14px; + height: 14px; + background: var(--accent, #3b82f6); + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; +} + +.data-option-label { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; +} + +.custom-checkbox { + width: 16px; + height: 16px; + accent-color: var(--accent, #3b82f6); + cursor: pointer; +} + +.data-option-text { + display: inline-flex; + align-items: baseline; + gap: 6px; + font-size: 13px; + color: var(--ink, #374151); +} + +.data-option-text small { + color: var(--ink-faint, #9ca3af); + font-size: 12px; +} + +.danger-batch-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 18px; + background: #ef4444; + color: #ffffff; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + box-shadow: 0 2px 6px rgba(239, 68, 68, 0.25); + transition: all 0.2s ease; +} + +.danger-batch-btn:hover:not(:disabled) { + background: #dc2626; + box-shadow: 0 4px 10px rgba(220, 38, 38, 0.35); +} + +.danger-batch-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.app-row { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + background: var(--surface-raised, #ffffff); + border: 1px solid var(--line, #e5e7eb); + border-radius: 10px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.15s ease; +} + +.app-row:hover { + border-color: var(--accent, #3b82f6); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.app-row.selected { + background: #f0f7ff; + border-color: var(--accent, #3b82f6); +} + +.row-checkbox { + display: flex; + align-items: center; + justify-content: center; +} + +.row-check { + color: var(--ink-faint, #9ca3af); +} + +.row-check.checked { + color: var(--accent, #3b82f6); +} + +.app-icon-wrap { + width: 40px; + height: 40px; + border-radius: 10px; + background: #f3f4f6; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; +} + +.real-app-icon { + width: 36px; + height: 36px; + object-fit: contain; +} + +.app-copy { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.app-title-row { + display: flex; + align-items: center; + gap: 8px; +} + +.app-name-text { + font-size: 14px; + font-weight: 600; + color: var(--ink, #111827); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-sub-text { + font-size: 12px; + color: var(--ink-faint, #6b7280); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.row-tail { + flex-shrink: 0; +} + +.data-mode-tag { + font-size: 11px; + padding: 3px 8px; + border-radius: 6px; + background: #ecfdf5; + color: #059669; + font-weight: 500; +} + +.data-mode-tag.tag-clean { + background: #fef2f2; + color: #dc2626; +} diff --git a/plugins/system-manager/modules/startup-manager/public/preload/adapters/darwin.cjs b/plugins/system-manager/modules/startup-manager/public/preload/adapters/darwin.cjs index ac6c2bb8c..0090b48ed 100644 --- a/plugins/system-manager/modules/startup-manager/public/preload/adapters/darwin.cjs +++ b/plugins/system-manager/modules/startup-manager/public/preload/adapters/darwin.cjs @@ -1,3 +1,4 @@ +const { getAppIconDataUrl, getLetterSvgIcon } = require('../icon-helper.cjs'); 'use strict' const fs = require('node:fs/promises') @@ -140,6 +141,7 @@ async function scan(deps = {}) { commandSummary: command, enabled, running, status: running ? 'running' : enabled ? 'idle' : 'disabled', action: { canToggle: false, requiresElevation: location.scope === 'system', reason: !labelValid ? 'LaunchAgent Label 为空、过长或包含控制字符,当前仅支持查看' : isApple ? '系统项目仅支持查看' : location.scope === 'user' ? '无法可信绑定当前 launchd 服务与来源 plist,当前仅支持查看' : '系统域项目需要管理员权限,当前仅查看' }, metadata: { description: safeBaseName(file), serviceType: plist.KeepAlive ? 'persistent' : 'on-demand' }, + icon: getAppIconDataUrl(file) || (command ? getAppIconDataUrl(command.split(' ')[0]) : '') || getLetterSvgIcon(label), internal: { label }, }, home) }, deadlineAt) diff --git a/plugins/system-manager/modules/startup-manager/public/preload/core/icon-helper.cjs b/plugins/system-manager/modules/startup-manager/public/preload/core/icon-helper.cjs new file mode 100644 index 000000000..1bb54e862 --- /dev/null +++ b/plugins/system-manager/modules/startup-manager/public/preload/core/icon-helper.cjs @@ -0,0 +1,105 @@ +/** + * 通用 App 图标提取与高保真矢量回退工具 + */ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const iconCache = new Map(); + +function getAppIconDataUrl(appPath) { + if (!appPath || typeof appPath !== 'string') return ''; + if (iconCache.has(appPath)) return iconCache.get(appPath); + + try { + if (process.platform === 'darwin') { + let bundlePath = ''; + if (appPath.includes('.app')) { + const idx = appPath.indexOf('.app'); + bundlePath = appPath.slice(0, idx + 4); + } else if (appPath.endsWith('.app')) { + bundlePath = appPath; + } + + if (!bundlePath || !fs.existsSync(bundlePath)) { + iconCache.set(appPath, ''); + return ''; + } + + const plistPath = path.join(bundlePath, 'Contents', 'Info.plist'); + let iconFileName = ''; + if (fs.existsSync(plistPath)) { + try { + const jsonStr = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'] }); + const plist = JSON.parse(jsonStr); + if (plist && plist.CFBundleIconFile) { + iconFileName = String(plist.CFBundleIconFile); + if (!iconFileName.endsWith('.icns')) iconFileName += '.icns'; + } + } catch {} + } + + const resDir = path.join(bundlePath, 'Contents', 'Resources'); + let icnsPath = ''; + if (iconFileName && fs.existsSync(path.join(resDir, iconFileName))) { + icnsPath = path.join(resDir, iconFileName); + } else if (fs.existsSync(resDir)) { + try { + const files = fs.readdirSync(resDir); + const candidate = files.find(f => f.endsWith('.icns')); + if (candidate) icnsPath = path.join(resDir, candidate); + } catch {} + } + + if (icnsPath && fs.existsSync(icnsPath)) { + const tmpOut = path.join(require('os').tmpdir(), `app_icon_${Date.now()}_${Math.random().toString(36).slice(2, 6)}.png`); + try { + execFileSync('/usr/bin/sips', ['-s', 'format', 'png', icnsPath, '--out', tmpOut, '-z', '48', '48'], { timeout: 1500, stdio: 'ignore' }); + if (fs.existsSync(tmpOut)) { + const buf = fs.readFileSync(tmpOut); + fs.unlinkSync(tmpOut); + const dataUrl = `data:image/png;base64,${buf.toString('base64')}`; + iconCache.set(appPath, dataUrl); + return dataUrl; + } + } catch { + try { if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut); } catch {} + } + } + } + } catch {} + + iconCache.set(appPath, ''); + return ''; +} + +function getLetterSvgIcon(name, category = '') { + const cleanName = (name || '?').trim(); + const letter = (cleanName[0] || '?').toUpperCase(); + + let hash = 0; + for (let i = 0; i < cleanName.length; i++) { + hash = (hash << 5) - hash + cleanName.charCodeAt(i); + hash |= 0; + } + const hues = [210, 260, 290, 340, 160, 180, 25, 45]; + const hue = hues[Math.abs(hash) % hues.length]; + + const svg = ` + + + + + + + + ${letter} + `; + + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +module.exports = { + getAppIconDataUrl, + getLetterSvgIcon +}; diff --git a/plugins/system-manager/modules/startup-manager/public/preload/core/model.cjs b/plugins/system-manager/modules/startup-manager/public/preload/core/model.cjs index 557a5df64..146f8e0b7 100644 --- a/plugins/system-manager/modules/startup-manager/public/preload/core/model.cjs +++ b/plugins/system-manager/modules/startup-manager/public/preload/core/model.cjs @@ -73,6 +73,7 @@ function createItem(input, home) { requiresElevation: Boolean(action.requiresElevation), reason: cleanText(action.reason, 200), }, + icon: typeof input.icon === 'string' ? input.icon : null, metadata: sanitizeMetadata(input.metadata || {}), internal: input.internal || {}, } @@ -80,7 +81,7 @@ function createItem(input, home) { function publicItem(item, id) { const { key, internal, metadata, ...safe } = item - return { id, ...safe, metadata: sanitizeMetadata(metadata) } + return { id, icon: item.icon || null, ...safe, metadata: sanitizeMetadata(metadata) } } function sanitizeMetadata(metadata) { diff --git a/plugins/system-manager/modules/startup-manager/public/preload/icon-helper.cjs b/plugins/system-manager/modules/startup-manager/public/preload/icon-helper.cjs new file mode 100644 index 000000000..6800501fa --- /dev/null +++ b/plugins/system-manager/modules/startup-manager/public/preload/icon-helper.cjs @@ -0,0 +1,100 @@ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const iconCache = new Map(); + +function getAppIconDataUrl(appPath) { + if (!appPath || typeof appPath !== 'string') return ''; + if (iconCache.has(appPath)) return iconCache.get(appPath); + + try { + if (process.platform === 'darwin') { + let bundlePath = appPath; + if (!bundlePath.endsWith('.app')) { + const idx = bundlePath.indexOf('.app'); + if (idx !== -1) { + bundlePath = bundlePath.slice(0, idx + 4); + } + } + if (!fs.existsSync(bundlePath)) { + iconCache.set(appPath, ''); + return ''; + } + const plistPath = path.join(bundlePath, 'Contents', 'Info.plist'); + let iconFileName = ''; + if (fs.existsSync(plistPath)) { + try { + const jsonStr = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { encoding: 'utf8', timeout: 800 }); + const plist = JSON.parse(jsonStr); + if (plist.CFBundleIconFile) { + iconFileName = plist.CFBundleIconFile; + if (!iconFileName.endsWith('.icns')) iconFileName += '.icns'; + } + } catch {} + } + + const resDir = path.join(bundlePath, 'Contents', 'Resources'); + let icnsPath = ''; + if (iconFileName && fs.existsSync(path.join(resDir, iconFileName))) { + icnsPath = path.join(resDir, iconFileName); + } else if (fs.existsSync(resDir)) { + try { + const files = fs.readdirSync(resDir); + const candidate = files.find(f => f.endsWith('.icns')); + if (candidate) icnsPath = path.join(resDir, candidate); + } catch {} + } + + if (icnsPath && fs.existsSync(icnsPath)) { + const tmpOut = path.join(require('os').tmpdir(), `app_icon_${Date.now()}_${Math.random().toString(36).slice(2, 6)}.png`); + try { + execFileSync('/usr/bin/sips', ['-s', 'format', 'png', icnsPath, '--out', tmpOut, '-z', '48', '48'], { timeout: 1500, stdio: 'ignore' }); + if (fs.existsSync(tmpOut)) { + const buf = fs.readFileSync(tmpOut); + fs.unlinkSync(tmpOut); + const dataUrl = `data:image/png;base64,${buf.toString('base64')}`; + iconCache.set(appPath, dataUrl); + return dataUrl; + } + } catch { + try { if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut); } catch {} + } + } + } + } catch {} + + iconCache.set(appPath, ''); + return ''; +} + +function getLetterSvgIcon(name, category = '') { + const cleanName = (name || '?').trim(); + const letter = (cleanName[0] || '?').toUpperCase(); + + // Choose harmonic hue from name + let hash = 0; + for (let i = 0; i < cleanName.length; i++) { + hash = (hash << 5) - hash + cleanName.charCodeAt(i); + hash |= 0; + } + const hues = [210, 225, 260, 280, 160, 185, 340, 25]; + const hue = hues[Math.abs(hash) % hues.length]; + + const svg = ` + + + + + + + + ${letter} + `; + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +module.exports = { + getAppIconDataUrl, + getLetterSvgIcon +}; diff --git a/plugins/system-manager/modules/startup-manager/src/App.vue b/plugins/system-manager/modules/startup-manager/src/App.vue index 4e376996e..63131c157 100644 --- a/plugins/system-manager/modules/startup-manager/src/App.vue +++ b/plugins/system-manager/modules/startup-manager/src/App.vue @@ -1,7 +1,8 @@