Ariver
2026-05-13 beebaf7b68b3749f4a1bc4d916e23906b8c16d7f
Release TagLauncher 5.6.0
15 files modified
276 ■■■■ changed files
AppStore_Submission.md 19 ●●●● patch | view | raw | blame | history
Apptag/ApptagApp.swift 16 ●●●●● patch | view | raw | blame | history
Apptag/ContentView.swift 173 ●●●● patch | view | raw | blame | history
Apptag/DataLayer.swift 49 ●●●●● patch | view | raw | blame | history
Apptag/Info.plist 4 ●●●● patch | view | raw | blame | history
Apptag/Localization/en.json 1 ●●●● patch | view | raw | blame | history
Apptag/Localization/es.json 1 ●●●● patch | view | raw | blame | history
Apptag/Localization/fr.json 1 ●●●● patch | view | raw | blame | history
Apptag/Localization/it.json 1 ●●●● patch | view | raw | blame | history
Apptag/Localization/ja.json 1 ●●●● patch | view | raw | blame | history
Apptag/Localization/ko.json 1 ●●●● patch | view | raw | blame | history
Apptag/Localization/ru.json 1 ●●●● patch | view | raw | blame | history
Apptag/Localization/zh-Hans.json 1 ●●●● patch | view | raw | blame | history
Apptag/Localization/zh-Hant.json 1 ●●●● patch | view | raw | blame | history
Apptag/TagLauncher.entitlements 6 ●●●●● patch | view | raw | blame | history
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>