From 667a89382078d7b38e6dc21c5627839fbde7f92c Mon Sep 17 00:00:00 2001
From: Ariver <ar@MacBook-Air.local>
Date: Mon, 11 May 2026 11:48:46 +0800
Subject: [PATCH] Archive TagLauncher 5.1.12

---
 Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128.png    |    0 
 Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512.png    |    0 
 Apptag/AppGridItem.swift                                      |  236 ++++++++++++-
 Apptag/Localization/zh-Hant.json                              |    1 
 Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png   |    0 
 Apptag/Localization/ja.json                                   |    1 
 Apptag/Info.plist                                             |    6 
 Apptag/AppDragCoordinator.swift                               |  248 ++++++++++++++
 Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png |    0 
 Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png |    0 
 Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32.png      |    0 
 .hermes.md                                                    |   21 
 Apptag/TagEditorView.swift                                    |   19 
 Apptag/Localization/zh-Hans.json                              |    1 
 generate_icon.py                                              |  108 ++---
 Apptag/Localization/es.json                                   |    1 
 icon-icns.icns                                                |    0 
 Apptag/Localization/en.json                                   |    1 
 Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png |    0 
 Apptag/Localization/fr.json                                   |    1 
 build.sh                                                      |    2 
 Apptag/TagGroupView.swift                                     |   44 ++
 /dev/null                                                     |    0 
 Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16.png      |    0 
 Apptag/Localization/ko.json                                   |    1 
 Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png   |    0 
 Apptag/ContentView.swift                                      |  141 ++++++++
 Apptag/DataLayer.swift                                        |  129 ++-----
 Apptag/Localization/it.json                                   |    1 
 Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256.png    |    0 
 Apptag/L10n.swift                                             |   12 
 Apptag/Localization/ru.json                                   |    1 
 32 files changed, 763 insertions(+), 212 deletions(-)

diff --git a/.hermes.md b/.hermes.md
index 750d003..6e29db9 100644
--- a/.hermes.md
+++ b/.hermes.md
@@ -1,31 +1,22 @@
 # Apptag Project Rules
+
 本项目 Hermes 必须严格遵守的规则
 
 ## 版本管理 & 需求记录铁律(每次任务都必须执行)
+
 1. 任何修改文件前,必须先执行 checkpoint:
    git add -A && git commit -m "checkpoint: [本次任务简要描述]"
-
 2. 任务完成后,**必须**更新 DECISIONS.md:
    - 在文件最顶部追加一节新内容(使用当前日期)
    - 格式必须严格遵守下面的模板
-
 3. 最后执行正式 commit:
    git add DECISIONS.md .hermes.md && git commit -m "docs: 更新 DECISIONS.md [任务描述]"
-
 4. 如果是重大功能,自动推送 GitHub PR(可选)
 
-## DECISIONS.md 记录模板(必须完全按照这个格式)
+## 上下文管理规则
 
-```
-## [YYYY-MM-DD] 任务标题
-**需求来源**:(你当时说的原话或简要描述)
-**决策理由**:(为什么这么实现,有什么取舍)
-**实现内容**:(做了哪些改动)
-**影响文件**:(列出修改的文件)
-**回滚命令**:git revert <本次commit-hash> 或 git checkout <上一个checkpoint>
-**测试命令**:(如果有)
-```
+- 每完成 5 个任务后,自动执行一次上下文压缩
+- 压缩完成后,在 DECISIONS.md 里新增一节 “压缩摘要”
 
-此文件由 Hermes 自动维护,请勿手动删除历史记录。
-
+##
 
