diff --git a/plugins/system-manager/modules/application-uninstaller/public/preload/core/engine.cjs b/plugins/system-manager/modules/application-uninstaller/public/preload/core/engine.cjs index 418e5e2d4..e777576d8 100644 --- a/plugins/system-manager/modules/application-uninstaller/public/preload/core/engine.cjs +++ b/plugins/system-manager/modules/application-uninstaller/public/preload/core/engine.cjs @@ -21,6 +21,7 @@ function publicApp(app) { return { id: app.id, platform: app.platform, name: app.name, version: app.version, publisher: app.publisher, install: app.install, uninstall: app.uninstall, protected: app.protected, + icon: app.icon || null, } } 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..bf42c89e2 --- /dev/null +++ b/plugins/system-manager/modules/application-uninstaller/public/preload/core/icon-helper.cjs @@ -0,0 +1,233 @@ +/** + * 通用 App 图标提取与高保真矢量回退工具 + */ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const iconCache = new Map(); +let bundleIndex = null; + +function normalizeKey(str) { + if (!str || typeof str !== 'string') return ''; + return str.toLowerCase() + .replace(/^(com|org|net|io)\.[^.]+\./i, '') + .replace(/[\s\-_.]+/g, '') + .trim(); +} + +function buildBundleIndex() { + if (bundleIndex) return bundleIndex; + bundleIndex = new Map(); + + const appDirs = [ + '/Applications', + '/System/Applications', + '/System/Applications/Utilities', + path.join(os.homedir(), 'Applications') + ]; + + for (const dir of appDirs) { + if (!fs.existsSync(dir)) continue; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + if (!file.endsWith('.app')) continue; + const appPath = path.join(dir, file); + const appName = file.slice(0, -4); + + // 索引直接应用名称 + bundleIndex.set(appName.toLowerCase(), appPath); + const normName = normalizeKey(appName); + if (normName && normName.length >= 2) bundleIndex.set(normName, appPath); + + const plistPath = path.join(appPath, 'Contents/Info.plist'); + if (fs.existsSync(plistPath)) { + try { + const out = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 600 + }); + const plist = JSON.parse(out); + if (plist.CFBundleIdentifier) { + const bundleId = plist.CFBundleIdentifier.toLowerCase(); + bundleIndex.set(bundleId, appPath); + const normBundle = normalizeKey(bundleId); + if (normBundle && normBundle.length >= 2) bundleIndex.set(normBundle, appPath); + + const sub = bundleId.split('.').pop(); + // Don't index generic words like 'desktop', 'agent', 'helper', 'client', 'app' + const genericWords = new Set(['desktop', 'agent', 'helper', 'client', 'app', 'service', 'launcher', 'daemon', 'updater']); + if (sub && sub.length >= 3 && !genericWords.has(sub.toLowerCase())) { + bundleIndex.set(sub, appPath); + } + } + if (plist.CFBundleName) { + bundleIndex.set(plist.CFBundleName.toLowerCase(), appPath); + const normCb = normalizeKey(plist.CFBundleName); + if (normCb && normCb.length >= 2) bundleIndex.set(normCb, appPath); + } + } catch {} + } + } + } catch {} + } + return bundleIndex; +} + +function resolveAppPath(query) { + if (!query || typeof query !== 'string') return null; + const trimmed = query.trim(); + if (!trimmed) return null; + + if (trimmed.includes('/') && fs.existsSync(trimmed)) { + let curr = trimmed; + while (curr && curr !== '/' && curr !== '.') { + if (curr.endsWith('.app')) return curr; + curr = path.dirname(curr); + } + } + + const idx = buildBundleIndex(); + const lower = trimmed.toLowerCase(); + if (idx.has(lower)) return idx.get(lower); + + const cleanQuery = normalizeKey(lower); + if (cleanQuery && cleanQuery.length >= 3) { + if (idx.has(cleanQuery)) return idx.get(cleanQuery); + for (const [key, appPath] of idx.entries()) { + const cleanKey = normalizeKey(key); + if (cleanKey && cleanKey.length >= 3) { + if (cleanKey === cleanQuery) { + return appPath; + } + } + } + } + + // 尝试按点分反向解析父级 Bundle ID(如 com.figma.Desktop.ShipIt -> com.figma.Desktop) + if (trimmed.includes('.')) { + const parts = trimmed.split('.'); + while (parts.length > 2) { + parts.pop(); + const parentQuery = parts.join('.'); + const pLower = parentQuery.toLowerCase(); + if (idx.has(pLower)) return idx.get(pLower); + const pClean = normalizeKey(pLower); + if (pClean && idx.has(pClean)) return idx.get(pClean); + } + } + + return null; +} + +function extractDarwinIcon(appPath) { + if (!appPath || typeof appPath !== 'string') return null; + if (iconCache.has(appPath)) return iconCache.get(appPath); + + try { + const resourcesDir = path.join(appPath, 'Contents/Resources'); + if (!fs.existsSync(resourcesDir)) { + iconCache.set(appPath, null); + return null; + } + + let iconFileName = null; + const plistPath = path.join(appPath, 'Contents/Info.plist'); + if (fs.existsSync(plistPath)) { + try { + const out = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 600 + }); + const plist = JSON.parse(out); + if (plist.CFBundleIconFile) { + iconFileName = plist.CFBundleIconFile.endsWith('.icns') ? plist.CFBundleIconFile : plist.CFBundleIconFile + '.icns'; + } + } catch {} + } + + if (!iconFileName) { + const files = fs.readdirSync(resourcesDir); + const icns = files.find(f => f.endsWith('.icns')); + if (icns) iconFileName = icns; + } + + if (!iconFileName) { + iconCache.set(appPath, null); + return null; + } + + const icnsPath = path.join(resourcesDir, iconFileName); + if (!fs.existsSync(icnsPath)) { + iconCache.set(appPath, null); + return null; + } + + const tmpOut = path.join(os.tmpdir(), 'ztools-icon-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '.png'); + execFileSync('/usr/bin/sips', ['-s', 'format', 'png', icnsPath, '--out', tmpOut, '-z', '48', '48'], { + stdio: ['ignore', 'ignore', 'ignore'], + timeout: 1500 + }); + + if (fs.existsSync(tmpOut)) { + const buf = fs.readFileSync(tmpOut); + try { fs.unlinkSync(tmpOut); } catch {} + const dataUrl = 'data:image/png;base64,' + buf.toString('base64'); + iconCache.set(appPath, dataUrl); + return dataUrl; + } + } catch {} + + iconCache.set(appPath, null); + return null; +} + +function getAppIconDataUrl(appNameOrPath) { + if (!appNameOrPath) return ''; + const resolved = resolveAppPath(appNameOrPath); + if (resolved) { + const icon = extractDarwinIcon(resolved); + if (icon) return icon; + } + return ''; +} + +function getLetterSvgIcon(name) { + const char = (name || '?').replace(/^[._]/, '').trim().charAt(0).toUpperCase() || '?'; + const colors = [ + ['#3b82f6', '#1d4ed8'], + ['#10b981', '#047857'], + ['#8b5cf6', '#6d28d9'], + ['#f59e0b', '#d97706'], + ['#ec4899', '#be185d'], + ['#06b6d4', '#0e7490'] + ]; + const idx = Math.abs((char.codePointAt(0) || 0) % colors.length); + const [c1, c2] = colors[idx]; + + const svg = '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + char + + '' + + ''; + + return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg); +} + +module.exports = { + resolveAppPath, + extractDarwinIcon, + 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..bf42c89e2 --- /dev/null +++ b/plugins/system-manager/modules/application-uninstaller/public/preload/icon-helper.cjs @@ -0,0 +1,233 @@ +/** + * 通用 App 图标提取与高保真矢量回退工具 + */ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const iconCache = new Map(); +let bundleIndex = null; + +function normalizeKey(str) { + if (!str || typeof str !== 'string') return ''; + return str.toLowerCase() + .replace(/^(com|org|net|io)\.[^.]+\./i, '') + .replace(/[\s\-_.]+/g, '') + .trim(); +} + +function buildBundleIndex() { + if (bundleIndex) return bundleIndex; + bundleIndex = new Map(); + + const appDirs = [ + '/Applications', + '/System/Applications', + '/System/Applications/Utilities', + path.join(os.homedir(), 'Applications') + ]; + + for (const dir of appDirs) { + if (!fs.existsSync(dir)) continue; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + if (!file.endsWith('.app')) continue; + const appPath = path.join(dir, file); + const appName = file.slice(0, -4); + + // 索引直接应用名称 + bundleIndex.set(appName.toLowerCase(), appPath); + const normName = normalizeKey(appName); + if (normName && normName.length >= 2) bundleIndex.set(normName, appPath); + + const plistPath = path.join(appPath, 'Contents/Info.plist'); + if (fs.existsSync(plistPath)) { + try { + const out = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 600 + }); + const plist = JSON.parse(out); + if (plist.CFBundleIdentifier) { + const bundleId = plist.CFBundleIdentifier.toLowerCase(); + bundleIndex.set(bundleId, appPath); + const normBundle = normalizeKey(bundleId); + if (normBundle && normBundle.length >= 2) bundleIndex.set(normBundle, appPath); + + const sub = bundleId.split('.').pop(); + // Don't index generic words like 'desktop', 'agent', 'helper', 'client', 'app' + const genericWords = new Set(['desktop', 'agent', 'helper', 'client', 'app', 'service', 'launcher', 'daemon', 'updater']); + if (sub && sub.length >= 3 && !genericWords.has(sub.toLowerCase())) { + bundleIndex.set(sub, appPath); + } + } + if (plist.CFBundleName) { + bundleIndex.set(plist.CFBundleName.toLowerCase(), appPath); + const normCb = normalizeKey(plist.CFBundleName); + if (normCb && normCb.length >= 2) bundleIndex.set(normCb, appPath); + } + } catch {} + } + } + } catch {} + } + return bundleIndex; +} + +function resolveAppPath(query) { + if (!query || typeof query !== 'string') return null; + const trimmed = query.trim(); + if (!trimmed) return null; + + if (trimmed.includes('/') && fs.existsSync(trimmed)) { + let curr = trimmed; + while (curr && curr !== '/' && curr !== '.') { + if (curr.endsWith('.app')) return curr; + curr = path.dirname(curr); + } + } + + const idx = buildBundleIndex(); + const lower = trimmed.toLowerCase(); + if (idx.has(lower)) return idx.get(lower); + + const cleanQuery = normalizeKey(lower); + if (cleanQuery && cleanQuery.length >= 3) { + if (idx.has(cleanQuery)) return idx.get(cleanQuery); + for (const [key, appPath] of idx.entries()) { + const cleanKey = normalizeKey(key); + if (cleanKey && cleanKey.length >= 3) { + if (cleanKey === cleanQuery) { + return appPath; + } + } + } + } + + // 尝试按点分反向解析父级 Bundle ID(如 com.figma.Desktop.ShipIt -> com.figma.Desktop) + if (trimmed.includes('.')) { + const parts = trimmed.split('.'); + while (parts.length > 2) { + parts.pop(); + const parentQuery = parts.join('.'); + const pLower = parentQuery.toLowerCase(); + if (idx.has(pLower)) return idx.get(pLower); + const pClean = normalizeKey(pLower); + if (pClean && idx.has(pClean)) return idx.get(pClean); + } + } + + return null; +} + +function extractDarwinIcon(appPath) { + if (!appPath || typeof appPath !== 'string') return null; + if (iconCache.has(appPath)) return iconCache.get(appPath); + + try { + const resourcesDir = path.join(appPath, 'Contents/Resources'); + if (!fs.existsSync(resourcesDir)) { + iconCache.set(appPath, null); + return null; + } + + let iconFileName = null; + const plistPath = path.join(appPath, 'Contents/Info.plist'); + if (fs.existsSync(plistPath)) { + try { + const out = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 600 + }); + const plist = JSON.parse(out); + if (plist.CFBundleIconFile) { + iconFileName = plist.CFBundleIconFile.endsWith('.icns') ? plist.CFBundleIconFile : plist.CFBundleIconFile + '.icns'; + } + } catch {} + } + + if (!iconFileName) { + const files = fs.readdirSync(resourcesDir); + const icns = files.find(f => f.endsWith('.icns')); + if (icns) iconFileName = icns; + } + + if (!iconFileName) { + iconCache.set(appPath, null); + return null; + } + + const icnsPath = path.join(resourcesDir, iconFileName); + if (!fs.existsSync(icnsPath)) { + iconCache.set(appPath, null); + return null; + } + + const tmpOut = path.join(os.tmpdir(), 'ztools-icon-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '.png'); + execFileSync('/usr/bin/sips', ['-s', 'format', 'png', icnsPath, '--out', tmpOut, '-z', '48', '48'], { + stdio: ['ignore', 'ignore', 'ignore'], + timeout: 1500 + }); + + if (fs.existsSync(tmpOut)) { + const buf = fs.readFileSync(tmpOut); + try { fs.unlinkSync(tmpOut); } catch {} + const dataUrl = 'data:image/png;base64,' + buf.toString('base64'); + iconCache.set(appPath, dataUrl); + return dataUrl; + } + } catch {} + + iconCache.set(appPath, null); + return null; +} + +function getAppIconDataUrl(appNameOrPath) { + if (!appNameOrPath) return ''; + const resolved = resolveAppPath(appNameOrPath); + if (resolved) { + const icon = extractDarwinIcon(resolved); + if (icon) return icon; + } + return ''; +} + +function getLetterSvgIcon(name) { + const char = (name || '?').replace(/^[._]/, '').trim().charAt(0).toUpperCase() || '?'; + const colors = [ + ['#3b82f6', '#1d4ed8'], + ['#10b981', '#047857'], + ['#8b5cf6', '#6d28d9'], + ['#f59e0b', '#d97706'], + ['#ec4899', '#be185d'], + ['#06b6d4', '#0e7490'] + ]; + const idx = Math.abs((char.codePointAt(0) || 0) % colors.length); + const [c1, c2] = colors[idx]; + + const svg = '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + char + + '' + + ''; + + return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg); +} + +module.exports = { + resolveAppPath, + extractDarwinIcon, + 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..e109a44c3 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) @@ -150,7 +152,7 @@ async function scan(deps = {}) { if (result && result.ok) items.push(result.value) else { const record = records[index] - if (record) warnings.push(`${safeBaseName(record.file)}:无法在时限内读取或解析`) + if (record && !record.file.includes("jetsamproperties")) warnings.push(`${safeBaseName(record.file)}:无法在时限内读取或解析`) } }) const labelOrigins = new Map() 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..bf42c89e2 --- /dev/null +++ b/plugins/system-manager/modules/startup-manager/public/preload/core/icon-helper.cjs @@ -0,0 +1,233 @@ +/** + * 通用 App 图标提取与高保真矢量回退工具 + */ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const iconCache = new Map(); +let bundleIndex = null; + +function normalizeKey(str) { + if (!str || typeof str !== 'string') return ''; + return str.toLowerCase() + .replace(/^(com|org|net|io)\.[^.]+\./i, '') + .replace(/[\s\-_.]+/g, '') + .trim(); +} + +function buildBundleIndex() { + if (bundleIndex) return bundleIndex; + bundleIndex = new Map(); + + const appDirs = [ + '/Applications', + '/System/Applications', + '/System/Applications/Utilities', + path.join(os.homedir(), 'Applications') + ]; + + for (const dir of appDirs) { + if (!fs.existsSync(dir)) continue; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + if (!file.endsWith('.app')) continue; + const appPath = path.join(dir, file); + const appName = file.slice(0, -4); + + // 索引直接应用名称 + bundleIndex.set(appName.toLowerCase(), appPath); + const normName = normalizeKey(appName); + if (normName && normName.length >= 2) bundleIndex.set(normName, appPath); + + const plistPath = path.join(appPath, 'Contents/Info.plist'); + if (fs.existsSync(plistPath)) { + try { + const out = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 600 + }); + const plist = JSON.parse(out); + if (plist.CFBundleIdentifier) { + const bundleId = plist.CFBundleIdentifier.toLowerCase(); + bundleIndex.set(bundleId, appPath); + const normBundle = normalizeKey(bundleId); + if (normBundle && normBundle.length >= 2) bundleIndex.set(normBundle, appPath); + + const sub = bundleId.split('.').pop(); + // Don't index generic words like 'desktop', 'agent', 'helper', 'client', 'app' + const genericWords = new Set(['desktop', 'agent', 'helper', 'client', 'app', 'service', 'launcher', 'daemon', 'updater']); + if (sub && sub.length >= 3 && !genericWords.has(sub.toLowerCase())) { + bundleIndex.set(sub, appPath); + } + } + if (plist.CFBundleName) { + bundleIndex.set(plist.CFBundleName.toLowerCase(), appPath); + const normCb = normalizeKey(plist.CFBundleName); + if (normCb && normCb.length >= 2) bundleIndex.set(normCb, appPath); + } + } catch {} + } + } + } catch {} + } + return bundleIndex; +} + +function resolveAppPath(query) { + if (!query || typeof query !== 'string') return null; + const trimmed = query.trim(); + if (!trimmed) return null; + + if (trimmed.includes('/') && fs.existsSync(trimmed)) { + let curr = trimmed; + while (curr && curr !== '/' && curr !== '.') { + if (curr.endsWith('.app')) return curr; + curr = path.dirname(curr); + } + } + + const idx = buildBundleIndex(); + const lower = trimmed.toLowerCase(); + if (idx.has(lower)) return idx.get(lower); + + const cleanQuery = normalizeKey(lower); + if (cleanQuery && cleanQuery.length >= 3) { + if (idx.has(cleanQuery)) return idx.get(cleanQuery); + for (const [key, appPath] of idx.entries()) { + const cleanKey = normalizeKey(key); + if (cleanKey && cleanKey.length >= 3) { + if (cleanKey === cleanQuery) { + return appPath; + } + } + } + } + + // 尝试按点分反向解析父级 Bundle ID(如 com.figma.Desktop.ShipIt -> com.figma.Desktop) + if (trimmed.includes('.')) { + const parts = trimmed.split('.'); + while (parts.length > 2) { + parts.pop(); + const parentQuery = parts.join('.'); + const pLower = parentQuery.toLowerCase(); + if (idx.has(pLower)) return idx.get(pLower); + const pClean = normalizeKey(pLower); + if (pClean && idx.has(pClean)) return idx.get(pClean); + } + } + + return null; +} + +function extractDarwinIcon(appPath) { + if (!appPath || typeof appPath !== 'string') return null; + if (iconCache.has(appPath)) return iconCache.get(appPath); + + try { + const resourcesDir = path.join(appPath, 'Contents/Resources'); + if (!fs.existsSync(resourcesDir)) { + iconCache.set(appPath, null); + return null; + } + + let iconFileName = null; + const plistPath = path.join(appPath, 'Contents/Info.plist'); + if (fs.existsSync(plistPath)) { + try { + const out = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 600 + }); + const plist = JSON.parse(out); + if (plist.CFBundleIconFile) { + iconFileName = plist.CFBundleIconFile.endsWith('.icns') ? plist.CFBundleIconFile : plist.CFBundleIconFile + '.icns'; + } + } catch {} + } + + if (!iconFileName) { + const files = fs.readdirSync(resourcesDir); + const icns = files.find(f => f.endsWith('.icns')); + if (icns) iconFileName = icns; + } + + if (!iconFileName) { + iconCache.set(appPath, null); + return null; + } + + const icnsPath = path.join(resourcesDir, iconFileName); + if (!fs.existsSync(icnsPath)) { + iconCache.set(appPath, null); + return null; + } + + const tmpOut = path.join(os.tmpdir(), 'ztools-icon-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '.png'); + execFileSync('/usr/bin/sips', ['-s', 'format', 'png', icnsPath, '--out', tmpOut, '-z', '48', '48'], { + stdio: ['ignore', 'ignore', 'ignore'], + timeout: 1500 + }); + + if (fs.existsSync(tmpOut)) { + const buf = fs.readFileSync(tmpOut); + try { fs.unlinkSync(tmpOut); } catch {} + const dataUrl = 'data:image/png;base64,' + buf.toString('base64'); + iconCache.set(appPath, dataUrl); + return dataUrl; + } + } catch {} + + iconCache.set(appPath, null); + return null; +} + +function getAppIconDataUrl(appNameOrPath) { + if (!appNameOrPath) return ''; + const resolved = resolveAppPath(appNameOrPath); + if (resolved) { + const icon = extractDarwinIcon(resolved); + if (icon) return icon; + } + return ''; +} + +function getLetterSvgIcon(name) { + const char = (name || '?').replace(/^[._]/, '').trim().charAt(0).toUpperCase() || '?'; + const colors = [ + ['#3b82f6', '#1d4ed8'], + ['#10b981', '#047857'], + ['#8b5cf6', '#6d28d9'], + ['#f59e0b', '#d97706'], + ['#ec4899', '#be185d'], + ['#06b6d4', '#0e7490'] + ]; + const idx = Math.abs((char.codePointAt(0) || 0) % colors.length); + const [c1, c2] = colors[idx]; + + const svg = '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + char + + '' + + ''; + + return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg); +} + +module.exports = { + resolveAppPath, + extractDarwinIcon, + 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..bf42c89e2 --- /dev/null +++ b/plugins/system-manager/modules/startup-manager/public/preload/icon-helper.cjs @@ -0,0 +1,233 @@ +/** + * 通用 App 图标提取与高保真矢量回退工具 + */ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const iconCache = new Map(); +let bundleIndex = null; + +function normalizeKey(str) { + if (!str || typeof str !== 'string') return ''; + return str.toLowerCase() + .replace(/^(com|org|net|io)\.[^.]+\./i, '') + .replace(/[\s\-_.]+/g, '') + .trim(); +} + +function buildBundleIndex() { + if (bundleIndex) return bundleIndex; + bundleIndex = new Map(); + + const appDirs = [ + '/Applications', + '/System/Applications', + '/System/Applications/Utilities', + path.join(os.homedir(), 'Applications') + ]; + + for (const dir of appDirs) { + if (!fs.existsSync(dir)) continue; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + if (!file.endsWith('.app')) continue; + const appPath = path.join(dir, file); + const appName = file.slice(0, -4); + + // 索引直接应用名称 + bundleIndex.set(appName.toLowerCase(), appPath); + const normName = normalizeKey(appName); + if (normName && normName.length >= 2) bundleIndex.set(normName, appPath); + + const plistPath = path.join(appPath, 'Contents/Info.plist'); + if (fs.existsSync(plistPath)) { + try { + const out = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 600 + }); + const plist = JSON.parse(out); + if (plist.CFBundleIdentifier) { + const bundleId = plist.CFBundleIdentifier.toLowerCase(); + bundleIndex.set(bundleId, appPath); + const normBundle = normalizeKey(bundleId); + if (normBundle && normBundle.length >= 2) bundleIndex.set(normBundle, appPath); + + const sub = bundleId.split('.').pop(); + // Don't index generic words like 'desktop', 'agent', 'helper', 'client', 'app' + const genericWords = new Set(['desktop', 'agent', 'helper', 'client', 'app', 'service', 'launcher', 'daemon', 'updater']); + if (sub && sub.length >= 3 && !genericWords.has(sub.toLowerCase())) { + bundleIndex.set(sub, appPath); + } + } + if (plist.CFBundleName) { + bundleIndex.set(plist.CFBundleName.toLowerCase(), appPath); + const normCb = normalizeKey(plist.CFBundleName); + if (normCb && normCb.length >= 2) bundleIndex.set(normCb, appPath); + } + } catch {} + } + } + } catch {} + } + return bundleIndex; +} + +function resolveAppPath(query) { + if (!query || typeof query !== 'string') return null; + const trimmed = query.trim(); + if (!trimmed) return null; + + if (trimmed.includes('/') && fs.existsSync(trimmed)) { + let curr = trimmed; + while (curr && curr !== '/' && curr !== '.') { + if (curr.endsWith('.app')) return curr; + curr = path.dirname(curr); + } + } + + const idx = buildBundleIndex(); + const lower = trimmed.toLowerCase(); + if (idx.has(lower)) return idx.get(lower); + + const cleanQuery = normalizeKey(lower); + if (cleanQuery && cleanQuery.length >= 3) { + if (idx.has(cleanQuery)) return idx.get(cleanQuery); + for (const [key, appPath] of idx.entries()) { + const cleanKey = normalizeKey(key); + if (cleanKey && cleanKey.length >= 3) { + if (cleanKey === cleanQuery) { + return appPath; + } + } + } + } + + // 尝试按点分反向解析父级 Bundle ID(如 com.figma.Desktop.ShipIt -> com.figma.Desktop) + if (trimmed.includes('.')) { + const parts = trimmed.split('.'); + while (parts.length > 2) { + parts.pop(); + const parentQuery = parts.join('.'); + const pLower = parentQuery.toLowerCase(); + if (idx.has(pLower)) return idx.get(pLower); + const pClean = normalizeKey(pLower); + if (pClean && idx.has(pClean)) return idx.get(pClean); + } + } + + return null; +} + +function extractDarwinIcon(appPath) { + if (!appPath || typeof appPath !== 'string') return null; + if (iconCache.has(appPath)) return iconCache.get(appPath); + + try { + const resourcesDir = path.join(appPath, 'Contents/Resources'); + if (!fs.existsSync(resourcesDir)) { + iconCache.set(appPath, null); + return null; + } + + let iconFileName = null; + const plistPath = path.join(appPath, 'Contents/Info.plist'); + if (fs.existsSync(plistPath)) { + try { + const out = execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 600 + }); + const plist = JSON.parse(out); + if (plist.CFBundleIconFile) { + iconFileName = plist.CFBundleIconFile.endsWith('.icns') ? plist.CFBundleIconFile : plist.CFBundleIconFile + '.icns'; + } + } catch {} + } + + if (!iconFileName) { + const files = fs.readdirSync(resourcesDir); + const icns = files.find(f => f.endsWith('.icns')); + if (icns) iconFileName = icns; + } + + if (!iconFileName) { + iconCache.set(appPath, null); + return null; + } + + const icnsPath = path.join(resourcesDir, iconFileName); + if (!fs.existsSync(icnsPath)) { + iconCache.set(appPath, null); + return null; + } + + const tmpOut = path.join(os.tmpdir(), 'ztools-icon-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '.png'); + execFileSync('/usr/bin/sips', ['-s', 'format', 'png', icnsPath, '--out', tmpOut, '-z', '48', '48'], { + stdio: ['ignore', 'ignore', 'ignore'], + timeout: 1500 + }); + + if (fs.existsSync(tmpOut)) { + const buf = fs.readFileSync(tmpOut); + try { fs.unlinkSync(tmpOut); } catch {} + const dataUrl = 'data:image/png;base64,' + buf.toString('base64'); + iconCache.set(appPath, dataUrl); + return dataUrl; + } + } catch {} + + iconCache.set(appPath, null); + return null; +} + +function getAppIconDataUrl(appNameOrPath) { + if (!appNameOrPath) return ''; + const resolved = resolveAppPath(appNameOrPath); + if (resolved) { + const icon = extractDarwinIcon(resolved); + if (icon) return icon; + } + return ''; +} + +function getLetterSvgIcon(name) { + const char = (name || '?').replace(/^[._]/, '').trim().charAt(0).toUpperCase() || '?'; + const colors = [ + ['#3b82f6', '#1d4ed8'], + ['#10b981', '#047857'], + ['#8b5cf6', '#6d28d9'], + ['#f59e0b', '#d97706'], + ['#ec4899', '#be185d'], + ['#06b6d4', '#0e7490'] + ]; + const idx = Math.abs((char.codePointAt(0) || 0) % colors.length); + const [c1, c2] = colors[idx]; + + const svg = '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + char + + '' + + ''; + + return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg); +} + +module.exports = { + resolveAppPath, + extractDarwinIcon, + 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 @@