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