diff --git a/Apptag/AppDragCoordinator.swift b/Apptag/AppDragCoordinator.swift
new file mode 100644
index 0000000..a8f0b00
--- /dev/null
+++ b/Apptag/AppDragCoordinator.swift
@@ -0,0 +1,248 @@
+import SwiftUI
+import AppKit
+
+final class AppDragCoordinator {
+    static let shared = AppDragCoordinator()
+
+    struct DropTarget {
+        weak var view: NSView?
+        var tag: String
+        var onDrop: (String, String, Bool) -> Void
+    }
+
+    private var targets: [UUID: DropTarget] = [:]
+    private weak var dragHostWindow: NSWindow?
+    private weak var dragPreviewSuperview: NSView?
+    private var dragPreviewView: DragPreviewView?
+    private var dragWindow: NSWindow?
+    private var dragImageSize: NSSize = .zero
+    private var activePayload = ""
+
+    private init() {}
+
+    func register(id: UUID, view: NSView, tag: String, onDrop: @escaping (String, String, Bool) -> Void) {
+        targets[id] = DropTarget(view: view, tag: tag, onDrop: onDrop)
+    }
+
+    func unregister(id: UUID) {
+        targets.removeValue(forKey: id)
+    }
+
+    func beginDrag(image: NSImage, payload: String, at screenPoint: NSPoint, copy: Bool, in hostWindow: NSWindow?) {
+        endDragVisuals()
+        activePayload = payload
+        dragImageSize = image.size
+        dragHostWindow = hostWindow
+
+        if let contentView = hostWindow?.contentView {
+            let preview = DragPreviewView(image: image, copy: copy, frame: NSRect(origin: .zero, size: image.size))
+            preview.translatesAutoresizingMaskIntoConstraints = true
+            preview.autoresizingMask = []
+            contentView.addSubview(preview, positioned: .above, relativeTo: nil)
+            preview.layer?.zPosition = 1_000_000
+            dragPreviewSuperview = contentView
+            dragPreviewView = preview
+            updateDragLocation(screenPoint)
+            return
+        }
+
+        let panel = NSPanel(
+            contentRect: NSRect(origin: .zero, size: image.size),
+            styleMask: [.borderless, .nonactivatingPanel],
+            backing: .buffered,
+            defer: false
+        )
+        panel.isOpaque = false
+        panel.backgroundColor = .clear
+        panel.hasShadow = false
+        panel.isFloatingPanel = true
+        panel.hidesOnDeactivate = false
+        panel.ignoresMouseEvents = true
+        panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
+        panel.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary, .stationary, .transient, .ignoresCycle]
+        panel.isReleasedWhenClosed = false
+
+        panel.contentView = DragPreviewView(image: image, copy: copy, frame: NSRect(origin: .zero, size: image.size))
+
+        dragWindow = panel
+        updateDragLocation(screenPoint)
+        panel.setFrame(panel.frame, display: true)
+        panel.orderFrontRegardless()
+    }
+
+    func updateDragLocation(_ screenPoint: NSPoint, copy: Bool? = nil) {
+        if let dragPreviewView, let dragHostWindow, let contentView = dragPreviewSuperview {
+            if let copy {
+                dragPreviewView.isCopyMode = copy
+            }
+            let windowPoint = dragHostWindow.convertPoint(fromScreen: screenPoint)
+            let contentPoint = contentView.convert(windowPoint, from: nil)
+            dragPreviewView.setFrameOrigin(
+                NSPoint(
+                    x: contentPoint.x - dragImageSize.width / 2,
+                    y: contentPoint.y - dragImageSize.height / 2
+                )
+            )
+            return
+        }
+
+        guard let dragWindow else { return }
+        let origin = NSPoint(
+            x: screenPoint.x - dragImageSize.width / 2,
+            y: screenPoint.y - dragImageSize.height / 2
+        )
+        dragWindow.setFrameOrigin(origin)
+    }
+
+    func finishDrag(at screenPoint: NSPoint, copy: Bool) {
+        defer { endDragVisuals() }
+        let parts = activePayload.components(separatedBy: "\n")
+        guard let path = parts.first, !path.isEmpty else { return }
+        let source = parts.dropFirst().first ?? ""
+
+        let hitTarget = targets.values
+            .compactMap { target -> (DropTarget, CGFloat)? in
+                guard let frame = target.view?.screenFrame(), frame.contains(screenPoint) else { return nil }
+                return (target, frame.width * frame.height)
+            }
+            .sorted { $0.1 < $1.1 }
+            .first?.0
+
+        hitTarget?.onDrop(path, source, copy)
+    }
+
+    func cancelDrag() {
+        endDragVisuals()
+    }
+
+    private func endDragVisuals() {
+        dragPreviewView?.removeFromSuperview()
+        dragPreviewView = nil
+        dragPreviewSuperview = nil
+        dragHostWindow = nil
+        dragWindow?.orderOut(nil)
+        dragWindow = nil
+        activePayload = ""
+        dragImageSize = .zero
+    }
+}
+
+private final class DragPreviewView: NSView {
+    private let image: NSImage
+    var isCopyMode: Bool {
+        didSet {
+            if oldValue != isCopyMode {
+                needsDisplay = true
+            }
+        }
+    }
+
+    init(image: NSImage, copy: Bool, frame: NSRect) {
+        self.image = image
+        self.isCopyMode = copy
+        super.init(frame: frame)
+        wantsLayer = true
+        layer?.masksToBounds = false
+    }
+
+    required init?(coder: NSCoder) {
+        nil
+    }
+
+    override var isOpaque: Bool { false }
+
+    override func hitTest(_ point: NSPoint) -> NSView? {
+        nil
+    }
+
+    override func draw(_ dirtyRect: NSRect) {
+        NSColor.clear.setFill()
+        dirtyRect.fill()
+        image.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1.0)
+
+        guard isCopyMode else { return }
+
+        let badgeSize = min(bounds.width, bounds.height) * 0.28
+        let badgeRect = NSRect(
+            x: bounds.maxX - badgeSize - badgeSize * 0.22,
+            y: bounds.maxY - badgeSize - badgeSize * 0.22,
+            width: badgeSize,
+            height: badgeSize
+        )
+
+        NSGraphicsContext.saveGraphicsState()
+        let shadow = NSShadow()
+        shadow.shadowColor = NSColor.black.withAlphaComponent(0.30)
+        shadow.shadowBlurRadius = 8
+        shadow.shadowOffset = NSSize(width: 0, height: -2)
+        shadow.set()
+        NSColor.systemGreen.setFill()
+        NSBezierPath(ovalIn: badgeRect).fill()
+        NSGraphicsContext.restoreGraphicsState()
+
+        let plus = "+"
+        let attrs: [NSAttributedString.Key: Any] = [
+            .font: NSFont.systemFont(ofSize: badgeSize * 0.78, weight: .bold),
+            .foregroundColor: NSColor.white
+        ]
+        let plusSize = plus.size(withAttributes: attrs)
+        plus.draw(
+            at: NSPoint(
+                x: badgeRect.midX - plusSize.width / 2,
+                y: badgeRect.midY - plusSize.height / 2 + badgeSize * 0.03
+            ),
+            withAttributes: attrs
+        )
+    }
+}
+
+private extension NSView {
+    func screenFrame() -> NSRect? {
+        guard let window else { return nil }
+        let rectInWindow = convert(bounds, to: nil)
+        return window.convertToScreen(rectInWindow)
+    }
+}
+
+struct AppDropTargetView: NSViewRepresentable {
+    let targetTag: String
+    let onDropApp: (String, String, Bool) -> Void
+
+    func makeNSView(context: Context) -> AppDropTargetNSView {
+        let view = AppDropTargetNSView()
+        view.targetTag = targetTag
+        view.onDropApp = onDropApp
+        return view
+    }
+
+    func updateNSView(_ view: AppDropTargetNSView, context: Context) {
+        view.targetTag = targetTag
+        view.onDropApp = onDropApp
+        view.registerTarget()
+    }
+
+    static func dismantleNSView(_ view: AppDropTargetNSView, coordinator: ()) {
+        AppDragCoordinator.shared.unregister(id: view.id)
+    }
+}
+
+final class AppDropTargetNSView: NSView {
+    let id = UUID()
+    var targetTag = ""
+    var onDropApp: ((String, String, Bool) -> Void)?
+
+    override func viewDidMoveToWindow() {
+        super.viewDidMoveToWindow()
+        registerTarget()
+    }
+
+    override func layout() {
+        super.layout()
+        registerTarget()
+    }
+
+    func registerTarget() {
+        guard window != nil, let onDropApp else { return }
+        AppDragCoordinator.shared.register(id: id, view: self, tag: targetTag, onDrop: onDropApp)
+    }
+}
diff --git a/Apptag/AppGridItem.swift b/Apptag/AppGridItem.swift
index b3b8141..f25f223 100644
--- a/Apptag/AppGridItem.swift
+++ b/Apptag/AppGridItem.swift
@@ -7,39 +7,223 @@
     let app: AppInfo
     let iconSize: CGFloat
     var showName: Bool = true
+    var sourceTag: String? = nil
+    var dragModeActive: Bool = false
+    var onDragModeChange: ((Bool) -> Void)? = nil
     let onSelect: () -> Void
 
     @State private var isHovered = false
+    @State private var wiggle = false
+
+    private let hoverScale: CGFloat = 1.22
 
     var body: some View {
-        Button(action: onSelect) {
-            VStack(spacing: 6) {
-                Image(nsImage: app.icon)
-                    .resizable()
-                    .aspectRatio(contentMode: .fit)
-                    .frame(width: iconSize, height: iconSize)
-                    .scaleEffect(isHovered ? 1.22 : 1.0)
-                    .shadow(
-                        color: .black.opacity(isHovered ? 0.35 : 0),
-                        radius: isHovered ? 14 : 0,
-                        y: isHovered ? 8 : 0
-                    )
-                    .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered)
+        VStack(spacing: 6) {
+            DraggableAppIconView(
+                icon: app.icon,
+                iconSize: iconSize,
+                payload: "\(app.path.path)\n\(sourceTag ?? "")",
+                onHover: { isHovered = $0 },
+                onLongPress: { onDragModeChange?(true) },
+                onDragEnd: { onDragModeChange?(false) },
+                onClick: onSelect
+            )
+            .frame(width: iconSize, height: iconSize)
+            .scaleEffect(isHovered ? hoverScale : 1.0)
+            .shadow(
+                color: .black.opacity(isHovered ? 0.35 : 0),
+                radius: isHovered ? 14 : 0,
+                y: isHovered ? 8 : 0
+            )
+            .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered)
 
-                Text(app.name)
-                    .font(.system(size: 11, weight: .medium))
-                    .lineLimit(1)
-                    .truncationMode(.tail)
-                    .frame(maxWidth: iconSize + 20)
-                    .opacity(showName ? 1 : (isHovered ? 0.85 : 0))
-            }
-            .padding(.vertical, 8)
-            .padding(.horizontal, 4)
-            .contentShape(RoundedRectangle(cornerRadius: 10))
+            Text(app.name)
+                .font(.system(size: 11, weight: .medium))
+                .lineLimit(1)
+                .truncationMode(.tail)
+                .frame(maxWidth: iconSize + 20)
+                .opacity(showName ? 1 : (isHovered ? 0.85 : 0))
         }
-        .buttonStyle(.plain)
-        .onHover { hovering in
-            isHovered = hovering
+        .padding(.vertical, 8)
+        .padding(.horizontal, 4)
+        .contentShape(RoundedRectangle(cornerRadius: 10))
+        .rotationEffect(.degrees(dragModeActive ? (wiggle ? 2.0 : -2.0) : 0))
+        .animation(
+            dragModeActive
+                ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true)
+                : .default,
+            value: wiggle
+        )
+        .onChange(of: dragModeActive) { _, active in
+            wiggle = active
         }
     }
 }
