Skip to content

Commit 683aa40

Browse files
committed
feat: Add custom local scan directories and Stub Portal version sync
- Introduced a "+" button in the "Mac Local Apps" header for adding custom local scan directories, enhancing support for tools like JetBrains Toolbox and Steam. - Implemented automatic synchronization of Stub Portal version info when external apps are updated via the App Store, ensuring the "Open With" menu reflects the correct version. - Optimized app scanning performance by reducing Info.plist read operations from 7 to 1, significantly improving scan speed. - Added a 10-second timeout for the `codesign` subprocess to prevent indefinite blocking during signature checks. - Introduced a safety cap of 500,000 files for directory size calculations to avoid excessive enumeration in large app bundles. - Enhanced scan trace logging for better tracking of scanning performance on a per-app basis. - Improved data directory matching by filtering out generic TLD words from bundle ID suffix extraction to prevent misidentification of system container directories. fix: Resolve various issues related to app scanning and version synchronization - Fixed slow scanning issues for apps like Trae caused by generic suffixes in bundle IDs. - Corrected the failure of local Stub Portal version info to update after external app updates via the App Store. - Addressed the refresh button not triggering Stub Portal version synchronization.
1 parent a85c006 commit 683aa40

21 files changed

Lines changed: 1521 additions & 709 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ docs/superpowers/specs/2026-05-10-logging-improvements-design.md
3838
.kilo/plans/1778449101739-gentle-engine.md
3939
.kilo/
4040
Tools/StubLauncher/launcher
41+
.momocode/
42+
.omo/
43+
.mimocode/plans/1782222306653-lucky-otter.md

AppPorts.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@
376376
"@executable_path/../Frameworks",
377377
);
378378
MACOSX_DEPLOYMENT_TARGET = 12.0;
379-
MARKETING_VERSION = 1.7.0;
379+
MARKETING_VERSION = 1.7.1;
380380
PRODUCT_BUNDLE_IDENTIFIER = com.shimoko.AppPorts;
381381
PRODUCT_NAME = "$(TARGET_NAME)";
382382
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -413,7 +413,7 @@
413413
"@executable_path/../Frameworks",
414414
);
415415
MACOSX_DEPLOYMENT_TARGET = 12.0;
416-
MARKETING_VERSION = 1.7.0;
416+
MARKETING_VERSION = 1.7.1;
417417
PRODUCT_BUNDLE_IDENTIFIER = com.shimoko.AppPorts;
418418
PRODUCT_NAME = "$(TARGET_NAME)";
419419
PROVISIONING_PROFILE_SPECIFIER = "";

AppPorts/ContentView.swift

