AppStore_Submission.md
@@ -1,6 +1,6 @@ # Apptag — Mac App Store 提审资料 > 版本: 5.5.0 | Bundle ID: com.apptag.launcher | 更新日期: 2026-05-11 > 版本: 5.6.0 | Bundle ID: com.apptag.launcher | 更新日期: 2026-05-13 --- @@ -11,8 +11,8 @@ | **App 名称** | Apptag | | **副标题** | Tag-Based App Launcher | | **Bundle ID** | com.apptag.launcher | | **版本号** | 5.5.0 | | **Build 号** | 550 | | **版本号** | 5.6.0 | | **Build 号** | 560 | | **SKU** | apptag-mac-001 | | **主要类别** | Utilities (工具) | | **次要类别** | Productivity (效率) | @@ -187,8 +187,12 @@ ## 8. What's New (本次版本更新说明) ``` Apptag 5.5.0 Apptag 5.6.0 - Added long-press drag sorting for tag navigation in normal app-list views - Tag sorting now works from top, left, or right tag navigation positions across all 5 app-list styles - Tag reorder animations now update both the tag navigation and matching app containers - Added automatic migration for legacy non-sandbox tag databases into the App Store sandbox container - Added a dedicated Language tab in Preferences with live language switching - Preferences now opens centered above the active TagLauncher overlay, including multi-display setups - Improved tag reordering in edit mode with live visual feedback and synchronized group layout updates @@ -196,7 +200,7 @@ - Added Mac App Store sandbox entitlements and App Store build validation - Hid Launch at Login controls in sandboxed App Store builds - Removed deprecated app launch API usage - Version updated to 5.5.0 (Build 550) - Version updated to 5.6.0 (Build 560) ``` --- @@ -220,13 +224,18 @@ **已完成配置**: 1. **Entitlements 文件**: `Apptag/TagLauncher.entitlements` 已创建,包含: - `com.apple.security.app-sandbox` - `com.apple.security.files.user-selected.read-write`,用于用户主动选择位置后的导入/导出 JSON - `com.apple.security.temporary-exception.files.home-relative-path.read-only`,用于一次性读取旧版非沙盒数据库 `~/Library/Application Support/Apptag/` 2. **build.sh 已更新**: - `APP_STORE=1` 时强制检查 entitlements 文件存在 - `APP_STORE=1 CODESIGN_IDENTITY="..." bash build.sh` 使用 entitlements 签名 3. **代码处理**: - App Store 沙盒环境下会优先迁移旧版非沙盒 `tags.json`,避免升级后标签和排序看起来丢失 - 本地非沙盒版本保留 LaunchAgent 登录启动 - App Store 沙盒环境下隐藏 Launch at Login 设置项,并跳过 LaunchAgent 写入 **审核说明建议**: 临时 home-relative-path 读取权限仅用于从旧版非沙盒存储位置迁移用户已有标签、分类和排序数据到 sandbox 容器,不用于持续访问用户文件。 **仍需验证**: TestFlight 中确认沙盒下 Carbon `RegisterEventHotKey` 是否可正常工作。 ### 🟢 已处理: Launch at Login 与 Sandbox Apptag/ApptagApp.swift
@@ -588,6 +588,7 @@ try TagDatabase.exportTo(url) } catch { fputs("[TagLauncher] Export failed: \(error)\n", stderr) showDataAlert(title: tr("settings.exportFailed"), message: error.localizedDescription) } } } @@ -604,16 +605,19 @@ scanApps() } catch { fputs("[TagLauncher] Import failed: \(error)\n", stderr) // Show alert on failure let alert = NSAlert() alert.messageText = tr("settings.importFailed") alert.informativeText = error.localizedDescription alert.alertStyle = .warning alert.runModal() showDataAlert(title: tr("settings.importFailed"), message: error.localizedDescription) } } } private func showDataAlert(title: String, message: String) { let alert = NSAlert() alert.messageText = title alert.informativeText = message alert.alertStyle = .warning alert.runModal() } private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" } Apptag/ContentView.swift
@@ -110,7 +110,10 @@ struct TagPill: View { let name: String let colorIndex: Int var dragModeActive: Bool = false var isDragging: Bool = false let action: () -> Void @State private var wiggle = false private var bgColor: Color { Color(nsColor: TagColor.nsColor(for: colorIndex)) @@ -124,16 +127,28 @@ } var body: some View { Button(action: action) { Text(name) .font(.system(size: 13, weight: .medium)) .foregroundStyle(textColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(RoundedRectangle(cornerRadius: 7).fill(bgColor)) .shadow(color: .black.opacity(0.2), radius: 3, y: 1) Text(name) .font(.system(size: 13, weight: .medium)) .foregroundStyle(textColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(RoundedRectangle(cornerRadius: 7).fill(bgColor)) .shadow(color: .black.opacity(isDragging ? 0.34 : 0.2), radius: isDragging ? 8 : 3, y: isDragging ? 4 : 1) .scaleEffect(isDragging ? 1.05 : 1.0) .rotationEffect(.degrees(dragModeActive ? (wiggle ? 1.8 : -1.8) : 0)) .animation( dragModeActive ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true) : .default, value: wiggle ) .onChange(of: dragModeActive) { _, active in wiggle = active } .contentShape(RoundedRectangle(cornerRadius: 7)) .onTapGesture { if !dragModeActive { action() } } .buttonStyle(.plain) } } @@ -142,7 +157,10 @@ struct SideTagPill: View { let name: String let colorIndex: Int var dragModeActive: Bool = false var isDragging: Bool = false let action: () -> Void @State private var wiggle = false private var bgColor: Color { Color(nsColor: TagColor.nsColor(for: colorIndex)) @@ -152,16 +170,28 @@ } var body: some View { Button(action: action) { Text(name) .font(.system(size: 13, weight: .medium)) .foregroundStyle(textColor) .padding(.horizontal, 10).padding(.vertical, 5) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 6).fill(bgColor)) .shadow(color: .black.opacity(0.2), radius: 3, y: 1) Text(name) .font(.system(size: 13, weight: .medium)) .foregroundStyle(textColor) .padding(.horizontal, 10).padding(.vertical, 5) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 6).fill(bgColor)) .shadow(color: .black.opacity(isDragging ? 0.34 : 0.2), radius: isDragging ? 8 : 3, y: isDragging ? 4 : 1) .scaleEffect(isDragging ? 1.03 : 1.0) .rotationEffect(.degrees(dragModeActive ? (wiggle ? 1.6 : -1.6) : 0)) .animation( dragModeActive ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true) : .default, value: wiggle ) .onChange(of: dragModeActive) { _, active in wiggle = active } .contentShape(RoundedRectangle(cornerRadius: 6)) .onTapGesture { if !dragModeActive { action() } } .buttonStyle(.plain) } } @@ -205,6 +235,9 @@ @State private var draggedTagNames: [String] = [] // live drag order @State private var dragItem: String? = nil // currently dragged tag @State private var tagReorderFrames: [String: CGRect] = [:] @State private var tagNavDragModeActive = false @State private var tagNavDragItem: String? = nil @State private var tagNavReorderFrames: [String: CGRect] = [:] @State private var hoveredContainer: String? = nil // colored container lift // Fixed interaction for "Colorless Container": hover fills persistently; click clears. @State private var filledColorlessContainer: String? = nil @@ -388,12 +421,17 @@ HStack(spacing: 8) { ForEach(tagLabels) { tag in TagPill(name: tag.name, colorIndex: tag.colorIndex, dragModeActive: tagNavDragModeActive && canReorderTag(tag.name), isDragging: tagNavDragItem == tag.name, action: { if isColorlessContainerMode { toggleColorlessFill(tag.id) } scrollTo(tag.id) }) .background(tagNavFrameReader(for: tag.name)) .zIndex(tagNavDragItem == tag.name ? 1 : 0) .highPriorityGesture(tagNavReorderGesture(for: tag.name)) .onHover { hovering in if hovering { fillColorlessContainer(tag.id) @@ -401,7 +439,12 @@ } } } }.padding(.horizontal, 24) } .padding(.horizontal, 24) .coordinateSpace(name: "tagNavReorder") .onPreferenceChange(TagNavReorderFramePreferenceKey.self) { frames in tagNavReorderFrames = frames } } } @@ -410,12 +453,17 @@ VStack(spacing: 6) { ForEach(tagLabels) { tag in SideTagPill(name: tag.name, colorIndex: tag.colorIndex, dragModeActive: tagNavDragModeActive && canReorderTag(tag.name), isDragging: tagNavDragItem == tag.name, action: { if isColorlessContainerMode { toggleColorlessFill(tag.id) } scrollTo(tag.id) }) .background(tagNavFrameReader(for: tag.name)) .zIndex(tagNavDragItem == tag.name ? 1 : 0) .highPriorityGesture(tagNavReorderGesture(for: tag.name)) .onHover { hovering in if hovering { fillColorlessContainer(tag.id) @@ -423,7 +471,12 @@ } } } }.padding(12) } .padding(12) .coordinateSpace(name: "tagNavReorder") .onPreferenceChange(TagNavReorderFramePreferenceKey.self) { frames in tagNavReorderFrames = frames } }.frame(width: 135) } @@ -1179,6 +1232,78 @@ filledColorlessContainer = (filledColorlessContainer == id) ? nil : id } private func canReorderTag(_ name: String) -> Bool { tagColors[name] != nil && name != "Mac自带" && name != defaultGroupName } private func tagNavFrameReader(for tagName: String) -> some View { GeometryReader { proxy in Color.clear.preference( key: TagNavReorderFramePreferenceKey.self, value: canReorderTag(tagName) ? [tagName: proxy.frame(in: .named("tagNavReorder"))] : [:] ) } } private func tagNavReorderGesture(for tagName: String) -> some Gesture { LongPressGesture(minimumDuration: 0.5) .sequenced(before: DragGesture(minimumDistance: 3, coordinateSpace: .named("tagNavReorder"))) .onChanged { value in guard canReorderTag(tagName) else { return } switch value { case .first(true): beginTagNavReorder(tagName) case .second(true, let drag?): if tagNavDragItem == nil { beginTagNavReorder(tagName) } reorderTagNavItem(at: drag.location) default: break } } .onEnded { value in guard canReorderTag(tagName) else { return } if case .second(true, let drag?) = value { reorderTagNavItem(at: drag.location) } endTagNavReorder() } } private func beginTagNavReorder(_ tagName: String) { guard canReorderTag(tagName) else { return } if tagNavDragItem == nil { tagNavDragItem = tagName } tagNavDragModeActive = true } private func endTagNavReorder() { if tagNavDragModeActive { TagEditor.reorderTags(draggedTagNames) } tagNavDragModeActive = false tagNavDragItem = nil } private func reorderTagNavItem(at location: CGPoint) { guard let fromName = tagNavDragItem, let targetName = tagNavReorderFrames.first(where: { $0.value.contains(location) })?.key, fromName != targetName, let fromIndex = draggedTagNames.firstIndex(of: fromName), let toIndex = draggedTagNames.firstIndex(of: targetName) else { return } withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) { let destination = toIndex > fromIndex ? toIndex + 1 : toIndex draggedTagNames.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: destination) } TagEditor.reorderTags(draggedTagNames) } private func handleAppDrop(_ providers: [NSItemProvider], targetTag: String) -> Bool { guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) }) else { return false @@ -1315,6 +1440,14 @@ } } private struct TagNavReorderFramePreferenceKey: PreferenceKey { static var defaultValue: [String: CGRect] = [:] static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { value.merge(nextValue(), uniquingKeysWith: { _, new in new }) } } private struct TagLabel: Identifiable { var id: String { name } let name: String Apptag/DataLayer.swift
@@ -165,9 +165,25 @@ static var storeURL: URL { storeDir.appendingPathComponent("tags.json") } private static var legacyStoreURL: URL? { guard ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil else { return nil } let home = FileManager.default.homeDirectoryForCurrentUser let bundleID = Bundle.main.bundleIdentifier ?? "com.apptag.launcher" let marker = "/Library/Containers/\(bundleID)/Data" guard let range = home.path.range(of: marker) else { return nil } let realHomePath = String(home.path[..<range.lowerBound]) return URL(fileURLWithPath: realHomePath) .appendingPathComponent("Library/Application Support/Apptag/tags.json") } // MARK: Load / Save static func load() -> Store { migrateLegacyStoreIfNeeded() guard let data = try? Data(contentsOf: storeURL), let store = try? JSONDecoder().decode(Store.self, from: data) else { return Store() } @@ -179,10 +195,40 @@ try? data.write(to: storeURL, options: .atomic) } /// App Store sandbox builds read Application Support inside the app container. /// Older non-sandbox builds stored the same database directly under ~/Library. /// On first sandbox launch, migrate the richer legacy database before seeding defaults. static func migrateLegacyStoreIfNeeded() { let fm = FileManager.default guard let legacyURL = legacyStoreURL, fm.fileExists(atPath: legacyURL.path) else { return } guard let legacyData = try? Data(contentsOf: legacyURL), let legacyStore = try? JSONDecoder().decode(Store.self, from: legacyData) else { return } if let currentData = try? Data(contentsOf: storeURL), let currentStore = try? JSONDecoder().decode(Store.self, from: currentData), storeScore(currentStore) >= storeScore(legacyStore) { return } try? fm.createDirectory(at: storeDir, withIntermediateDirectories: true) try? legacyData.write(to: storeURL, options: .atomic) } private static func storeScore(_ store: Store) -> Int { store.appTags.count * 100 + store.tagOrder.count * 10 + store.tags.count } // MARK: Export / Import static func exportTo(_ url: URL) throws { try FileManager.default.copyItem(at: storeURL, to: url) migrateLegacyStoreIfNeeded() let store = load() let data = try JSONEncoder().encode(store) try data.write(to: url, options: .atomic) } static func importFrom(_ url: URL) throws -> Store { @@ -195,6 +241,7 @@ /// Seed default tags on first launch. Only runs if store doesn't exist yet. /// Tag names are loaded from the current language's localization. static func seedDefaultTags() { migrateLegacyStoreIfNeeded() guard !FileManager.default.fileExists(atPath: storeURL.path) else { return } let keys = [ Apptag/Info.plist
@@ -19,9 +19,9 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> <string>5.5.0</string> <string>5.6.0</string> <key>CFBundleVersion</key> <string>550</string> <string>560</string> <key>LSMinimumSystemVersion</key> <string>15.0</string> <key>NSHighResolutionCapable</key> Apptag/Localization/en.json
@@ -29,6 +29,7 @@ "settings.iconSizeDesc": "Icon display size. Grid columns adjust automatically.", "settings.backup": "Backup & Restore", "settings.export": "Export Categories & Layout…", "settings.exportFailed": "Export Failed", "settings.import": "Import Categories & Layout…", "settings.backupDesc": "Export saves your categories and layout to a JSON file. Import replaces the current setup with one from a file.", "settings.importFailed": "Import Failed", Apptag/Localization/es.json
@@ -29,6 +29,7 @@ "settings.iconSizeDesc": "Tamaño de visualización de iconos. Las columnas se ajustan automáticamente.", "settings.backup": "Copia de seguridad", "settings.export": "Exportar categorías y diseño…", "settings.exportFailed": "Error de exportación", "settings.import": "Importar categorías y diseño…", "settings.backupDesc": "La exportación guarda las categorías y el diseño en un archivo JSON. La importación reemplaza la configuración actual.", "settings.importFailed": "Error de importación", Apptag/Localization/fr.json
@@ -29,6 +29,7 @@ "settings.iconSizeDesc": "Taille d'affichage des icônes. Les colonnes s'ajustent automatiquement.", "settings.backup": "Sauvegarde et restauration", "settings.export": "Exporter catégories et disposition…", "settings.exportFailed": "Échec de l'export", "settings.import": "Importer catégories et disposition…", "settings.backupDesc": "L'export sauvegarde les catégories et la disposition dans un fichier JSON. L'import remplace la configuration actuelle.", "settings.importFailed": "Échec de l'import", Apptag/Localization/it.json
@@ -29,6 +29,7 @@ "settings.iconSizeDesc": "Dimensione di visualizzazione delle icone. Le colonne si adattano automaticamente.", "settings.backup": "Backup e ripristino", "settings.export": "Esporta categorie e layout…", "settings.exportFailed": "Esportazione fallita", "settings.import": "Importa categorie e layout…", "settings.backupDesc": "L'esportazione salva categorie e layout in un file JSON. L'importazione sostituisce la configurazione attuale.", "settings.importFailed": "Importazione fallita", Apptag/Localization/ja.json
@@ -29,6 +29,7 @@ "settings.iconSizeDesc": "アイコン表示サイズ。グリッド列は自動調整されます。", "settings.backup": "バックアップと復元", "settings.export": "カテゴリとレイアウトをエクスポート…", "settings.exportFailed": "エクスポート失敗", "settings.import": "カテゴリとレイアウトをインポート…", "settings.backupDesc": "エクスポートはカテゴリとレイアウトをJSONファイルに保存します。インポートは現在の設定をファイルの内容で置き換えます。", "settings.importFailed": "インポート失敗", Apptag/Localization/ko.json
@@ -29,6 +29,7 @@ "settings.iconSizeDesc": "아이콘 표시 크기. 그리드 열은 자동 조정됩니다.", "settings.backup": "백업 및 복원", "settings.export": "분류와 레이아웃 내보내기…", "settings.exportFailed": "내보내기 실패", "settings.import": "분류와 레이아웃 가져오기…", "settings.backupDesc": "내보내기는 분류와 레이아웃을 JSON 파일로 저장합니다. 가져오기는 파일의 내용으로 현재 설정을 대체합니다.", "settings.importFailed": "가져오기 실패", Apptag/Localization/ru.json
@@ -29,6 +29,7 @@ "settings.iconSizeDesc": "Размер отображения значков. Колонки сетки подстраиваются автоматически.", "settings.backup": "Резервное копирование", "settings.export": "Экспорт категорий и макета…", "settings.exportFailed": "Ошибка экспорта", "settings.import": "Импорт категорий и макета…", "settings.backupDesc": "Экспорт сохраняет категории и макет в JSON-файл. Импорт заменяет текущие настройки данными из файла.", "settings.importFailed": "Ошибка импорта", Apptag/Localization/zh-Hans.json
@@ -29,6 +29,7 @@ "settings.iconSizeDesc": "图标显示尺寸。网格列数自动调整。", "settings.backup": "备份与恢复", "settings.export": "导出分类与布局…", "settings.exportFailed": "导出失败", "settings.import": "导入分类与布局…", "settings.backupDesc": "导出将分类与布局保存为 JSON 文件。导入用一个文件替换当前设置。", "settings.importFailed": "导入失败", Apptag/Localization/zh-Hant.json
@@ -29,6 +29,7 @@ "settings.iconSizeDesc": "圖示顯示尺寸。網格列數自動調整。", "settings.backup": "備份與還原", "settings.export": "匯出分類與版面配置…", "settings.exportFailed": "匯出失敗", "settings.import": "匯入分類與版面配置…", "settings.backupDesc": "匯出將分類與版面配置儲存為 JSON 檔案。匯入用一個檔案取代目前設定。", "settings.importFailed": "匯入失敗", Apptag/TagLauncher.entitlements
@@ -4,5 +4,11 @@ <dict> <key>com.apple.security.app-sandbox</key> <true/> <key>com.apple.security.files.user-selected.read-write</key> <true/> <key>com.apple.security.temporary-exception.files.home-relative-path.read-only</key> <array> <string>/Library/Application Support/Apptag/</string> </array> </dict> </plist>