@@ -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 ///
0 commit comments