From beebaf7b68b3749f4a1bc4d916e23906b8c16d7f Mon Sep 17 00:00:00 2001
From: Ariver <ar@MacBook-Air.local>
Date: Wed, 13 May 2026 23:31:23 +0800
Subject: [PATCH] Release TagLauncher 5.6.0

---
 Apptag/ApptagApp.swift           |   16 ++-
 AppStore_Submission.md           |   19 ++-
 Apptag/Localization/en.json      |    1 
 Apptag/Localization/zh-Hant.json |    1 
 Apptag/TagLauncher.entitlements  |    6 +
 Apptag/Localization/ja.json      |    1 
 Apptag/Info.plist                |    4 
 Apptag/Localization/fr.json      |    1 
 Apptag/Localization/ko.json      |    1 
 Apptag/Localization/zh-Hans.json |    1 
 Apptag/ContentView.swift         |  173 ++++++++++++++++++++++++++++++----
 Apptag/DataLayer.swift           |   49 +++++++++
 Apptag/Localization/it.json      |    1 
 Apptag/Localization/es.json      |    1 
 Apptag/Localization/ru.json      |    1 
 15 files changed, 242 insertions(+), 34 deletions(-)

diff --git a/AppStore_Submission.md b/AppStore_Submission.md
index e11b6a2..b0335d1 100644
--- a/AppStore_Submission.md
+++ b/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
diff --git a/Apptag/ApptagApp.swift b/Apptag/ApptagApp.swift
index 9698519..76f0549 100644
--- a/Apptag/ApptagApp.swift
+++ b/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 ?? "?"
     }
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 843f892..3430c2e 100644
--- a/Apptag/ContentView.swift
+++ b/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
diff --git a/Apptag/DataLayer.swift b/Apptag/DataLayer.swift
index 6147cac..2eb3d99 100644
--- a/Apptag/DataLayer.swift
+++ b/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 = [
diff --git a/Apptag/Info.plist b/Apptag/Info.plist
index 6755fc9..a9c9d12 100644
--- a/Apptag/Info.plist
+++ b/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>
diff --git a/Apptag/Localization/en.json b/Apptag/Localization/en.json
index bf25473..c8b7bc7 100644
--- a/Apptag/Localization/en.json
+++ b/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",
diff --git a/Apptag/Localization/es.json b/Apptag/Localization/es.json
index 0ce4851..6e0907f 100644
--- a/Apptag/Localization/es.json
+++ b/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",
diff --git a/Apptag/Localization/fr.json b/Apptag/Localization/fr.json
index f0a757c..6c9d00e 100644
--- a/Apptag/Localization/fr.json
+++ b/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",
diff --git a/Apptag/Localization/it.json b/Apptag/Localization/it.json
index 38ac781..44dbb93 100644
--- a/Apptag/Localization/it.json
+++ b/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",
diff --git a/Apptag/Localization/ja.json b/Apptag/Localization/ja.json
index 8f024d4..74e87a1 100644
--- a/Apptag/Localization/ja.json
+++ b/Apptag/Localization/ja.json
@@ -29,6 +29,7 @@
   "settings.iconSizeDesc": "アイコン表示サイズ。グリッド列は自動調整されます。",
   "settings.backup": "バックアップと復元",
   "settings.export": "カテゴリとレイアウトをエクスポート…",
+  "settings.exportFailed": "エクスポート失敗",
   "settings.import": "カテゴリとレイアウトをインポート…",
   "settings.backupDesc": "エクスポートはカテゴリとレイアウトをJSONファイルに保存します。インポートは現在の設定をファイルの内容で置き換えます。",
   "settings.importFailed": "インポート失敗",
diff --git a/Apptag/Localization/ko.json b/Apptag/Localization/ko.json
index ce0a436..909b4f7 100644
--- a/Apptag/Localization/ko.json
+++ b/Apptag/Localization/ko.json
@@ -29,6 +29,7 @@
   "settings.iconSizeDesc": "아이콘 표시 크기. 그리드 열은 자동 조정됩니다.",
   "settings.backup": "백업 및 복원",
   "settings.export": "분류와 레이아웃 내보내기…",
+  "settings.exportFailed": "내보내기 실패",
   "settings.import": "분류와 레이아웃 가져오기…",
   "settings.backupDesc": "내보내기는 분류와 레이아웃을 JSON 파일로 저장합니다. 가져오기는 파일의 내용으로 현재 설정을 대체합니다.",
   "settings.importFailed": "가져오기 실패",
diff --git a/Apptag/Localization/ru.json b/Apptag/Localization/ru.json
index d259399..fedb682 100644
--- a/Apptag/Localization/ru.json
+++ b/Apptag/Localization/ru.json
@@ -29,6 +29,7 @@
   "settings.iconSizeDesc": "Размер отображения значков. Колонки сетки подстраиваются автоматически.",
   "settings.backup": "Резервное копирование",
   "settings.export": "Экспорт категорий и макета…",
+  "settings.exportFailed": "Ошибка экспорта",
   "settings.import": "Импорт категорий и макета…",
   "settings.backupDesc": "Экспорт сохраняет категории и макет в JSON-файл. Импорт заменяет текущие настройки данными из файла.",
   "settings.importFailed": "Ошибка импорта",
diff --git a/Apptag/Localization/zh-Hans.json b/Apptag/Localization/zh-Hans.json
index 516e047..5e96d76 100644
--- a/Apptag/Localization/zh-Hans.json
+++ b/Apptag/Localization/zh-Hans.json
@@ -29,6 +29,7 @@
   "settings.iconSizeDesc": "图标显示尺寸。网格列数自动调整。",
   "settings.backup": "备份与恢复",
   "settings.export": "导出分类与布局…",
+  "settings.exportFailed": "导出失败",
   "settings.import": "导入分类与布局…",
   "settings.backupDesc": "导出将分类与布局保存为 JSON 文件。导入用一个文件替换当前设置。",
   "settings.importFailed": "导入失败",
diff --git a/Apptag/Localization/zh-Hant.json b/Apptag/Localization/zh-Hant.json
index d0a6bd7..abd3a21 100644
--- a/Apptag/Localization/zh-Hant.json
+++ b/Apptag/Localization/zh-Hant.json
@@ -29,6 +29,7 @@
   "settings.iconSizeDesc": "圖示顯示尺寸。網格列數自動調整。",
   "settings.backup": "備份與還原",
   "settings.export": "匯出分類與版面配置…",
+  "settings.exportFailed": "匯出失敗",
   "settings.import": "匯入分類與版面配置…",
   "settings.backupDesc": "匯出將分類與版面配置儲存為 JSON 檔案。匯入用一個檔案取代目前設定。",
   "settings.importFailed": "匯入失敗",
diff --git a/Apptag/TagLauncher.entitlements b/Apptag/TagLauncher.entitlements
index 852fa1a..e10e91e 100644
--- a/Apptag/TagLauncher.entitlements
+++ b/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>

--
Gitblit v1.9.3