Ariver
2026-05-11 667a89382078d7b38e6dc21c5627839fbde7f92c
Archive TagLauncher 5.1.12
30 files modified
12 files deleted
1 files added
911 ■■■■ changed files
.hermes.md 21 ●●●● patch | view | raw | blame | history
Apptag/AppDragCoordinator.swift 248 ●●●●● patch | view | raw | blame | history
Apptag/AppGridItem.swift 200 ●●●●● patch | view | raw | blame | history
Apptag/AppIcon.iconset/AppIcon.icns patch | view | raw | blame | history
Apptag/AppIcon.iconset/icon_1024_preview.png patch | view | raw | blame | history
Apptag/AppIcon.iconset/icon_128x128.png patch | view | raw | blame | history
Apptag/AppIcon.iconset/icon_128x128@2x.png patch | view | raw | blame | history
Apptag/AppIcon.iconset/icon_16x16.png patch | view | raw | blame | history
Apptag/AppIcon.iconset/icon_16x16@2x.png patch | view | raw | blame | history
Apptag/AppIcon.iconset/icon_256x256.png patch | view | raw | blame | history
Apptag/AppIcon.iconset/icon_256x256@2x.png patch | view | raw | blame | history
Apptag/AppIcon.iconset/icon_32x32.png patch | view | raw | blame | history
Apptag/AppIcon.iconset/icon_32x32@2x.png patch | view | raw | blame | history
Apptag/AppIcon.iconset/icon_512x512.png patch | view | raw | blame | history
Apptag/AppIcon.iconset/icon_512x512@2x.png patch | view | raw | blame | history
Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128.png patch | view | raw | blame | history
Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png patch | view | raw | blame | history
Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16.png patch | view | raw | blame | history
Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png patch | view | raw | blame | history
Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256.png patch | view | raw | blame | history
Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png patch | view | raw | blame | history
Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32.png patch | view | raw | blame | history
Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png patch | view | raw | blame | history
Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512.png patch | view | raw | blame | history
Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png patch | view | raw | blame | history
Apptag/ContentView.swift 141 ●●●●● patch | view | raw | blame | history
Apptag/DataLayer.swift 127 ●●●●● patch | view | raw | blame | history
Apptag/Info.plist 6 ●●●● patch | view | raw | blame | history
Apptag/L10n.swift 12 ●●●●● 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/TagEditorView.swift 19 ●●●● patch | view | raw | blame | history
Apptag/TagGroupView.swift 44 ●●●●● patch | view | raw | blame | history
build.sh 2 ●●● patch | view | raw | blame | history
generate_icon.py 82 ●●●●● patch | view | raw | blame | history
icon-icns.icns patch | view | raw | blame | history
.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 自动维护,请勿手动删除历史记录。
##
Apptag/AppDragCoordinator.swift
New file
@@ -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)
    }
}
Apptag/AppGridItem.swift
@@ -7,18 +7,29 @@
    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)
            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 ? 1.22 : 1.0)
            .scaleEffect(isHovered ? hoverScale : 1.0)
                    .shadow(
                        color: .black.opacity(isHovered ? 0.35 : 0),
                        radius: isHovered ? 14 : 0,
@@ -36,10 +47,183 @@
            .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
        }
        .buttonStyle(.plain)
        .onHover { hovering in
            isHovered = hovering
        }
    }
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
    }
}
Apptag/AppIcon.iconset/AppIcon.icns
Binary files differ
Apptag/AppIcon.iconset/icon_1024_preview.png
Binary files differ
Apptag/AppIcon.iconset/icon_128x128.png
Binary files differ
Apptag/AppIcon.iconset/icon_128x128@2x.png
Binary files differ
Apptag/AppIcon.iconset/icon_16x16.png
Binary files differ
Apptag/AppIcon.iconset/icon_16x16@2x.png
Binary files differ
Apptag/AppIcon.iconset/icon_256x256.png
Binary files differ
Apptag/AppIcon.iconset/icon_256x256@2x.png
Binary files differ
Apptag/AppIcon.iconset/icon_32x32.png
Binary files differ
Apptag/AppIcon.iconset/icon_32x32@2x.png
Binary files differ
Apptag/AppIcon.iconset/icon_512x512.png
Binary files differ
Apptag/AppIcon.iconset/icon_512x512@2x.png
Binary files differ
Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128.png

Apptag/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png

Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16.png

Apptag/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png

Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256.png

Apptag/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png

Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32.png

Apptag/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png

Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512.png

Apptag/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png

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(
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)
    }
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>
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",
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",
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",
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",
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",
Apptag/Localization/ja.json
@@ -49,6 +49,7 @@
  "app.build": "ビルド",
  "group.uncategorized": "未分類",
  "group.appleBuiltIn": "Mac内蔵",
  "drop.systemDefaultWarning": "システム既定カテゴリにはドラッグできません",
  "tag.design": "デザイン",
  "tag.development": "プログラミング",
  "tag.writing": "ライティング",
Apptag/Localization/ko.json
@@ -49,6 +49,7 @@
  "app.build": "빌드",
  "group.uncategorized": "미분류",
  "group.appleBuiltIn": "Mac 내장",
  "drop.systemDefaultWarning": "시스템 기본 분류로 드래그하지 마세요",
  "tag.design": "디자인",
  "tag.development": "프로그래밍",
  "tag.writing": "글쓰기",
Apptag/Localization/ru.json
@@ -49,6 +49,7 @@
  "app.build": "Сборка",
  "group.uncategorized": "Без категории",
  "group.appleBuiltIn": "Встроенные",
  "drop.systemDefaultWarning": "Не перетаскивайте в системные категории по умолчанию",
  "tag.design": "Дизайн",
  "tag.development": "Разработка",
  "tag.writing": "Текст",
Apptag/Localization/zh-Hans.json
@@ -49,6 +49,7 @@
  "app.build": "构建",
  "group.uncategorized": "未分类",
  "group.appleBuiltIn": "Mac自带",
  "drop.systemDefaultWarning": "请勿拖动至系统默认分类",
  "tag.design": "设计",
  "tag.development": "编程",
  "tag.writing": "写作",
Apptag/Localization/zh-Hant.json
@@ -49,6 +49,7 @@
  "app.build": "建置",
  "group.uncategorized": "未分類",
  "group.appleBuiltIn": "Mac內建",
  "drop.systemDefaultWarning": "請勿拖動至系統預設分類",
  "tag.design": "設計",
  "tag.development": "程式設計",
  "tag.writing": "寫作",
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.
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
    }
}
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.
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,65 +28,55 @@
        [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 ---
# --- Save PNG variants to temp .iconset, then generate .icns ---
with tempfile.TemporaryDirectory() as tmpdir:
    iconset = os.path.join(tmpdir, "AppIcon.iconset")
    os.makedirs(iconset)
variants = [
    ("icon_16x16.png", 16),
    ("icon_16x16@2x.png", 32),
@@ -112,21 +92,17 @@
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})")
        path = os.path.join(iconset, name)
        resized.save(path, "PNG", optimize=True)
        print(f"  {name} ({size}x{size}): {os.path.getsize(path):,}B")
# 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}")
    # 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)")
# --- 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")
# 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}")
icon-icns.icns
Binary files differ