+
+private struct DraggableAppIconView: NSViewRepresentable {
+    let icon: NSImage
+    let iconSize: CGFloat
+    let payload: String
+    let onHover: (Bool) -> Void
+    let onLongPress: () -> Void
+    let onDragEnd: () -> Void
+    let onClick: () -> Void
+
+    func makeNSView(context: Context) -> DragIconNSView {
+        let view = DragIconNSView()
+        view.image = icon
+        view.iconSize = iconSize
+        view.payload = payload
+        view.onHover = onHover
+        view.onLongPress = onLongPress
+        view.onDragEnd = onDragEnd
+        view.onClick = onClick
+        return view
+    }
+
+    func updateNSView(_ view: DragIconNSView, context: Context) {
+        view.image = icon
+        view.iconSize = iconSize
+        view.payload = payload
+        view.onHover = onHover
+        view.onLongPress = onLongPress
+        view.onDragEnd = onDragEnd
+        view.onClick = onClick
+        view.needsDisplay = true
+    }
+}
+
+private final class DragIconNSView: NSView {
+    var image: NSImage = NSImage()
+    var iconSize: CGFloat = 56
+    var payload: String = ""
+    var onHover: ((Bool) -> Void)?
+    var onLongPress: (() -> Void)?
+    var onDragEnd: (() -> Void)?
+    var onClick: (() -> Void)?
+
+    private var mouseDownEvent: NSEvent?
+    private var didStartDrag = false
+    private var isLongPressActive = false
+    private var longPressWorkItem: DispatchWorkItem?
+    private var trackingAreaRef: NSTrackingArea?
+
+    override var isFlipped: Bool { true }
+
+    override func updateTrackingAreas() {
+        super.updateTrackingAreas()
+        if let trackingAreaRef {
+            removeTrackingArea(trackingAreaRef)
+        }
+        let area = NSTrackingArea(
+            rect: bounds,
+            options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+            owner: self,
+            userInfo: nil
+        )
+        trackingAreaRef = area
+        addTrackingArea(area)
+    }
+
+    override func draw(_ dirtyRect: NSRect) {
+        super.draw(dirtyRect)
+        let rect = NSRect(
+            x: (bounds.width - iconSize) / 2,
+            y: (bounds.height - iconSize) / 2,
+            width: iconSize,
+            height: iconSize
+        )
+        image.draw(in: rect)
+    }
+
+    override func mouseEntered(with event: NSEvent) {
+        onHover?(true)
+    }
+
+    override func mouseDown(with event: NSEvent) {
+        mouseDownEvent = event
+        didStartDrag = false
+        isLongPressActive = false
+        let workItem = DispatchWorkItem { [weak self] in
+            guard let self, self.mouseDownEvent != nil else { return }
+            self.isLongPressActive = true
+            self.onLongPress?()
+        }
+        longPressWorkItem = workItem
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
+    }
+
+    override func mouseDragged(with event: NSEvent) {
+        if didStartDrag {
+            AppDragCoordinator.shared.updateDragLocation(
+                screenPoint(for: event),
+                copy: event.modifierFlags.contains(.option)
+            )
+            return
+        }
+
+        guard let mouseDownEvent, isLongPressActive else { return }
+        let dx = event.locationInWindow.x - mouseDownEvent.locationInWindow.x
+        let dy = event.locationInWindow.y - mouseDownEvent.locationInWindow.y
+        guard hypot(dx, dy) > 3 else { return }
+
+        didStartDrag = true
+        longPressWorkItem?.cancel()
+        AppDragCoordinator.shared.beginDrag(
+            image: makeDragImage(),
+            payload: payload,
+            at: screenPoint(for: event),
+            copy: event.modifierFlags.contains(.option),
+            in: window
+        )
+    }
+
+    override func mouseUp(with event: NSEvent) {
+        longPressWorkItem?.cancel()
+        if didStartDrag {
+            AppDragCoordinator.shared.finishDrag(
+                at: screenPoint(for: event),
+                copy: event.modifierFlags.contains(.option)
+            )
+            onDragEnd?()
+        } else if !isLongPressActive {
+            onClick?()
+        } else {
+            onDragEnd?()
+        }
+        didStartDrag = false
+        isLongPressActive = false
+        mouseDownEvent = nil
+    }
+
+    override func mouseExited(with event: NSEvent) {
+        if !didStartDrag {
+            onHover?(false)
+        }
+    }
+
+    private func makeDragImage() -> NSImage {
+        let scale: CGFloat = 1.22 * 1.5
+        let imageSize = iconSize * scale
+        let padding = iconSize * 0.45
+        let canvasSize = NSSize(width: imageSize + padding * 2, height: imageSize + padding * 2)
+        let dragImage = NSImage(size: canvasSize)
+
+        dragImage.lockFocus()
+        NSGraphicsContext.current?.imageInterpolation = .high
+
+        let shadow = NSShadow()
+        shadow.shadowColor = NSColor.black.withAlphaComponent(0.48)
+        shadow.shadowBlurRadius = 24
+        shadow.shadowOffset = NSSize(width: 0, height: -14)
+        shadow.set()
+
+        image.draw(in: NSRect(x: padding, y: padding, width: imageSize, height: imageSize))
+        dragImage.unlockFocus()
+        return dragImage
+    }
+
+    private func screenPoint(for event: NSEvent) -> NSPoint {
+        window?.convertPoint(toScreen: event.locationInWindow) ?? NSEvent.mouseLocation
+    }
+}
diff --git a/Apptag/AppIcon.iconset/AppIcon.icns b/Apptag/AppIcon.iconset/AppIcon.icns
deleted file mode 100644
index e9b04ea..0000000
--- a/Apptag/AppIcon.iconset/AppIcon.icns
+++ /dev/null
Binary files differ
diff --git a/Apptag/AppIcon.iconset/icon_1024_preview.png b/Apptag/AppIcon.iconset/icon_1024_preview.png
deleted file mode 100644
index 391bbaa..0000000
--- a/Apptag/AppIcon.iconset/icon_1024_preview.png
+++ /dev/null
Binary files differ
diff --git a/Apptag/AppIcon.iconset/icon_128x128.png b/Apptag/AppIcon.iconset/icon_128x128.png
deleted file mode 100644
index fc47afb..0000000
--- a/Apptag/AppIcon.iconset/icon_128x128.png
+++ /dev/null
Binary files differ
diff --git a/Apptag/AppIcon.iconset/icon_128x128@2x.png b/Apptag/AppIcon.iconset/icon_128x128@2x.png
deleted file mode 100644
index 59de752..0000000
--- a/Apptag/AppIcon.iconset/icon_128x128@2x.png
+++ /dev/null
Binary files differ
diff --git a/Apptag/AppIcon.iconset/icon_16x16.png b/Apptag/AppIcon.iconset/icon_16x16.png
deleted file mode 100644
index 9b6c2b5..0000000
--- a/Apptag/AppIcon.iconset/icon_16x16.png
+++ /dev/null
Binary files differ
diff --git a/Apptag/AppIcon.iconset/icon_16x16@2x.png b/Apptag/AppIcon.iconset/icon_16x16@2x.png
deleted file mode 100644
index 338de73..0000000
--- a/Apptag/AppIcon.iconset/icon_16x16@2x.png
+++ /dev/null
Binary files differ
diff --git a/Apptag/AppIcon.iconset/icon_256x256.png b/Apptag/AppIcon.iconset/icon_256x256.png
deleted file mode 100644
index 59de752..0000000
--- a/Apptag/AppIcon.iconset/icon_256x256.png
+++ /dev/null
Binary files differ
diff --git a/Apptag/AppIcon.iconset/icon_256x256@2x.png b/Apptag/AppIcon.iconset/icon_256x256@2x.png
deleted file mode 100644
index 848cdd4..0000000
--- a/Apptag/AppIcon.iconset/icon_256x256@2x.png
+++ /dev/null
Binary files differ
diff --git a/Apptag/AppIcon.iconset/icon_32x32.png b/Apptag/AppIcon.iconset/icon_32x32.png
deleted file mode 100644
index 338de73..0000000
--- a/Apptag/AppIcon.iconset/icon_32x32.png
+++ /dev/null
Binary files differ
diff --git a/Apptag/AppIcon.iconset/icon_32x32@2x.png b/Apptag/AppIcon.iconset/icon_32x32@2x.png
deleted file mode 100644
index 9cda5f4..0000000
--- a/Apptag/AppIcon.iconset/icon_32x32@2x.png
+++ /dev/null
Binary files differ
diff --git a/Apptag/AppIcon.iconset/icon_512x512.png b/Apptag/AppIcon.iconset/icon_512x512.png
deleted file mode 100644
index 848cdd4..0000000
--- a/Apptag/AppIcon.iconset/icon_512x512.png
+++ /dev/null
Binary files differ
diff --git a/Apptag/AppIcon.iconset/icon_512x512@2x.png b/Apptag/AppIcon.iconset/icon_512x512@2x.png
deleted file mode 100644
index 391bbaa..0000000
--- a/Apptag/AppIcon.iconset/icon_512x512@2x.png
+++ /dev/null
Binary files differ
diff --git a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
index fc47afb..c3de6aa 100644
--- a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
+++ b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
Binary files differ
diff --git a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
index 59de752..90e11ab 100644
--- a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
+++ b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
Binary files differ
diff --git a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
index 9b6c2b5..74a20d5 100644
--- a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
+++ b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
Binary files differ
diff --git a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
index 338de73..929343b 100644
--- a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
+++ b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
Binary files differ
diff --git a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
index 59de752..90e11ab 100644
--- a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
+++ b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
Binary files differ
diff --git a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
index 848cdd4..0299db0 100644
--- a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
+++ b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
Binary files differ
diff --git a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
index 338de73..929343b 100644
--- a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
+++ b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
Binary files differ
diff --git a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
index 9cda5f4..24e6603 100644
--- a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
+++ b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
Binary files differ
diff --git a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
index 848cdd4..0299db0 100644
--- a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
+++ b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
Binary files differ
diff --git a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
index 391bbaa..3633d92 100644
--- a/Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
+++ b/Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
Binary files differ
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 8962e5a..b8833eb 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -1,5 +1,6 @@
 import SwiftUI
 import AppKit
