| src/Apptag/DataLayer.swift | ●●●●● patch | view | raw | blame | history | |
| src/Apptag/Info.plist | ●●●●● patch | view | raw | blame | history | |
| src/Apptag/QuickSearch.swift | ●●●●● patch | view | raw | blame | history | |
| src/CHANGELOG.md | ●●●●● patch | view | raw | blame | history | |
| src/Scripts/quick_search_app_name_qa.sh | ●●●●● patch | view | raw | blame | history | |
| src/TODO.md | ●●●●● patch | view | raw | blame | history |
src/Apptag/DataLayer.swift
@@ -12,46 +12,18 @@ let bundleIdentifier: String? let localizedNames: [String] let localizedNamesByLanguage: [String: String] let systemDisplayNames: [String] let bundleDisplayNames: [String] let icon: NSImage // Pre-loaded during background scan var isUncommon: Bool = false var note: String? = nil var displayName: String { localizedDisplayName(for: L10n.currentCode) AppDisplayNameResolver.displayName(for: self, languageCode: L10n.currentCode) } func localizedDisplayName(for languageCode: String) -> String { let candidates = AppInfo.displayLanguageFallbacks(for: languageCode) for code in candidates { if let localizedName = localizedNamesByLanguage[code], !localizedName.isEmpty { return localizedName } } if let localizedName = localizedNames.first, !localizedName.isEmpty { return localizedName } return name } private static func displayLanguageFallbacks(for languageCode: String) -> [String] { var candidates = [languageCode] switch languageCode { case "zh-Hans": candidates.append("zh-Hant") case "zh-Hant": candidates.append("zh-Hans") case "nb": candidates.append("no") case "nn": candidates.append("no") case "no": candidates.append(contentsOf: ["nb", "nn"]) default: break } candidates.append("en") return uniqueLanguageCodes(candidates) AppDisplayNameResolver.displayName(for: self, languageCode: languageCode) } fileprivate static func uniqueLanguageCodes(_ codes: [String]) -> [String] { @@ -67,6 +39,141 @@ func hash(into hasher: inout Hasher) { hasher.combine(path) } static func == (lhs: AppInfo, rhs: AppInfo) -> Bool { lhs.path == rhs.path } } enum AppDisplayNameResolver { static func displayName(for app: AppInfo, languageCode: String) -> String { let languageFallbacks = displayLanguageFallbacks(for: languageCode, includeEnglish: false) if let localizedName = firstLocalizedName( for: languageFallbacks, in: app.localizedNamesByLanguage ) { return localizedName } if languageCode == "en", let englishBaseName = firstDisplayCandidate([app.name] + app.bundleDisplayNames) { return englishBaseName } if let systemDisplayName = firstDisplayCandidate(app.systemDisplayNames) { return systemDisplayName } if let englishName = firstLocalizedName(for: ["en"], in: app.localizedNamesByLanguage) { return englishName } if let bundleDisplayName = firstDisplayCandidate(app.bundleDisplayNames) { return bundleDisplayName } let skippedLanguageCodes = Set(languageFallbacks + ["en"]) let remainingLocalizedNames = L10n.supported.compactMap { language -> String? in guard !skippedLanguageCodes.contains(language.code) else { return nil } return app.localizedNamesByLanguage[language.code] } if let localizedName = firstDisplayCandidate(remainingLocalizedNames) { return localizedName } if let alias = firstDisplayCandidate(app.localizedNames) { return alias } return app.name } static func searchAliases(for app: AppInfo) -> [String] { uniqueDisplayNames( L10n.supported.compactMap { app.localizedNamesByLanguage[$0.code] } + app.systemDisplayNames + app.bundleDisplayNames + app.localizedNames + [app.displayName], excluding: app.name ) } private static func displayLanguageFallbacks( for languageCode: String, includeEnglish: Bool ) -> [String] { var candidates = [ languageCode, languageCode.replacingOccurrences(of: "_", with: "-") ] switch languageCode { case "zh-Hans": candidates.append("zh-Hant") case "zh-Hant": candidates.append("zh-Hans") case "pt-BR": candidates.append("pt") case "sr-Cyrl": candidates.append("sr") case "ar-Najdi": candidates.append("ar") case "nb": candidates.append(contentsOf: ["no", "nn"]) case "nn": candidates.append(contentsOf: ["no", "nb"]) case "no": candidates.append(contentsOf: ["nb", "nn"]) default: if let base = languageCode.split(separator: "-").first.map(String.init) { candidates.append(base) } } if includeEnglish { candidates.append("en") } return AppInfo.uniqueLanguageCodes(candidates) } private static func firstLocalizedName( for languageCodes: [String], in localizedNamesByLanguage: [String: String] ) -> String? { firstDisplayCandidate(languageCodes.compactMap { localizedNamesByLanguage[$0] }) } private static func firstDisplayCandidate(_ values: [String]) -> String? { values.lazy.compactMap(normalizedDisplayName).first } private static func uniqueDisplayNames(_ values: [String], excluding excludedValue: String) -> [String] { let normalizedExcluded = normalizedKey(excludedValue) var seen = Set<String>() var result: [String] = [] for value in values { guard let trimmed = normalizedDisplayName(value) else { continue } let normalized = normalizedKey(trimmed) guard normalized != normalizedExcluded, seen.insert(normalized).inserted else { continue } result.append(trimmed) } return result } private static func normalizedDisplayName(_ value: String) -> String? { let trimmed = value .replacingOccurrences(of: ".app", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private static func normalizedKey(_ value: String) -> String { value .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) .lowercased() .trimmingCharacters(in: .whitespacesAndNewlines) } } struct TagGroup: Identifiable { @@ -282,10 +389,13 @@ } } } let systemDisplayNames = systemDisplayNames(for: displayURL, fallbackName: name) let bundleDisplayNames = bundleDisplayNames(bundle: bundle, fallbackName: name) let localizedNames = localizedAppNames( for: displayURL, fallbackName: name, localizedNamesByLanguage: localizedNamesByLanguage localizedNamesByLanguage: localizedNamesByLanguage, systemDisplayNames: systemDisplayNames, bundleDisplayNames: bundleDisplayNames ) apps.append(AppInfo( @@ -295,6 +405,8 @@ bundleIdentifier: bundleId, localizedNames: localizedNames, localizedNamesByLanguage: localizedNamesByLanguage, systemDisplayNames: systemDisplayNames, bundleDisplayNames: bundleDisplayNames, icon: icon )) } @@ -325,16 +437,39 @@ } private static func localizedAppNames( for appURL: URL, fallbackName: String, localizedNamesByLanguage: [String: String] localizedNamesByLanguage: [String: String], systemDisplayNames: [String], bundleDisplayNames: [String] ) -> [String] { var values = L10n.supported.map { localizedNamesByLanguage[$0.code] } values.append(resourceLocalizedName(for: appURL)) values.append(spotlightDisplayName(for: appURL)) values.append(FileManager.default.displayName(atPath: appURL.path).replacingOccurrences(of: ".app", with: "")) values.append(contentsOf: systemDisplayNames.map { Optional($0) }) values.append(contentsOf: bundleDisplayNames.map { Optional($0) }) return uniqueLocalizedNames(values, excluding: fallbackName) } private static func systemDisplayNames(for appURL: URL, fallbackName: String) -> [String] { uniqueLocalizedNames( [ resourceLocalizedName(for: appURL), spotlightDisplayName(for: appURL), FileManager.default.displayName(atPath: appURL.path) ], excluding: fallbackName ) } private static func bundleDisplayNames(bundle: Bundle?, fallbackName: String) -> [String] { uniqueLocalizedNames( [ bundle?.localizedInfoDictionary?["CFBundleDisplayName"] as? String, bundle?.localizedInfoDictionary?["CFBundleName"] as? String, bundle?.infoDictionary?["CFBundleDisplayName"] as? String, bundle?.infoDictionary?["CFBundleName"] as? String ], excluding: fallbackName ) } private static func localizedAppName( @@ -488,7 +623,7 @@ .trimmingCharacters(in: .whitespacesAndNewlines) } private static func isNestedInsideAppBundle(_ url: URL) -> Bool { static func isNestedInsideAppBundle(_ url: URL) -> Bool { let components = url.standardizedFileURL.pathComponents guard let lastAppIndex = components.lastIndex(where: { $0.lowercased().hasSuffix(".app") }) else { return false @@ -1325,6 +1460,8 @@ bundleIdentifier: app.bundleIdentifier, localizedNames: app.localizedNames, localizedNamesByLanguage: app.localizedNamesByLanguage, systemDisplayNames: app.systemDisplayNames, bundleDisplayNames: app.bundleDisplayNames, icon: app.icon, isUncommon: uncommonPaths.contains(app.path.path), note: store.appNotes[app.path.path] src/Apptag/Info.plist
@@ -19,9 +19,9 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> <string>7.8.24</string> <string>7.8.25</string> <key>CFBundleVersion</key> <string>20260609.1221</string> <string>20260609.1741</string> <key>LSApplicationCategoryType</key> <string>public.app-category.utilities</string> <key>LSMinimumSystemVersion</key> src/Apptag/QuickSearch.swift
@@ -294,31 +294,33 @@ enum QuickSearchEngine { static func makeDocuments(apps: [AppInfo], store: TagDatabase.Store) -> [QuickSearchDocument] { apps.map { app in let localizedNames = uniqueOrdered(app.localizedNames) let internalBundleNames = internalBundleNames(for: app) let note = store.appNotes[app.path.path] ?? app.note ?? "" let bundleIdentifier = app.bundleIdentifier ?? "" let searchableFields = makeSearchableFields( appName: app.name, localizedNames: localizedNames, internalBundleNames: internalBundleNames, tagNames: app.tags, note: note, bundleIdentifier: bundleIdentifier ) return QuickSearchDocument( app: app, localizedNames: localizedNames, internalBundleNames: internalBundleNames, tagNames: app.tags, note: note, bundleIdentifier: bundleIdentifier, lastOpenedAt: store.appLastOpenedAt[app.path.path], openCount: store.appOpenCounts[app.path.path] ?? 0, searchableFields: searchableFields ) } apps .filter { !AppIndexer.isNestedInsideAppBundle($0.path) } .map { app in let localizedNames = uniqueOrdered(AppDisplayNameResolver.searchAliases(for: app)) let internalBundleNames = internalBundleNames(for: app) let note = store.appNotes[app.path.path] ?? app.note ?? "" let bundleIdentifier = app.bundleIdentifier ?? "" let searchableFields = makeSearchableFields( appName: app.name, localizedNames: localizedNames, internalBundleNames: internalBundleNames, tagNames: app.tags, note: note, bundleIdentifier: bundleIdentifier ) return QuickSearchDocument( app: app, localizedNames: localizedNames, internalBundleNames: internalBundleNames, tagNames: app.tags, note: note, bundleIdentifier: bundleIdentifier, lastOpenedAt: store.appLastOpenedAt[app.path.path], openCount: store.appOpenCounts[app.path.path] ?? 0, searchableFields: searchableFields ) } } static func search(_ query: String, documents: [QuickSearchDocument], limit: Int = 50) -> [QuickSearchResult] { @@ -688,19 +690,6 @@ previousWasLowercase = isLowercase } return normalizeField(String(parts)) } private static func localizedNames(for app: AppInfo) -> [String] { guard let bundle = Bundle(url: app.path) else { return [] } let values = [ bundle.localizedInfoDictionary?["CFBundleDisplayName"] as? String, bundle.infoDictionary?["CFBundleDisplayName"] as? String, FileManager.default.displayName(atPath: app.path.path).replacingOccurrences(of: ".app", with: "") ] return uniqueOrdered(values.compactMap { $0 }.compactMap { value in let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty || trimmed == app.name ? nil : trimmed }) } private static func internalBundleNames(for app: AppInfo) -> [String] { src/CHANGELOG.md
@@ -1,5 +1,13 @@ # TagLauncher Changelog ## [7.8.25] — 2026-06-09 - 新增统一 App 显示名解析策略:App Grid 和 Quick Search 标题优先使用当前语种的官方本地化名,再按同语系、系统/Finder/Spotlight 可见名、英文官方名和基础 bundle 名逐级 fallback - Quick Search 搜索别名与结果标题分离:内部 bundle 名、可执行名和 bundle id 继续参与搜索匹配,但不再优先作为用户可见标题 - Quick Search 文档生成增加嵌套 `.app` 防御过滤,避免 wrapper / iOS-on-Mac 应用的内层 helper 作为独立结果漏出 - Quick Search 名称 QA 增加多语种 fixture、系统显示名 fallback、英文基础名保留和“贝锐向日葵被控 / isunclient”最终文档过滤检查 - 版本号更新为 `7.8.25`,Build 更新为 `20260609.1741` ## [7.8.24] — 2026-06-09 - 修复全新用户首次安装后,Smart Start 自动整理完成提示中点击“OK/好的”可能把整个 App Grid 关闭的问题;确认提示现在只关闭 Smart Start 弹窗,App Grid 会继续留在当前页面 src/Scripts/quick_search_app_name_qa.sh
@@ -3,12 +3,17 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DATA_LAYER="$ROOT_DIR/Apptag/DataLayer.swift" QUICK_SEARCH="$ROOT_DIR/Apptag/QuickSearch.swift" SUNLOGIN_APP="${1:-/Applications/贝锐向日葵被控.app}" rg -Fq 'localizedNames.first' "$DATA_LAYER" rg -Fq 'AppDisplayNameResolver.displayName' "$DATA_LAYER" rg -Fq 'systemDisplayNames' "$DATA_LAYER" rg -Fq 'bundleDisplayNames' "$DATA_LAYER" rg -Fq '.localizedNameKey' "$DATA_LAYER" rg -Fq 'isNestedInsideAppBundle' "$DATA_LAYER" rg -Fq '.skipsPackageDescendants' "$DATA_LAYER" rg -Fq 'AppDisplayNameResolver.searchAliases' "$QUICK_SEARCH" rg -Fq 'AppIndexer.isNestedInsideAppBundle($0.path)' "$QUICK_SEARCH" swift - "$SUNLOGIN_APP" <<'SWIFT' import Foundation @@ -54,6 +59,135 @@ return result } func uniqueDisplayNames(_ values: [String], excluding excludedValue: String) -> [String] { let excluded = normalized(excludedValue) var seen = Set<String>() var result: [String] = [] for value in values { guard let trimmed = trim(value) else { continue } let key = normalized(trimmed) guard key != excluded, seen.insert(key).inserted else { continue } result.append(trimmed) } return result } struct NameProfile { let name: String let localizedNames: [String] let localizedNamesByLanguage: [String: String] let systemDisplayNames: [String] let bundleDisplayNames: [String] } let supportedLanguages = [ "en", "fr", "it", "de", "es", "pt-BR", "zh-Hans", "zh-Hant", "ko", "ja", "ru", "sr-Cyrl", "uk", "th", "vi", "ar", "ar-Najdi", "tr", "id", "cs", "da", "nl", "no", "nn", "nb", "ms", "pl", "ro", "sv" ] func uniqueLanguageCodes(_ codes: [String]) -> [String] { var seen = Set<String>() return codes.filter { seen.insert($0).inserted } } func displayLanguageFallbacks(for languageCode: String, includeEnglish: Bool = false) -> [String] { var candidates = [ languageCode, languageCode.replacingOccurrences(of: "_", with: "-") ] switch languageCode { case "zh-Hans": candidates.append("zh-Hant") case "zh-Hant": candidates.append("zh-Hans") case "pt-BR": candidates.append("pt") case "sr-Cyrl": candidates.append("sr") case "ar-Najdi": candidates.append("ar") case "nb": candidates.append(contentsOf: ["no", "nn"]) case "nn": candidates.append(contentsOf: ["no", "nb"]) case "no": candidates.append(contentsOf: ["nb", "nn"]) default: if let base = languageCode.split(separator: "-").first.map(String.init) { candidates.append(base) } } if includeEnglish { candidates.append("en") } return uniqueLanguageCodes(candidates) } func firstDisplayCandidate(_ values: [String]) -> String? { values.lazy.compactMap(trim).first } func firstLocalizedName(for languageCodes: [String], in localizedNamesByLanguage: [String: String]) -> String? { firstDisplayCandidate(languageCodes.compactMap { localizedNamesByLanguage[$0] }) } func displayName(_ profile: NameProfile, languageCode: String) -> String { let languageFallbacks = displayLanguageFallbacks(for: languageCode) if let localizedName = firstLocalizedName( for: languageFallbacks, in: profile.localizedNamesByLanguage ) { return localizedName } if languageCode == "en", let englishBaseName = firstDisplayCandidate([profile.name] + profile.bundleDisplayNames) { return englishBaseName } if let systemDisplayName = firstDisplayCandidate(profile.systemDisplayNames) { return systemDisplayName } if let englishName = firstLocalizedName(for: ["en"], in: profile.localizedNamesByLanguage) { return englishName } if let bundleDisplayName = firstDisplayCandidate(profile.bundleDisplayNames) { return bundleDisplayName } let skippedLanguageCodes = Set(languageFallbacks + ["en"]) let remainingLocalizedNames = supportedLanguages.compactMap { languageCode -> String? in guard !skippedLanguageCodes.contains(languageCode) else { return nil } return profile.localizedNamesByLanguage[languageCode] } if let localizedName = firstDisplayCandidate(remainingLocalizedNames) { return localizedName } if let alias = firstDisplayCandidate(profile.localizedNames) { return alias } return profile.name } func searchAliases(_ profile: NameProfile) -> [String] { uniqueDisplayNames( supportedLanguages.compactMap { profile.localizedNamesByLanguage[$0] } + profile.systemDisplayNames + profile.bundleDisplayNames + profile.localizedNames + [displayName(profile, languageCode: "zh-Hans")], excluding: profile.name ) } func isNestedInsideAppBundle(_ url: URL) -> Bool { let components = url.standardizedFileURL.pathComponents guard let lastAppIndex = components.lastIndex(where: { $0.lowercased().hasSuffix(".app") }) else { @@ -79,21 +213,68 @@ return trim(bundle.infoDictionary?["CFBundleDisplayName"] as? String) } func displayName(name: String, localizedNames: [String], localizedNamesByLanguage: [String: String], languageCode: String) -> String { var candidates = [languageCode] if languageCode == "zh-Hans" { candidates.append("zh-Hant") } if languageCode == "zh-Hant" { candidates.append("zh-Hans") } candidates.append("en") var seen = Set<String>() for code in candidates where seen.insert(code).inserted { if let localizedName = localizedNamesByLanguage[code], !localizedName.isEmpty { return localizedName } } if let localizedName = localizedNames.first, !localizedName.isEmpty { return localizedName } return name func makeDocumentPaths(appPaths: [URL]) -> [String] { appPaths .filter { !isNestedInsideAppBundle($0) } .map(\.path) } let multilingualProfile = NameProfile( name: "InternalRunner", localizedNames: [], localizedNamesByLanguage: [ "en": "English Name", "de": "Deutscher Name", "ja": "日本語名", "zh-Hans": "简体名" ], systemDisplayNames: ["System Visible"], bundleDisplayNames: ["Bundle Visible"] ) guard displayName(multilingualProfile, languageCode: "de") == "Deutscher Name" else { fail("German localized display name was not preferred") } guard displayName(multilingualProfile, languageCode: "ja") == "日本語名" else { fail("Japanese localized display name was not preferred") } guard displayName(multilingualProfile, languageCode: "zh-Hant") == "简体名" else { fail("Chinese language fallback did not use paired Chinese localization") } let systemFallbackProfile = NameProfile( name: "internal-helper", localizedNames: [], localizedNamesByLanguage: [:], systemDisplayNames: ["Nom système"], bundleDisplayNames: ["Bundle Visible"] ) guard displayName(systemFallbackProfile, languageCode: "fr") == "Nom système" else { fail("system/Finder display name was not used before bundle/internal names") } let englishBaseProfile = NameProfile( name: "AweSun", localizedNames: [], localizedNamesByLanguage: [:], systemDisplayNames: ["贝锐向日葵"], bundleDisplayNames: [] ) guard displayName(englishBaseProfile, languageCode: "en") == "AweSun" else { fail("English users should keep a presentable English base app name") } guard displayName(englishBaseProfile, languageCode: "zh-Hans") == "贝锐向日葵" else { fail("non-English users should still get system-localized display fallback") } let englishFallbackProfile = NameProfile( name: "inner-name", localizedNames: [], localizedNamesByLanguage: ["en": "Official English"], systemDisplayNames: [], bundleDisplayNames: [] ) guard displayName(englishFallbackProfile, languageCode: "sv") == "Official English" else { fail("official English display name was not used as cross-language fallback") } guard FileManager.default.fileExists(atPath: appPath) else { @@ -156,15 +337,28 @@ ], excluding: "isunclient" ) let helperDisplayName = displayName( let helperProfile = NameProfile( name: "isunclient", localizedNames: helperLocalizedNames, localizedNamesByLanguage: [:], languageCode: "zh-Hans" systemDisplayNames: helperLocalizedNames, bundleDisplayNames: [] ) let helperDisplayName = displayName(helperProfile, languageCode: "zh-Hans") guard helperDisplayName == expectedDisplayName else { fail("displayName fallback returned \(helperDisplayName) instead of \(expectedDisplayName)") } let documentPaths = makeDocumentPaths(appPaths: [appURL, nestedURL]) guard documentPaths.contains(appPath) else { fail("Quick Search document fixture dropped outer wrapper app") } guard !documentPaths.contains(nestedURL.path) else { fail("Quick Search document fixture included nested helper app: \(nestedURL.path)") } guard searchAliases(helperProfile).contains(expectedDisplayName) else { fail("display aliases did not retain localized helper name for search") } print("PASS quick search app-name QA: \(expectedDisplayName) wrapper wins over nested isunclient") SWIFT src/TODO.md
@@ -23,6 +23,12 @@ ## Done - [2026-06-09] 多语种 App 显示名通用加固。 - 结果: 新增统一 `AppDisplayNameResolver`;App Grid 与 Quick Search 标题优先走当前语种/同语系/系统可见名/英文官方名/基础 bundle 名 fallback,内部 bundle 名和 bundle id 只作为搜索别名。 - 范围: `AppInfo` 增加系统显示名与 bundle 显示名候选;Quick Search 文档使用 resolver 生成别名并二次过滤嵌套 `.app`;版本更新为 `7.8.25 (20260609.1741)`。 - 不改范围: 未做第三方 App 名称机器翻译,未改 Quick Search 排名算法、App Grid 布局、窗口层级或 Smart Start 分类规则。 - 验证: `APP_BUILD=20260609.1741 bash build.sh`、`codesign --verify --deep --strict src/build/TagLauncher.app`、`Scripts/quick_search_app_name_qa.sh`、`Scripts/quick_search_system_app_qa.sh` 通过;构建产物 Info.plist 为 `7.8.25 (20260609.1741)`。 - [2026-06-09] 修复首安 Smart Start 提示确认后 App Grid 消失的问题。 - 结果: Smart Start 自动整理完成提示出现期间会抑制 AppKit 级 backdrop dismiss;点击“OK/好的”只关闭提示弹窗,不再关闭整个 App Grid。 - 范围: 只改 `ContentView.swift` 的 modal interaction 发布状态,不改 Smart Start 分类规则、不改 App Grid 布局、不改窗口层级。