Lines changed: 169 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ struct ContentView: View {
200200

201201
private let localAppsURL = URL(fileURLWithPath: "/Applications")
202202
@State private var externalDriveURL: URL?
203+
@State private var customLocalScanPaths: [String] = UserDefaults.standard.stringArray(forKey: "customLocalScanPaths") ?? []
204+
@State private var customLocalMonitors: [FolderMonitor] = []
203205

204206
// 多选支持
205207
@State private var selectedLocalApps: Set<String> = []
@@ -401,10 +403,43 @@ struct ContentView: View {
401403
// --- 左侧:本地应用 ---
402404
VStack(spacing: 0) {
403405
// Header Area (Restored to original simple style)
404-
HeaderView(title: "Mac 本地应用".localized, subtitle: "/Applications", icon: "macmini") {
405-
scanLocalApps()
406+
HeaderView(
407+
title: "Mac 本地应用".localized,
408+
subtitle: localAppsSubtitle,
409+
icon: "macmini",
410+
actionButtonText: "",
411+
onAction: addCustomLocalScanPath,
412+
onRefresh: { scanLocalApps() }
413+
)
414+
415+
// 自定义扫描目录标签
416+
if !customLocalScanPaths.isEmpty {
417+
ScrollView(.horizontal, showsIndicators: false) {
418+
HStack(spacing: 6) {
419+
ForEach(customLocalScanPaths, id: \.self) { path in
420+
HStack(spacing: 4) {
421+
Text((path as NSString).lastPathComponent)
422+
.font(.caption)
423+
.lineLimit(1)
424+
Button(action: { removeCustomLocalScanPath(path) }) {
425+
Image(systemName: "xmark.circle.fill")
426+
.font(.caption2)
427+
.foregroundColor(.secondary)
428+
}
429+
.buttonStyle(.plain)
430+
}
431+
.padding(.horizontal, 8)
432+
.padding(.vertical, 4)
433+
.background(Color.accentColor.opacity(0.1))
434+
.cornerRadius(6)
435+
}
436+
}
437+
.padding(.horizontal, 20)
438+
.padding(.vertical, 6)
439+
}
440+
.background(.ultraThinMaterial)
406441
}
407-
442+
408443
ZStack {
409444
Color(nsColor: .controlBackgroundColor).ignoresSafeArea()
410445

@@ -1095,31 +1130,59 @@ struct ContentView: View {
10951130
Task.detached(priority: .userInitiated) {
10961131
// Gather data needed for scanning
10971132
let runningAppURLs = await MainActor.run { self.getRunningAppURLs() }
1098-
let scanDir = self.localAppsURL
10991133
let externalAppsDir = await MainActor.run { self.externalDriveURL }
1100-
1134+
let customPaths = await MainActor.run { self.customLocalScanPaths }
1135+
11011136
// Use Actor
11021137
let scanner = AppScanner()
1103-
let newApps = await scanner.scanLocalApps(
1104-
at: scanDir,
1138+
var allApps = await scanner.scanLocalApps(
1139+
at: self.localAppsURL,
11051140
runningAppURLs: runningAppURLs,
11061141
externalAppsDir: externalAppsDir
11071142
)
1108-
1143+
1144+
// 扫描自定义目录
1145+
for path in customPaths {
1146+
let customApps = await scanner.scanLocalApps(
1147+
at: URL(fileURLWithPath: path),
1148+
runningAppURLs: runningAppURLs,
1149+
externalAppsDir: externalAppsDir
1150+
)
1151+
let existingPaths = Set(allApps.map { $0.path.path })
1152+
for app in customApps where !existingPaths.contains(app.path.path) {
1153+
allApps.append(app)
1154+
}
1155+
}
1156+
1157+
let finalApps = allApps
1158+
1159+
// 检测外置 app 版本变化,刷新本地 Stub Portal
1160+
if let externalDir = externalAppsDir {
1161+
let externalApps = await scanner.scanExternalApps(at: externalDir, localAppsDir: URL(fileURLWithPath: "/Applications"))
1162+
let service = AppMigrationService()
1163+
for localApp in finalApps where localApp.status == AppStatus.linked {
1164+
if let externalApp = externalApps.first(where: { $0.name == localApp.name }),
1165+
localApp.version != externalApp.version {
1166+
service.refreshStubPortal(at: localApp.path, from: externalApp.path)
1167+
}
1168+
}
1169+
}
1170+
11091171
await MainActor.run {
1110-
self.localApps = newApps
1172+
self.localApps = finalApps
11111173
}
11121174
AppLogger.shared.logContext(
11131175
"本地应用扫描完成",
11141176
details: [
11151177
("scan_id", scanID),
1116-
("count", String(newApps.count)),
1117-
("status_summary", self.summarizeStatuses(for: newApps))
1178+
("count", String(finalApps.count)),
1179+
("custom_dirs", String(customPaths.count)),
1180+
("status_summary", self.summarizeStatuses(for: finalApps))
11181181
]
11191182
)
11201183

11211184
if calculateSizes {
1122-
await self.calculateSizesProgressive(for: newApps, isLocal: true, scanner: scanner)
1185+
await self.calculateSizesProgressive(for: finalApps, isLocal: true, scanner: scanner)
11231186
}
11241187
}
11251188
}
@@ -1173,14 +1236,33 @@ struct ContentView: View {
11731236
func calculateSizesProgressive(for apps: [AppItem], isLocal: Bool, scanner: AppScanner) async {
11741237
// 并行计算所有大小,再一次性批量更新 UI,避免列表反复重排
11751238
let results = await withTaskGroup(of: (String, Int64).self) { group in
1176-
for app in apps {
1239+
var dict: [String: Int64] = [:]
1240+
var iterator = apps.makeIterator()
1241+
var active = 0
1242+
let maxConcurrency = 4
1243+
1244+
// 启动初始批次
1245+
for _ in 0..<min(maxConcurrency, apps.count) {
1246+
guard let app = iterator.next() else { break }
11771247
group.addTask {
11781248
let size = await scanner.calculateDisplayedSize(for: app, isLocalEntry: isLocal)
11791249
return (app.id, size)
11801250
}
1251+
active += 1
1252+
}
1253+
1254+
// 每完成一个再启动一个
1255+
for await (id, size) in group {
1256+
dict[id] = size
1257+
active -= 1
1258+
if let app = iterator.next() {
1259+
group.addTask {
1260+
let size = await scanner.calculateDisplayedSize(for: app, isLocalEntry: isLocal)
1261+
return (app.id, size)
1262+
}
1263+
active += 1
1264+
}
11811265
}
1182-
var dict: [String: Int64] = [:]
1183-
for await (id, size) in group { dict[id] = size }
11841266
return dict
11851267
}
11861268

@@ -1255,7 +1337,40 @@ struct ContentView: View {
12551337
AppLogger.shared.log("用户取消选择外部路径", level: "TRACE")
12561338
}
12571339
}
1258-
1340+
1341+
// MARK: - 自定义本地扫描目录
1342+
1343+
private var localAppsSubtitle: String {
1344+
let base = "/Applications"
1345+
if customLocalScanPaths.isEmpty {
1346+
return base
1347+
}
1348+
return "\(base) + \(customLocalScanPaths.count) \("个目录".localized)"
1349+
}
1350+
1351+
func addCustomLocalScanPath() {
1352+
let panel = NSOpenPanel()
1353+
panel.prompt = "选择文件夹".localized
1354+
panel.allowsMultipleSelection = false
1355+
panel.canChooseDirectories = true
1356+
panel.canChooseFiles = false
1357+
panel.message = "选择要额外扫描的应用目录".localized
1358+
guard panel.runModal() == .OK, let url = panel.url else { return }
1359+
let path = url.path
1360+
guard !customLocalScanPaths.contains(path) else { return }
1361+
customLocalScanPaths.append(path)
1362+
UserDefaults.standard.set(customLocalScanPaths, forKey: "customLocalScanPaths")
1363+
startMonitoringLocal()
1364+
scanLocalApps()
1365+
}
1366+
1367+
func removeCustomLocalScanPath(_ path: String) {
1368+
customLocalScanPaths.removeAll { $0 == path }
1369+
UserDefaults.standard.set(customLocalScanPaths, forKey: "customLocalScanPaths")
1370+
startMonitoringLocal()
1371+
scanLocalApps()
1372+
}
1373+
12591374
func showError(title: String, message: String) {
12601375
AppLogger.shared.logContext(
12611376
"向用户展示错误",
@@ -2100,13 +2215,26 @@ struct ContentView: View {
21002215

21012216
func startMonitoringLocal() {
21022217
localMonitor?.stopMonitoring()
2218+
customLocalMonitors.forEach { $0.stopMonitoring() }
2219+
customLocalMonitors.removeAll()
2220+
21032221
AppLogger.shared.logContext("启动本地目录监控", details: [("path", localAppsURL.path)])
21042222

21052223
let monitor = FolderMonitor(url: localAppsURL)
21062224
monitor.startMonitoring { [self] in
21072225
scheduleMonitorRescan(local: true)
21082226
}
21092227
self.localMonitor = monitor
2228+
2229+
// 监控自定义目录
2230+
for path in customLocalScanPaths {
2231+
let url = URL(fileURLWithPath: path)
2232+
let customMonitor = FolderMonitor(url: url)
2233+
customMonitor.startMonitoring { [self] in
2234+
scheduleMonitorRescan(local: true)
2235+
}
2236+
customLocalMonitors.append(customMonitor)
2237+
}
21102238
}
21112239

21122240
func startMonitoringExternal(url: URL) {
@@ -2136,6 +2264,7 @@ struct ContentView: View {
21362264
let runningAppURLs = await MainActor.run { self.getRunningAppURLs() }
21372265
let localDir = self.localAppsURL
21382266
let externalLocalDir = URL(fileURLWithPath: "/Applications")
2267+
let customPaths = await MainActor.run { self.customLocalScanPaths }
21392268

21402269
// 保留旧的大小信息
21412270
let oldLocalSizes = await MainActor.run {
@@ -2152,18 +2281,31 @@ struct ContentView: View {
21522281
}
21532282

21542283
// 并行扫描
2155-
async let localResult = scanner.scanLocalApps(
2284+
var newLocalApps = await scanner.scanLocalApps(
21562285
at: localDir,
21572286
runningAppURLs: runningAppURLs,
21582287
externalAppsDir: externalDir
21592288
)
2289+
2290+
// 扫描自定义目录
2291+
for path in customPaths {
2292+
let customApps = await scanner.scanLocalApps(
2293+
at: URL(fileURLWithPath: path),
2294+
runningAppURLs: runningAppURLs,
2295+
externalAppsDir: externalDir
2296+
)
2297+
let existingPaths = Set(newLocalApps.map { $0.path.path })
2298+
for app in customApps where !existingPaths.contains(app.path.path) {
2299+
newLocalApps.append(app)
2300+
}
2301+
}
2302+
21602303
let externalResult: [AppItem]
21612304
if let externalDir {
21622305
externalResult = await scanner.scanExternalApps(at: externalDir, localAppsDir: externalLocalDir)
21632306
} else {
21642307
externalResult = []
21652308
}
2166-
var newLocalApps = await localResult
21672309

21682310
// 恢复旧的大小信息,只对变化的 app 标记需要重算
21692311
for i in newLocalApps.indices {
@@ -2180,6 +2322,15 @@ struct ContentView: View {
21802322
}
21812323
}
21822324

2325+
// 检测外置 app 版本变化,刷新本地 Stub Portal
2326+
let service = AppMigrationService()
2327+
for localApp in newLocalApps where localApp.status == AppStatus.linked {
2328+
if let externalApp = newExternalApps.first(where: { $0.name == localApp.name }),
2329+
localApp.version != externalApp.version {
2330+
service.refreshStubPortal(at: localApp.path, from: externalApp.path)
2331+
}
2332+
}
2333+
21832334
let finalLocalApps = newLocalApps
21842335
let finalExternalApps = newExternalApps
21852336
await MainActor.run {

0 commit comments

Comments
 (0)