+import UniformTypeIdentifiers
 
 // MARK: - Notification for manual re-index
 
@@ -206,6 +207,8 @@
     @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
+    @State private var appDragModeActive = false
+    @State private var dropWarningToast: String? = nil
 
     // Configurable defaults
     @AppStorage("defaultGroupName") private var defaultGroupName = "Other"
@@ -251,6 +254,21 @@
                 editTagsView
             case .editingApps:
                 editAppsView
+            }
+
+            if let message = dropWarningToast {
+                Text(message)
+                    .font(.system(size: 16, weight: .semibold))
+                    .foregroundStyle(.primary)
+                    .padding(.horizontal, 22)
+                    .padding(.vertical, 12)
+                    .background(
+                        RoundedRectangle(cornerRadius: 10)
+                            .fill(.ultraThickMaterial)
+                            .shadow(color: .black.opacity(0.22), radius: 18, y: 10)
+                    )
+                    .transition(.scale(scale: 0.96).combined(with: .opacity))
+                    .allowsHitTesting(false)
             }
         }
         .onAppear {
@@ -415,7 +433,12 @@
                             onSelectApp: { app in openApp(app) },
                             tagFontSize: tagFontSize,
                             iconSize: iconSize,
-                            showNames: !hideAppNames
+                            showNames: !hideAppNames,
+                            dragModeActive: appDragModeActive,
+                            onDragModeChange: { setAppDragMode($0) },
+                            onDropApp: { path, source, copy in
+                                dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy)
+                            }
                         ).id(group.id)
                     }
                 }.padding(20)
@@ -504,7 +527,15 @@
                 spacing: 2
             ) {
                 ForEach(group.apps) { app in
-                    AppGridItem(app: app, iconSize: iconSize, showName: !hideAppNames, onSelect: { openApp(app) })
+                    AppGridItem(
+                        app: app,
+                        iconSize: iconSize,
+                        showName: !hideAppNames,
+                        sourceTag: group.name,
+                        dragModeActive: appDragModeActive,
+                        onDragModeChange: { setAppDragMode($0) },
+                        onSelect: { openApp(app) }
+                    )
                 }
             }
         }
@@ -522,6 +553,12 @@
             RoundedRectangle(cornerRadius: 14)
                 .stroke(Color.primary.opacity(0.08), lineWidth: 1)
         )
+        .overlay {
+            AppDropTargetView(targetTag: group.name) { path, source, copy in
+                dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy)
+            }
+            .allowsHitTesting(false)
+        }
         .shadow(color: .black.opacity((isColored && isHovered) || isColorlessActive ? 0.22 : 0),
                 radius: (isColored && isHovered) || isColorlessActive ? 18 : 0,
                 y: (isColored && isHovered) || isColorlessActive ? 10 : 0)
@@ -538,6 +575,9 @@
             if isColorless {
                 toggleColorlessFill(group.name)
             }
+        }
+        .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in
+            handleAppDrop(providers, targetTag: group.name)
         }
     }
 
@@ -721,7 +761,15 @@
                 ForEach(rows.indices, id: \.self) { rowIndex in
                     HStack(alignment: .top, spacing: 6) {
                         ForEach(rows[rowIndex]) { app in
-                            AppGridItem(app: app, iconSize: iconSize, showName: !hideAppNames, onSelect: { openApp(app) })
+                            AppGridItem(
+                                app: app,
+                                iconSize: iconSize,
+                                showName: !hideAppNames,
+                                sourceTag: group.name,
+                                dragModeActive: appDragModeActive,
+                                onDragModeChange: { setAppDragMode($0) },
+                                onSelect: { openApp(app) }
+                            )
                                 .frame(width: cellWidth)
                                 .frame(height: cellHeight)
                         }
@@ -751,6 +799,12 @@
             RoundedRectangle(cornerRadius: 14)
                 .stroke(Color.primary.opacity(0.08), lineWidth: 1)
         )
+        .overlay {
+            AppDropTargetView(targetTag: group.name) { path, source, copy in
+                dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy)
+            }
+            .allowsHitTesting(false)
+        }
         .shadow(color: .black.opacity((isColored && isHovered) || isColorlessGridActive ? 0.22 : 0),
                 radius: (isColored && isHovered) || isColorlessGridActive ? 18 : 0,
                 y: (isColored && isHovered) || isColorlessGridActive ? 10 : 0)
@@ -769,6 +823,9 @@
             if isColorlessGrid {
                 toggleColorlessFill(group.name)
             }
+        }
+        .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in
+            handleAppDrop(providers, targetTag: group.name)
         }
     }
 
@@ -1031,6 +1088,83 @@
         filledColorlessContainer = (filledColorlessContainer == id) ? nil : id
     }
 
