Skip to content

Commit f01f0d1

Browse files
committed
feat: Enhance app scanning to correctly identify and include symlinked app bundles and suites in both local and external directories.
1 parent e80d0c8 commit f01f0d1

2 files changed

Lines changed: 202 additions & 16 deletions

File tree

AppPorts/Utils/AppScanner.swift

Lines changed: 160 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,8 @@ actor AppScanner {
156156
newApps.append(AppItem(name: appName, path: itemURL, status: status, isSystemApp: isSystem, isRunning: isRunning, isAppStoreApp: isAppStore, isIOSApp: isIOS))
157157
}
158158
// 处理包含 .app 的文件夹(如 Microsoft Office、Adobe Creative Cloud 等套件)
159-
else if let resourceValues = try? itemURL.resourceValues(forKeys: [.isDirectoryKey]),
160-
resourceValues.isDirectory == true {
161-
let folderContents = (try? fileManager.contentsOfDirectory(at: itemURL, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? []
162-
let appsInFolder = folderContents.filter { $0.pathExtension == "app" }
159+
else {
160+
let appsInFolder = appBundlesInsideFolderPortal(at: itemURL)
163161

164162
if !appsInFolder.isEmpty {
165163
let folderName = itemURL.lastPathComponent
@@ -227,12 +225,30 @@ actor AppScanner {
227225

228226
private func detectLocalFolderStatus(at folderURL: URL) -> String {
229227
if let linkDest = resolveSymlinkDestination(of: folderURL) {
230-
return isCrossVolumeLink(fromPortalAt: folderURL, to: linkDest) ? "已链接" : "本地"
228+
return linkDest.standardizedFileURL == folderURL.standardizedFileURL ? "本地" : "已链接"
231229
}
232230

233231
return "本地"
234232
}
235233

234+
private func appBundlesInsideFolderPortal(at folderURL: URL) -> [URL] {
235+
let inspectURL: URL
236+
237+
if let resourceValues = try? folderURL.resourceValues(forKeys: [.isDirectoryKey]),
238+
resourceValues.isDirectory == true {
239+
inspectURL = folderURL
240+
} else if let linkDestination = resolveSymlinkDestination(of: folderURL),
241+
let targetValues = try? linkDestination.resourceValues(forKeys: [.isDirectoryKey]),
242+
targetValues.isDirectory == true {
243+
inspectURL = linkDestination
244+
} else {
245+
return []
246+
}
247+
248+
let folderContents = (try? FileManager.default.contentsOfDirectory(at: inspectURL, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? []
249+
return folderContents.filter { $0.pathExtension == "app" }
250+
}
251+
236252
/// 为体积计算解析真实目标。
237253
///
238254
/// 支持两类结构:
@@ -411,7 +427,7 @@ actor AppScanner {
411427
level: "TRACE"
412428
)
413429
let fileManager = FileManager.default
414-
var newApps: [AppItem] = []
430+
var discoveredApps: [String: AppItem] = [:]
415431
let keys: [URLResourceKey] = [.isSymbolicLinkKey, .isDirectoryKey]
416432
let items = (try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: keys, options: .skipsHiddenFiles)) ?? []
417433

@@ -426,7 +442,10 @@ actor AppScanner {
426442
isLocalApp(localAppURL, linkedTo: itemURL) {
427443
status = "已链接"
428444
}
429-
newApps.append(AppItem(name: appName, path: itemURL, status: status, isSystemApp: false, isRunning: false))
445+
mergeExternalApp(
446+
AppItem(name: appName, path: itemURL, status: status, isSystemApp: false, isRunning: false),
447+
into: &discoveredApps
448+
)
430449
}
431450
// 2. 处理包含 .app 的文件夹(如 Microsoft Office、Adobe Creative Cloud 等套件)
432451
else if let resourceValues = try? itemURL.resourceValues(forKeys: [.isDirectoryKey]),
@@ -457,19 +476,75 @@ actor AppScanner {
457476
status = linkedCount == 0 ? "未链接" : "部分链接"
458477
}
459478

460-
newApps.append(AppItem(
461-
name: folderName,
462-
path: itemURL,
463-
status: status,
479+
mergeExternalApp(
480+
AppItem(
481+
name: folderName,
482+
path: itemURL,
483+
status: status,
484+
isSystemApp: false,
485+
isRunning: false,
486+
isFolder: true,
487+
appCount: appCount
488+
),
489+
into: &discoveredApps
490+
)
491+
}
492+
}
493+
}
494+
495+
// 3. 从本地入口反向补全已经链接到当前外部根目录中的项目。
496+
// 这样即使外部目标位于更深一层的子目录中,右侧外部应用库仍然可以显示它。
497+
let localItems = (try? fileManager.contentsOfDirectory(at: localAppsDir, includingPropertiesForKeys: keys, options: .skipsHiddenFiles)) ?? []
498+
for localItemURL in localItems {
499+
if localItemURL.pathExtension == "app" {
500+
guard let externalTargetURL = externalTargetForLocalApp(at: localItemURL),
501+
isDescendantOrSame(externalTargetURL, under: dir),
502+
fileManager.fileExists(atPath: externalTargetURL.path) else {
503+
continue
504+
}
505+
506+
let (isAppStore, isIOS) = detectAppStoreAndIOSApp(at: externalTargetURL)
507+
mergeExternalApp(
508+
AppItem(
509+
name: externalTargetURL.lastPathComponent,
510+
path: externalTargetURL,
511+
status: "已链接",
464512
isSystemApp: false,
465513
isRunning: false,
466-
isFolder: true,
467-
appCount: appCount
468-
))
469-
}
514+
isAppStoreApp: isAppStore,
515+
isIOSApp: isIOS
516+
),
517+
into: &discoveredApps
518+
)
519+
continue
520+
}
521+
522+
guard let externalTargetURL = resolveSymlinkDestination(of: localItemURL),
523+
isDescendantOrSame(externalTargetURL, under: dir),
524+
let resourceValues = try? externalTargetURL.resourceValues(forKeys: [.isDirectoryKey]),
525+
resourceValues.isDirectory == true else {
526+
continue
470527
}
528+
529+
let folderContents = (try? fileManager.contentsOfDirectory(at: externalTargetURL, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? []
530+
let appsInFolder = folderContents.filter { $0.pathExtension == "app" }
531+
guard !appsInFolder.isEmpty else { continue }
532+
533+
mergeExternalApp(
534+
AppItem(
535+
name: externalTargetURL.lastPathComponent,
536+
path: externalTargetURL,
537+
status: "已链接",
538+
isSystemApp: false,
539+
isRunning: false,
540+
isFolder: true,
541+
appCount: appsInFolder.count
542+
),
543+
into: &discoveredApps
544+
)
471545
}
472-
let sortedApps = sortApps(newApps)
546+
547+
let sortedApps = sortApps(Array(discoveredApps.values))
473548
AppLogger.shared.logContext(
474549
"AppScanner 完成外部应用扫描",
475550
details: [
@@ -481,6 +556,75 @@ actor AppScanner {
481556
)
482557
return sortedApps
483558
}
559+
560+
private func externalTargetForLocalApp(at localAppURL: URL) -> URL? {
561+
if let linkDestination = resolveSymlinkDestination(of: localAppURL) {
562+
return linkDestination
563+
}
564+
565+
let contentsURL = localAppURL.appendingPathComponent("Contents")
566+
if let contentsDestination = resolveSymlinkDestination(of: contentsURL) {
567+
return enclosingAppBundleURL(for: contentsDestination)
568+
}
569+
570+
let macOSURL = contentsURL.appendingPathComponent("MacOS")
571+
if let macOSDestination = resolveSymlinkDestination(of: macOSURL) {
572+
return enclosingAppBundleURL(for: macOSDestination)
573+
}
574+
575+
let resourcesURL = contentsURL.appendingPathComponent("Resources")
576+
if let resourcesDestination = resolveSymlinkDestination(of: resourcesURL) {
577+
return enclosingAppBundleURL(for: resourcesDestination)
578+
}
579+
580+
return nil
581+
}
582+
583+
private func isDescendantOrSame(_ url: URL, under root: URL) -> Bool {
584+
let normalizedURL = url.standardizedFileURL.path
585+
let normalizedRoot = root.standardizedFileURL.path
586+
587+
if normalizedURL == normalizedRoot {
588+
return true
589+
}
590+
591+
return normalizedURL.hasPrefix(normalizedRoot + "/")
592+
}
593+
594+
private func mergeExternalApp(_ item: AppItem, into itemsByPath: inout [String: AppItem]) {
595+
let key = item.path.standardizedFileURL.path
596+
597+
guard var existing = itemsByPath[key] else {
598+
itemsByPath[key] = item
599+
return
600+
}
601+
602+
if externalStatusPriority(item.status) > externalStatusPriority(existing.status) {
603+
existing.status = item.status
604+
}
605+
606+
if item.isFolder {
607+
existing.isFolder = true
608+
existing.appCount = max(existing.appCount, item.appCount)
609+
}
610+
611+
existing.isAppStoreApp = existing.isAppStoreApp || item.isAppStoreApp
612+
existing.isIOSApp = existing.isIOSApp || item.isIOSApp
613+
itemsByPath[key] = existing
614+
}
615+
616+
private func externalStatusPriority(_ status: String) -> Int {
617+
switch status {
618+
case "已链接":
619+
return 3
620+
case "部分链接":
621+
return 2
622+
case "未链接", "外部":
623+
return 1
624+
default:
625+
return 0
626+
}
627+
}
484628

485629
/// 应用列表排序
486630
///

AppPortsTests/AppScannerTests.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,48 @@ final class AppScannerTests: XCTestCase {
6363
XCTAssertEqual(displayedSize, logicalSize)
6464
}
6565

66+
func testScanExternalAppsIncludesLinkedSuiteFolderNestedUnderSelectedRoot() async throws {
67+
let workspace = try makeWorkspace()
68+
defer { cleanupWorkspace(workspace.rootURL) }
69+
70+
let nestedSuitesURL = workspace.externalRootURL.appendingPathComponent("Suites")
71+
let externalFolderURL = nestedSuitesURL.appendingPathComponent("Microsoft Office")
72+
let localFolderURL = workspace.localAppsURL.appendingPathComponent("Microsoft Office")
73+
74+
try fileManager.createDirectory(at: nestedSuitesURL, withIntermediateDirectories: true)
75+
try createAppBundle(at: externalFolderURL.appendingPathComponent("Word.app"), payloadSize: 1024)
76+
try createAppBundle(at: externalFolderURL.appendingPathComponent("Excel.app"), payloadSize: 1024)
77+
try fileManager.createSymbolicLink(at: localFolderURL, withDestinationURL: externalFolderURL)
78+
79+
let scanner = AppScanner()
80+
let externalItems = await scanner.scanExternalApps(at: workspace.externalRootURL, localAppsDir: workspace.localAppsURL)
81+
82+
let linkedFolder = try XCTUnwrap(externalItems.first { $0.path.standardizedFileURL == externalFolderURL.standardizedFileURL })
83+
XCTAssertEqual(linkedFolder.status, "已链接")
84+
XCTAssertTrue(linkedFolder.isFolder)
85+
XCTAssertEqual(linkedFolder.appCount, 2)
86+
}
87+
88+
func testScanLocalAppsDetectsLinkedSuiteFolderSymlink() async throws {
89+
let workspace = try makeWorkspace()
90+
defer { cleanupWorkspace(workspace.rootURL) }
91+
92+
let externalFolderURL = workspace.externalRootURL.appendingPathComponent("Microsoft Office")
93+
let localFolderURL = workspace.localAppsURL.appendingPathComponent("Microsoft Office")
94+
95+
try createAppBundle(at: externalFolderURL.appendingPathComponent("Word.app"), payloadSize: 1024)
96+
try createAppBundle(at: externalFolderURL.appendingPathComponent("Excel.app"), payloadSize: 1024)
97+
try fileManager.createSymbolicLink(at: localFolderURL, withDestinationURL: externalFolderURL)
98+
99+
let scanner = AppScanner()
100+
let localItems = await scanner.scanLocalApps(at: workspace.localAppsURL, runningAppURLs: [])
101+
102+
let linkedFolder = try XCTUnwrap(localItems.first { $0.path.standardizedFileURL == localFolderURL.standardizedFileURL })
103+
XCTAssertEqual(linkedFolder.status, "已链接")
104+
XCTAssertTrue(linkedFolder.isFolder)
105+
XCTAssertEqual(linkedFolder.appCount, 2)
106+
}
107+
66108
private func makeWorkspace() throws -> (rootURL: URL, localAppsURL: URL, externalRootURL: URL) {
67109
let rootURL = fileManager.temporaryDirectory.appendingPathComponent("AppScannerTests-\(UUID().uuidString)")
68110
let localAppsURL = rootURL.appendingPathComponent("Applications")

0 commit comments

Comments
 (0)