+    private func handleAppDrop(_ providers: [NSItemProvider], targetTag: String) -> Bool {
+        guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) }) else {
+            return false
+        }
+        provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
+            let text: String?
+            if let data = item as? Data {
+                text = String(data: data, encoding: .utf8)
+            } else if let string = item as? String {
+                text = string
+            } else if let string = item as? NSString {
+                text = string as String
+            } else {
+                text = nil
+            }
+            guard let text else { return }
+            let parts = text.components(separatedBy: "\n")
+            guard let path = parts.first, !path.isEmpty else { return }
+            let source = parts.dropFirst().first ?? ""
+            let copy = NSEvent.modifierFlags.contains(.option)
+            DispatchQueue.main.async {
+                dropApp(path: path, sourceTag: source, targetTag: targetTag, copy: copy)
+            }
+        }
+        return true
+    }
+
+    private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) {
+        appDragModeActive = false
+        guard !isSystemDefaultDropTarget(targetTag) else {
+            showDropWarning()
+            return
+        }
+        guard tagColors[targetTag] != nil else { return }
+        guard sourceTag != targetTag || copy else { return }
+        TagEditor.moveApp(
+            path: path,
+            from: sourceTag,
+            to: targetTag,
+            color: tagColors[targetTag] ?? 0,
+            copy: copy
+        )
+        refreshApps()
+    }
+
+    private func isSystemDefaultDropTarget(_ targetTag: String) -> Bool {
+        let defaultNames = [
+            defaultGroupName,
+            tr("group.uncategorized"),
+            "Mac自带",
+            tr("group.appleBuiltIn")
+        ]
+        return defaultNames.contains(targetTag)
+    }
+
+    private func showDropWarning() {
+        withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
+            dropWarningToast = tr("drop.systemDefaultWarning")
+        }
+        DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) {
+            withAnimation(.easeOut(duration: 0.18)) {
+                dropWarningToast = nil
+            }
+        }
+    }
+
+    private func setAppDragMode(_ active: Bool) {
+        appDragModeActive = active
+        if active {
+            DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
+                if appDragModeActive {
+                    appDragModeActive = false
+                }
+            }
+        }
+    }
+
     func refreshApps() {
         DispatchQueue.global(qos: .userInitiated).async {
             var apps = AppIndexer.scan()
@@ -1047,6 +1181,7 @@
     }
 
     func openApp(_ app: AppInfo) {
+        appDragModeActive = false
         hideOverlay()
         if let bundleIdentifier = app.bundleIdentifier {
             NSWorkspace.shared.launchApplication(
diff --git a/Apptag/DataLayer.swift b/Apptag/DataLayer.swift
index f84b8d2..84d03d5 100644
--- a/Apptag/DataLayer.swift
+++ b/Apptag/DataLayer.swift
@@ -43,7 +43,7 @@
         default: return NSColor.systemGray
         }
     }
-    static let allIndices: [Int] = [0, 1, 2, 3, 4, 5, 6, 7]
+    static let allIndices: [Int] = [1, 2, 3, 4, 5, 6, 7]
 }
 
 // MARK: - App Scanner
@@ -57,8 +57,7 @@
             .appendingPathComponent("Applications")
     ]
 
-    /// Scan all standard locations. Tags are NOT read from Finder —
-    /// they're annotated from TagDatabase by the caller.
+    /// Scan all standard locations. Tags are annotated from TagDatabase by the caller.
     static func scan() -> [AppInfo] {
         var seen = Set<URL>()
         var apps: [AppInfo] = []
@@ -153,7 +152,6 @@
         var tags: [String: TagDef] = [:]
         var appTags: [String: [String]] = [:]  // path → tag names
         var tagOrder: [String] = []  // display order; empty → alpha sort
-        var migrated: Bool = false
     }
 
     // MARK: Paths
@@ -181,83 +179,6 @@
         try? data.write(to: storeURL, options: .atomic)
     }
 
-    // MARK: Migration (first launch: import Finder tags)
-
-    /// Run once on first launch. Reads Finder tags from all scanned apps
-    /// and seeds the local database. Also auto-assigns "Mac自带" to Apple apps.
-    static func migrateFromFinderIfNeeded(apps: [AppInfo]) -> Store {
-        var store = load()
-        guard !store.migrated else { return store }
-
-        // Read Finder tags from each app
-        for app in apps {
-            let finderTags = readFinderTags(from: app.path)
-            guard !finderTags.isEmpty else { continue }
-            store.appTags[app.path.path] = finderTags.map { $0.name }
-            for (name, color) in finderTags {
-                if store.tags[name] == nil {
-                    store.tags[name] = TagDef(color: color)
-                }
-            }
-        }
-
-        // Auto-assign "Mac自带" tag to Apple apps
-        for app in apps where app.isAppleApp {
-            var current = store.appTags[app.path.path] ?? []
-            if !current.contains("Mac自带") {
-                current.append("Mac自带")
-                store.appTags[app.path.path] = current
-            }
-        }
-        if store.tags["Mac自带"] == nil {
-            store.tags["Mac自带"] = TagDef(color: 1)  // gray
-        }
-
-        store.migrated = true
-        save(store)
-        return store
-    }
-
-    /// Read Finder tags (name, color) from a single .app bundle.
-    /// Only used during one-time migration.
-    private static func readFinderTags(from url: URL) -> [(name: String, color: Int)] {
-        // Read tag names via NSURL resource values
-        guard let values = try? url.resourceValues(forKeys: [.tagNamesKey]),
-              let tagNames = values.tagNames, !tagNames.isEmpty
-        else { return [] }
-
-        // Read tag colors from xattr
-        let xattrName = "com.apple.metadata:_kMDItemUserTags"
-        let path = url.path
-        let size = getxattr(path, xattrName, nil, 0, 0, 0)
-        guard size > 0 else {
-            return tagNames.map { ($0, 0) }
-        }
-
-        var buffer = [UInt8](repeating: 0, count: size)
-        guard getxattr(path, xattrName, &buffer, size, 0, 0) == size else {
-            return tagNames.map { ($0, 0) }
-        }
-
-        let data = Data(buffer)
-        guard let plist = try? PropertyListSerialization.propertyList(
-            from: data, options: [], format: nil
-        ) as? [String] else {
-            return tagNames.map { ($0, 0) }
-        }
-
-        // Build name→color map from xattr entries
-        var colorMap: [String: Int] = [:]
-        for entry in plist {
-            let parts = entry.components(separatedBy: "\n")
-            guard !parts[0].isEmpty else { continue }
-            let c = (parts.count >= 2) ? (Int(parts[1]) ?? 0) : 0
-            colorMap[parts[0]] = (0...7).contains(c) ? c : 0
-        }
-
-        return tagNames.map { ($0, colorMap[$0] ?? 0) }
-    }
-
     // MARK: Export / Import
 
     static func exportTo(_ url: URL) throws {
@@ -269,6 +190,27 @@
         let store = try JSONDecoder().decode(Store.self, from: data)
         save(store)
         return store
+    }
+
+    /// 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() {
+        guard !FileManager.default.fileExists(atPath: storeURL.path) else { return }
+
+        let keys = [
+            "tag.design", "tag.development", "tag.writing",
+            "tag.gaming", "tag.entertainment", "tag.system",
+            "tag.productivity"
+        ]
+        let colors: [Int] = [1, 2, 3, 4, 5, 6, 7]
+
+        var store = Store()
+        for (i, key) in keys.enumerated() {
+            let name = tr(key)
+            store.tags[name] = TagDef(color: colors[i])
+            store.tagOrder.append(name)
+        }
+        save(store)
     }
 }
 
@@ -341,14 +283,25 @@
         TagDatabase.save(store)
     }
 
-    /// Remove a tag from specific apps.
-    static func removeTag(_ tag: String, from paths: [String]) {
+    static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) {
         var store = TagDatabase.load()
-        for path in paths {
-            store.appTags[path]?.removeAll { $0 == tag }
-            if store.appTags[path]?.isEmpty == true {
-                store.appTags.removeValue(forKey: path)
-            }
+        if store.tags[targetTag] == nil {
+            store.tags[targetTag] = TagDatabase.TagDef(color: color)
+            if !store.tagOrder.contains(targetTag) { store.tagOrder.insert(targetTag, at: 0) }
+        }
+
+        var current = store.appTags[path] ?? []
+        if !copy, !sourceTag.isEmpty {
+            current.removeAll { $0 == sourceTag }
+        }
+        if !current.contains(targetTag) {
+            current.append(targetTag)
+        }
+
+        if current.isEmpty {
+            store.appTags.removeValue(forKey: path)
+        } else {
+            store.appTags[path] = current
         }
         TagDatabase.save(store)
     }
diff --git a/Apptag/Info.plist b/Apptag/Info.plist
index 0d9c32e..781fedb 100644
--- a/Apptag/Info.plist
+++ b/Apptag/Info.plist
@@ -19,11 +19,11 @@
 	<key>CFBundlePackageType</key>
 	<string>APPL</string>
 	<key>CFBundleShortVersionString</key>
-	<string>4.0.7</string>
+	<string>5.1.12</string>
 	<key>CFBundleVersion</key>
-	<string>8</string>
+	<string>12</string>
 	<key>LSMinimumSystemVersion</key>
-	<string>26.0</string>
+	<string>15.0</string>
 	<key>NSHighResolutionCapable</key>
 	<true/>
 	<key>NSPrincipalClass</key>
diff --git a/Apptag/L10n.swift b/Apptag/L10n.swift
index 9d6ac8b..9a80d8a 100644
--- a/Apptag/L10n.swift
+++ b/Apptag/L10n.swift
@@ -29,6 +29,18 @@
         UserDefaults.standard.set(code, forKey: "appLanguage")
     }
 
+    /// Load a specific key's translation for a given language code without switching.
+    static func loadedTranslation(_ key: String, for code: String) -> String? {
+        guard let url = Bundle.main.url(
+            forResource: code, withExtension: "json",
+            subdirectory: "Localization"
+        ) else { return nil }
+        guard let data = try? Data(contentsOf: url),
+              let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String]
+        else { return nil }
+        return dict[key]
+    }
+
     private static func load(_ code: String) {
         guard let url = Bundle.main.url(
             forResource: code, withExtension: "json",
diff --git a/Apptag/Localization/en.json b/Apptag/Localization/en.json
index aab8b35..b334e73 100644
--- a/Apptag/Localization/en.json
+++ b/Apptag/Localization/en.json
@@ -49,6 +49,7 @@
   "app.build": "Build",
   "group.uncategorized": "Uncategorized",
   "group.appleBuiltIn": "Built-in",
+  "drop.systemDefaultWarning": "Do not drag apps into system default categories",
   "tag.design": "Design",
   "tag.development": "Development",
   "tag.writing": "Writing",
diff --git a/Apptag/Localization/es.json b/Apptag/Localization/es.json
index 7a27672..e32c942 100644
--- a/Apptag/Localization/es.json
+++ b/Apptag/Localization/es.json
@@ -49,6 +49,7 @@
   "app.build": "Build",
   "group.uncategorized": "Sin categorizar",
   "group.appleBuiltIn": "Integradas",
+  "drop.systemDefaultWarning": "No arrastres a las categorías predeterminadas del sistema",
   "tag.design": "Diseño",
   "tag.development": "Desarrollo",
   "tag.writing": "Escritura",
diff --git a/Apptag/Localization/fr.json b/Apptag/Localization/fr.json
index e5664be..93a16fd 100644
--- a/Apptag/Localization/fr.json
+++ b/Apptag/Localization/fr.json
@@ -49,6 +49,7 @@
   "app.build": "Build",
   "group.uncategorized": "Non classé",
   "group.appleBuiltIn": "Intégrées",
+  "drop.systemDefaultWarning": "Ne déposez pas dans les catégories système par défaut",
   "tag.design": "Design",
   "tag.development": "Développement",
   "tag.writing": "Écriture",
diff --git a/Apptag/Localization/it.json b/Apptag/Localization/it.json
index b4ca461..8c1b403 100644
--- a/Apptag/Localization/it.json
+++ b/Apptag/Localization/it.json
@@ -49,6 +49,7 @@
   "app.build": "Build",
   "group.uncategorized": "Non categorizzato",
   "group.appleBuiltIn": "Integrate",
+  "drop.systemDefaultWarning": "Non trascinare nelle categorie di sistema predefinite",
   "tag.design": "Design",
   "tag.development": "Sviluppo",
   "tag.writing": "Scrittura",
diff --git a/Apptag/Localization/ja.json b/Apptag/Localization/ja.json
index 21ec766..8074fcb 100644
--- a/Apptag/Localization/ja.json
+++ b/Apptag/Localization/ja.json
@@ -49,6 +49,7 @@
   "app.build": "ビルド",
   "group.uncategorized": "未分類",
   "group.appleBuiltIn": "Mac内蔵",
+  "drop.systemDefaultWarning": "システム既定カテゴリにはドラッグできません",
   "tag.design": "デザイン",
   "tag.development": "プログラミング",
   "tag.writing": "ライティング",
diff --git a/Apptag/Localization/ko.json b/Apptag/Localization/ko.json
index 6d144c7..230ca61 100644
--- a/Apptag/Localization/ko.json
+++ b/Apptag/Localization/ko.json
@@ -49,6 +49,7 @@
   "app.build": "빌드",
   "group.uncategorized": "미분류",
   "group.appleBuiltIn": "Mac 내장",
+  "drop.systemDefaultWarning": "시스템 기본 분류로 드래그하지 마세요",
   "tag.design": "디자인",
   "tag.development": "프로그래밍",
   "tag.writing": "글쓰기",
diff --git a/Apptag/Localization/ru.json b/Apptag/Localization/ru.json
index a6922f9..0843093 100644
--- a/Apptag/Localization/ru.json
+++ b/Apptag/Localization/ru.json
@@ -49,6 +49,7 @@
   "app.build": "Сборка",
   "group.uncategorized": "Без категории",
   "group.appleBuiltIn": "Встроенные",
+  "drop.systemDefaultWarning": "Не перетаскивайте в системные категории по умолчанию",
   "tag.design": "Дизайн",
   "tag.development": "Разработка",
   "tag.writing": "Текст",
diff --git a/Apptag/Localization/zh-Hans.json b/Apptag/Localization/zh-Hans.json
index 49091fe..49e3364 100644
--- a/Apptag/Localization/zh-Hans.json
+++ b/Apptag/Localization/zh-Hans.json
@@ -49,6 +49,7 @@
   "app.build": "构建",
   "group.uncategorized": "未分类",
   "group.appleBuiltIn": "Mac自带",
+  "drop.systemDefaultWarning": "请勿拖动至系统默认分类",
   "tag.design": "设计",
   "tag.development": "编程",
   "tag.writing": "写作",
diff --git a/Apptag/Localization/zh-Hant.json b/Apptag/Localization/zh-Hant.json
index d284885..55e5aa7 100644
--- a/Apptag/Localization/zh-Hant.json
+++ b/Apptag/Localization/zh-Hant.json
@@ -49,6 +49,7 @@
   "app.build": "建置",
   "group.uncategorized": "未分類",
   "group.appleBuiltIn": "Mac內建",
+  "drop.systemDefaultWarning": "請勿拖動至系統預設分類",
   "tag.design": "設計",
   "tag.development": "程式設計",
   "tag.writing": "寫作",
diff --git a/Apptag/TagEditorView.swift b/Apptag/TagEditorView.swift
index 7d15f82..866b7ef 100644
--- a/Apptag/TagEditorView.swift
+++ b/Apptag/TagEditorView.swift
@@ -33,7 +33,7 @@
                     newTagNameText = ""
                     newTagColorIndex = 0
                 } label: {
-                    Label("New Tag", systemImage: "plus")
+                    Label(tr("tag.newTag"), systemImage: "plus")
                         .font(.system(size: 12))
                 }
                 .buttonStyle(.bordered)
@@ -45,7 +45,7 @@
             ScrollView {
                 VStack(spacing: 0) {
                     if sortedTagNames.isEmpty && !addingNewTag {
-                        Text("No tags yet. Click \"New Tag\" to create one.")
+                        Text(tr("edit.noTags"))
                             .font(.caption)
                             .foregroundStyle(.secondary)
                             .padding(.vertical, 40)
@@ -81,13 +81,13 @@
             if editingTagName == tagName {
                 MacTextField(
                     text: $editingTagText,
-                    placeholder: "Tag name",
+                    placeholder: tr("tag.name"),
                     onSubmit: { commitTagRename(tagName) }
                 )
                 .frame(width: 160, height: 24)
-                Button("Save") { commitTagRename(tagName) }
+                Button(tr("tag.save")) { commitTagRename(tagName) }
                     .buttonStyle(.borderedProminent).controlSize(.small)
-                Button("Cancel") { editingTagName = nil }
+                Button(tr("tag.cancel")) { editingTagName = nil }
                     .buttonStyle(.plain).foregroundStyle(.secondary)
             } else {
                 Text(tagName).font(.system(size: 14)).frame(width: 160, alignment: .leading)
@@ -118,13 +118,13 @@
             }
             MacTextField(
                 text: $newTagNameText,
-                placeholder: "New tag name",
+                placeholder: tr("tag.newName"),
                 onSubmit: { addNewTag() }
             )
             .frame(width: 160, height: 24)
-            Button("Add") { addNewTag() }
+            Button(tr("tag.add")) { addNewTag() }
                 .buttonStyle(.borderedProminent).controlSize(.small)
-            Button("Cancel") { addingNewTag = false; newTagNameText = "" }
+            Button(tr("tag.cancel")) { addingNewTag = false; newTagNameText = "" }
                 .buttonStyle(.plain).foregroundStyle(.secondary)
             Spacer()
         }
@@ -145,8 +145,7 @@
         tagColors[newName] = tagColors[oldName]
         tagColors.removeValue(forKey: oldName)
         editingTagName = nil
-        // Don't call onRefresh — re-scan may re-discover old tag from
-        // SIP-protected apps where xattr write silently fails.
+        onRefresh?()
     }
 
     /// Add a new tag name+color and persist to the database.
diff --git a/Apptag/TagGroupView.swift b/Apptag/TagGroupView.swift
index c5618ea..c293236 100644
--- a/Apptag/TagGroupView.swift
+++ b/Apptag/TagGroupView.swift
@@ -1,4 +1,5 @@
 import SwiftUI
+import UniformTypeIdentifiers
 
 // MARK: - Tag Group Section (with centered separator-line header)
 
@@ -8,6 +9,9 @@
     let tagFontSize: CGFloat
     let iconSize: CGFloat
     var showNames: Bool = true
+    var dragModeActive: Bool = false
+    var onDragModeChange: ((Bool) -> Void)? = nil
+    var onDropApp: ((String, String, Bool) -> Void)? = nil
 
     /// Adaptive columns — auto-fit based on icon size and available width.
     private var columns: [GridItem] {
@@ -45,10 +49,50 @@
                         app: app,
                         iconSize: iconSize,
                         showName: showNames,
+                        sourceTag: group.name,
+                        dragModeActive: dragModeActive,
+                        onDragModeChange: onDragModeChange,
                         onSelect: { onSelectApp(app) }
                     )
                 }
             }
         }
+        .contentShape(Rectangle())
+        .overlay {
+            AppDropTargetView(targetTag: group.name) { path, source, copy in
+                onDropApp?(path, source, copy)
+            }
+            .allowsHitTesting(false)
+        }
+        .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in
+            handleDrop(providers)
+        }
+    }
+
+    private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
+        guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) }) else {
+            return false
+        }
+        provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
+            let text: String?
+            if let data = item as? Data {
+                text = String(data: data, encoding: .utf8)
+            } else if let string = item as? String {
+                text = string
+            } else if let string = item as? NSString {
+                text = string as String
+            } else {
+                text = nil
+            }
+            guard let text else { return }
+            let parts = text.components(separatedBy: "\n")
+            guard let path = parts.first, !path.isEmpty else { return }
+            let source = parts.dropFirst().first ?? ""
+            let copy = NSEvent.modifierFlags.contains(.option)
+            DispatchQueue.main.async {
+                onDropApp?(path, source, copy)
+            }
+        }
+        return true
     }
 }
diff --git a/build.sh b/build.sh
index 6f525da..3764c3c 100644
--- a/build.sh
+++ b/build.sh
@@ -10,7 +10,7 @@
 SWIFT_DIR="$PROJECT_DIR/Apptag"
 
 SDK_PATH=$(xcrun --sdk macosx --show-sdk-path)
-TARGET="arm64-apple-macosx26.0"
+TARGET="arm64-apple-macosx15.0"
 
 # --- Optional: App Store / Sandbox signing ---
 # Set CODESIGN_IDENTITY to your "Apple Distribution" or "Mac Developer" cert name.
diff --git a/generate_icon.py b/generate_icon.py
index 05654bb..163b665 100644
--- a/generate_icon.py
+++ b/generate_icon.py
@@ -1,36 +1,26 @@
 #!/usr/bin/env python3
 """Generate Apptag app icon — two overlapping tags (red + blue) on macOS squircle."""
-from PIL import Image, ImageDraw, ImageFilter, ImageChops
-import math, os, subprocess
+from PIL import Image, ImageDraw, ImageFilter
+import math, os, subprocess, tempfile
 
 SIZE = 1024
-OUT_DIR = "/Users/ar/Projects/Apptag/Apptag/Assets.xcassets/AppIcon.appiconset"
-os.makedirs(OUT_DIR, exist_ok=True)
 
 def rounded_rectangle_mask(size, radius):
-    """Create a mask for a rounded rectangle."""
     mask = Image.new("L", (size, size), 0)
     draw = ImageDraw.Draw(mask)
     draw.rounded_rectangle([(0, 0), (size-1, size-1)], radius=radius, fill=255)
     return mask
 
-def draw_tag(draw, cx, cy, width, height, color, angle_deg, hole_ratio=0.22):
-    """Draw a tag shape (rounded rect with hole) rotated by angle."""
-    # Create a temp image for this tag
+def draw_tag(cx, cy, width, height, color, angle_deg, hole_ratio=0.22):
     padding = int(width * 1.5)
     tag_img = Image.new("RGBA", (padding*2, padding*2), (0,0,0,0))
     tag_draw = ImageDraw.Draw(tag_img)
-    
     x0 = padding - width//2
     y0 = padding - height//2
     x1 = padding + width//2
     y1 = padding + height//2
-    r = int(min(width, height) * 0.18)  # corner radius
-    
-    # Main tag body
+    r = int(min(width, height) * 0.18)
     tag_draw.rounded_rectangle([x0, y0, x1, y1], radius=r, fill=color)
-    
-    # Hole at top
     hole_r = int(width * hole_ratio)
     hole_x = padding + width//2 - int(width * 0.22)
     hole_y = padding - height//2 + int(height * 0.2)
@@ -38,95 +28,81 @@
         [hole_x - hole_r, hole_y - hole_r, hole_x + hole_r, hole_y + hole_r],
         fill=(0,0,0,0)
     )
-    
-    # Rotate
     tag_img = tag_img.rotate(angle_deg, resample=Image.BICUBIC, expand=True)
-    
-    return tag_img, padding, hole_x, hole_y
+    return tag_img
 
 # --- Build the icon ---
 img = Image.new("RGBA", (SIZE, SIZE), (0,0,0,0))
-draw = ImageDraw.Draw(img)
 
-# macOS Big Sur style squircle background
-squircle_r = int(SIZE * 0.225)  # ~225px for 1024 canvas
+squircle_r = int(SIZE * 0.225)
 bg_mask = rounded_rectangle_mask(SIZE, squircle_r)
-bg_color = Image.new("RGBA", (SIZE, SIZE), (240, 241, 245, 255))  # Light gray bg
+bg_color = Image.new("RGBA", (SIZE, SIZE), (240, 241, 245, 255))
 img = Image.composite(bg_color, img, bg_mask)
 
-# Create two tags
-red_color = (220, 45, 85, 255)    # Rich red/pink
-blue_color = (45, 130, 220, 255)  # Vibrant blue
+red_color = (220, 45, 85, 255)
+blue_color = (45, 130, 220, 255)
 
-# Red tag - slightly tilted left, on top
 tag_w = int(SIZE * 0.42)
 tag_h = int(SIZE * 0.55)
-red_tag, red_pad, _, _ = draw_tag(None, 0, 0, tag_w, tag_h, red_color, -8)
-# Position center, slightly up
+red_tag = draw_tag(0, 0, tag_w, tag_h, red_color, -8)
 red_x = SIZE//2 - red_tag.width//2
 red_y = SIZE//2 - red_tag.height//2 - int(SIZE * 0.04)
 
-# Blue tag - tilted right, behind/below
 tag_w2 = int(SIZE * 0.46)
 tag_h2 = int(SIZE * 0.58)
-blue_tag, _, _, _ = draw_tag(None, 0, 0, tag_w2, tag_h2, blue_color, 12)
+blue_tag = draw_tag(0, 0, tag_w2, tag_h2, blue_color, 12)
 blue_x = SIZE//2 - blue_tag.width//2 + int(SIZE * 0.02)
 blue_y = SIZE//2 - blue_tag.height//2 + int(SIZE * 0.03)
 
-# Composite: blue behind, red in front
-# Add drop shadows first
+# Drop shadows
 shadow = blue_tag.copy()
 shadow_data = shadow.getdata()
 shadow.putdata([(0,0,0, min(a//4, 80)) if a > 0 else (0,0,0,0) for (r,g,b,a) in shadow_data])
 shadow = shadow.filter(ImageFilter.GaussianBlur(radius=SIZE*0.025))
 img.paste(shadow, (blue_x+int(SIZE*0.015), blue_y+int(SIZE*0.02)), shadow)
-
 img.paste(blue_tag, (blue_x, blue_y), blue_tag)
 
-# Red tag shadow
 shadow2 = red_tag.copy()
 shadow_data2 = shadow2.getdata()
 shadow2.putdata([(0,0,0, min(a//3, 80)) if a > 0 else (0,0,0,0) for (r,g,b,a) in shadow_data2])
 shadow2 = shadow2.filter(ImageFilter.GaussianBlur(radius=SIZE*0.02))
 img.paste(shadow2, (red_x+int(SIZE*0.01), red_y+int(SIZE*0.015)), shadow2)
-
 img.paste(red_tag, (red_x, red_y), red_tag)
 
-# Apply squircle clip mask
 mask = rounded_rectangle_mask(SIZE, squircle_r)
 img.putalpha(mask)
 
-# --- Save PNG variants for .icns ---
-variants = [
-    ("icon_16x16.png", 16),
-    ("icon_16x16@2x.png", 32),
-    ("icon_32x32.png", 32),
-    ("icon_32x32@2x.png", 64),
-    ("icon_128x128.png", 128),
-    ("icon_128x128@2x.png", 256),
-    ("icon_256x256.png", 256),
-    ("icon_256x256@2x.png", 512),
-    ("icon_512x512.png", 512),
-    ("icon_512x512@2x.png", 1024),
-]
+# --- Save PNG variants to temp .iconset, then generate .icns ---
+with tempfile.TemporaryDirectory() as tmpdir:
+    iconset = os.path.join(tmpdir, "AppIcon.iconset")
+    os.makedirs(iconset)
 
-for name, size in variants:
-    resized = img.resize((size, size), Image.LANCZOS)
-    resized.save(os.path.join(OUT_DIR, name), "PNG")
-    print(f"  {name} ({size}x{size})")
+    variants = [
+        ("icon_16x16.png", 16),
+        ("icon_16x16@2x.png", 32),
+        ("icon_32x32.png", 32),
+        ("icon_32x32@2x.png", 64),
+        ("icon_128x128.png", 128),
+        ("icon_128x128@2x.png", 256),
+        ("icon_256x256.png", 256),
+        ("icon_256x256@2x.png", 512),
+        ("icon_512x512.png", 512),
+        ("icon_512x512@2x.png", 1024),
+    ]
 
-# Also save a 1024 preview
-img.save(os.path.join(OUT_DIR, "icon_1024_preview.png"), "PNG")
-print(f"\nSaved {len(variants)} variants to {OUT_DIR}")
+    for name, size in variants:
+        resized = img.resize((size, size), Image.LANCZOS)
+        path = os.path.join(iconset, name)
+        resized.save(path, "PNG", optimize=True)
+        print(f"  {name} ({size}x{size}): {os.path.getsize(path):,}B")
 
-# --- Generate .icns using iconutil ---
-iconset_dir = OUT_DIR.rstrip('/')
-subprocess.run(["iconutil", "-c", "icns", iconset_dir, "-o", 
-    "/Users/ar/Projects/Apptag/build/Apptag.app/Contents/Resources/AppIcon.icns"],
-    check=True)
-print("Generated AppIcon.icns")
+    # Generate .icns to project root
+    out_icns = "/Users/ar/Projects/Apptag/icon-icns.icns"
+    subprocess.run(["iconutil", "-c", "icns", iconset, "-o", out_icns], check=True)
+    icns_size = os.path.getsize(out_icns)
+    print(f"\nGenerated: {out_icns} ({icns_size:,}B, {icns_size/1024:.0f}KB)")
 
-# Also copy to build dir
-subprocess.run(["cp", os.path.join(OUT_DIR, "icon_1024_preview.png"),
-    "/Users/ar/Projects/Apptag/icon_preview.png"], check=True)
-print("Preview: /Users/ar/Projects/Apptag/icon_preview.png")
+# Also save 1024x1024 preview
+preview_path = "/Users/ar/Projects/Apptag/icon_preview.png"
+img.save(preview_path, "PNG", optimize=True)
+print(f"Preview: {preview_path}")
diff --git a/icon-icns.icns b/icon-icns.icns
index e9b04ea..eb7fe8e 100644
--- a/icon-icns.icns
+++ b/icon-icns.icns
Binary files differ

--
Gitblit v1.9.3