From 5a8600bb59862db9becada37452483e1a1d2e397 Mon Sep 17 00:00:00 2001
From: Ariver <ar@MacBook-Air.local>
Date: Thu, 14 May 2026 23:37:36 +0800
Subject: [PATCH] Release TagLauncher 5.7.5
---
Apptag/ApptagApp.swift | 214 +++++++++++++++++++++++++++++++++++++++++------------
1 files changed, 165 insertions(+), 49 deletions(-)
diff --git a/Apptag/ApptagApp.swift b/Apptag/ApptagApp.swift
index ff4466d..db83bfe 100644
--- a/Apptag/ApptagApp.swift
+++ b/Apptag/ApptagApp.swift
@@ -12,7 +12,7 @@
Settings {
PreferencesView()
}
- .defaultSize(width: 880, height: 420)
+ .defaultSize(width: 880, height: 460)
}
}
@@ -24,7 +24,9 @@
}
final class AppDelegate: NSObject, NSApplicationDelegate {
- private var statusItem: NSStatusItem!
+ private static let showMenuBarIconKey = "showMenuBarIcon"
+
+ private var statusItem: NSStatusItem?
private var overlayWindow: NSWindow?
private var settingsWindow: NSWindow? // Track Settings window to keep it above overlay
private var hotkeyRef: EventHotKeyRef?
@@ -32,6 +34,7 @@
func applicationDidFinishLaunching(_ notification: Notification) {
L10n.setup()
+ setupDefaultPreferences()
migrateDefaultGroupName()
TagDatabase.seedDefaultTags()
let showDock = UserDefaults.standard.bool(forKey: "showDockIcon")
@@ -41,7 +44,7 @@
observeOtherWindows()
observeSettingsClose()
observeEditMode()
- observeDockSetting()
+ observeChromeSettings()
observeLanguageChanges()
setupLaunchAtLogin()
}
@@ -70,14 +73,21 @@
// User has set a custom name — keep it
}
- /// Observe Show in Dock changes so it takes effect immediately.
- private func observeDockSetting() {
+ private func setupDefaultPreferences() {
+ if UserDefaults.standard.object(forKey: Self.showMenuBarIconKey) == nil {
+ UserDefaults.standard.set(true, forKey: Self.showMenuBarIconKey)
+ }
+ }
+
+ /// Observe Dock/menu-bar visibility changes so they take effect immediately.
+ private func observeChromeSettings() {
NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil, queue: .main
- ) { _ in
+ ) { [weak self] _ in
let show = UserDefaults.standard.bool(forKey: "showDockIcon")
NSApp.setActivationPolicy(show ? .regular : .accessory)
+ self?.setupMenuBar()
}
}
@@ -94,6 +104,9 @@
// MARK: - Launch at Login (LaunchAgent, zero permissions)
private static let launchAgentLabel = "com.apptag.launcher"
+ static var supportsLaunchAtLogin: Bool {
+ ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] == nil
+ }
private static var launchAgentURL: URL {
FileManager.default.homeDirectoryForCurrentUser
@@ -101,6 +114,7 @@
}
static func enableLaunchAtLogin() {
+ guard supportsLaunchAtLogin else { return }
let plist: [String: Any] = [
"Label": Self.launchAgentLabel,
"ProgramArguments": ["open", Bundle.main.bundlePath, "--hide"],
@@ -118,6 +132,7 @@
}
static func disableLaunchAtLogin() {
+ guard supportsLaunchAtLogin else { return }
let uid = getuid()
let task = Process()
task.launchPath = "/bin/launchctl"
@@ -129,6 +144,12 @@
/// On first launch, enable login item by default via LaunchAgent.
/// Does NOT require App Management permission.
private func setupLaunchAtLogin() {
+ guard Self.supportsLaunchAtLogin else {
+ if UserDefaults.standard.object(forKey: "launchAtLogin") == nil {
+ UserDefaults.standard.set(false, forKey: "launchAtLogin")
+ }
+ return
+ }
let key = "launchAtLogin"
if UserDefaults.standard.object(forKey: key) == nil {
UserDefaults.standard.set(true, forKey: key)
@@ -139,17 +160,22 @@
// MARK: - Menu Bar
private func setupMenuBar() {
- // Remove old status item if re-creating (language switch, etc.)
- if let existing = statusItem {
- NSStatusBar.system.removeStatusItem(existing)
+ guard UserDefaults.standard.object(forKey: Self.showMenuBarIconKey) as? Bool ?? true else {
+ removeMenuBarItem()
+ return
}
- statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+
+ if statusItem == nil {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ }
+ guard let statusItem else { return }
if let button = statusItem.button {
- button.image = NSImage(
- systemSymbolName: "tag.fill",
- accessibilityDescription: "TagLauncher"
- )
+ let image = makeMenuBarIcon()
+ button.image = image
+ button.imageScaling = .scaleProportionallyDown
+ button.imagePosition = .imageOnly
+ button.title = image == nil ? "T" : ""
button.toolTip = "TagLauncher — Tag-based app launcher"
button.action = #selector(toggleOverlay)
button.target = self
@@ -208,6 +234,33 @@
)
)
statusItem.menu = menu
+ }
+
+ private func removeMenuBarItem() {
+ guard let statusItem else { return }
+ NSStatusBar.system.removeStatusItem(statusItem)
+ self.statusItem = nil
+ }
+
+ private func makeMenuBarIcon() -> NSImage? {
+ let size = NSSize(width: 18, height: 18)
+ let image = NSImage(size: size)
+ image.lockFocus()
+ NSColor.black.setFill()
+ let tagPath = NSBezierPath()
+ tagPath.move(to: NSPoint(x: 2.5, y: 10.5))
+ tagPath.line(to: NSPoint(x: 8.5, y: 16.5))
+ tagPath.line(to: NSPoint(x: 15.5, y: 16.5))
+ tagPath.line(to: NSPoint(x: 15.5, y: 9.5))
+ tagPath.line(to: NSPoint(x: 8.5, y: 2.5))
+ tagPath.close()
+ tagPath.appendOval(in: NSRect(x: 10.6, y: 12.2, width: 2.6, height: 2.6))
+ tagPath.windingRule = .evenOdd
+ tagPath.fill()
+ image.unlockFocus()
+ image.isTemplate = true
+ image.accessibilityDescription = "TagLauncher"
+ return image
}
// MARK: - Overlay Window
@@ -354,8 +407,8 @@
!self.isInEditMode
else { return }
- // Settings/Preferences window → float it above overlay for real-time preview
- if NSApp.windows.contains(keyWindow) {
+ // Settings/Preferences window -> float it above overlay for real-time preview.
+ if self.isSettingsWindowCandidate(keyWindow) {
self.prepareSettingsWindow(keyWindow)
return
}
@@ -366,10 +419,11 @@
/// Settings must always appear centered over the current overlay view and float above it.
private func prepareSettingsWindow(_ window: NSWindow) {
- let settingsSize = NSSize(width: 880, height: max(window.frame.height, 420))
- window.minSize = NSSize(width: 880, height: 420)
- window.maxSize = NSSize(width: 880, height: CGFloat.greatestFiniteMagnitude)
- if abs(window.frame.width - settingsSize.width) > 0.5 || window.frame.height < settingsSize.height {
+ window.identifier = NSUserInterfaceItemIdentifier("TagLauncherPreferencesWindow")
+ let settingsSize = NSSize(width: 880, height: 460)
+ window.minSize = settingsSize
+ window.maxSize = settingsSize
+ if abs(window.frame.width - settingsSize.width) > 0.5 || abs(window.frame.height - settingsSize.height) > 0.5 {
window.setFrame(
NSRect(origin: window.frame.origin, size: settingsSize),
display: false
@@ -379,15 +433,39 @@
if let overlayWindow, overlayWindow.isVisible {
center(window, over: overlayWindow.frame)
window.level = NSWindow.Level(rawValue: overlayWindow.level.rawValue + 1)
+ attachSettingsWindow(window, to: overlayWindow)
} else if let screen = screenUnderMouse() {
center(window, over: screen.visibleFrame)
window.level = .floating
+ detachSettingsWindow(window)
}
- window.collectionBehavior.formUnion([.moveToActiveSpace, .fullScreenAuxiliary])
+ window.collectionBehavior.formUnion([.canJoinAllSpaces, .fullScreenAuxiliary, .moveToActiveSpace])
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
settingsWindow = window
+ }
+
+ private func attachSettingsWindow(_ window: NSWindow, to overlayWindow: NSWindow) {
+ if window.parent != overlayWindow {
+ window.parent?.removeChildWindow(window)
+ overlayWindow.addChildWindow(window, ordered: .above)
+ }
+ }
+
+ private func detachSettingsWindow(_ window: NSWindow) {
+ window.parent?.removeChildWindow(window)
+ }
+
+ private func isSettingsWindowCandidate(_ window: NSWindow) -> Bool {
+ if window == settingsWindow { return true }
+ if window.identifier?.rawValue == "TagLauncherPreferencesWindow" { return true }
+ guard NSApp.windows.contains(window),
+ window != overlayWindow,
+ window.isVisible,
+ !(window is NSPanel)
+ else { return false }
+ return true
}
private func center(_ window: NSWindow, over rect: NSRect) {
@@ -417,6 +495,7 @@
let closingWindow = notification.object as? NSWindow,
closingWindow == self.settingsWindow
else { return }
+ self.detachSettingsWindow(closingWindow)
self.settingsWindow = nil
}
}
@@ -434,15 +513,19 @@
@objc private func openPreferences() {
// Don't hide overlay — keep it visible for real-time setting preview.
+ if let overlayWindow, overlayWindow.isVisible {
+ overlayWindow.makeKeyAndOrderFront(nil)
+ overlayWindow.orderFrontRegardless()
+ }
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
- prepareSettingsWindowWhenAvailable(retries: 5)
+ prepareSettingsWindowWhenAvailable(retries: 10)
}
private func prepareSettingsWindowWhenAvailable(retries: Int) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in
guard let self else { return }
let candidates = NSApp.windows.filter { window in
- window != self.overlayWindow && window.isVisible
+ self.isSettingsWindowCandidate(window)
}
candidates.forEach { self.prepareSettingsWindow($0) }
if candidates.isEmpty && retries > 0 {
@@ -545,10 +628,10 @@
@AppStorage("defaultGroupName") private var defaultGroupName = "Other"
@AppStorage("displayMode") private var displayMode = "flat"
@AppStorage("hideAppNames") private var hideAppNames = false
+ @AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
@AppStorage("showDockIcon") private var showDockIcon = false
@AppStorage("launchAtLogin") private var launchAtLogin = true
@State private var selectedLanguage = L10n.currentCode
- @State private var languageRefreshID = UUID()
@State private var isRefreshingLanguage = false
@State private var allApps: [AppInfo] = []
@State private var tagColors: [String: Int] = [:]
@@ -577,6 +660,7 @@
try TagDatabase.exportTo(url)
} catch {
fputs("[TagLauncher] Export failed: \(error)\n", stderr)
+ showDataAlert(title: tr("settings.exportFailed"), message: error.localizedDescription)
}
}
}
@@ -593,14 +677,17 @@
scanApps()
} catch {
fputs("[TagLauncher] Import failed: \(error)\n", stderr)
- // Show alert on failure
- let alert = NSAlert()
- alert.messageText = tr("settings.importFailed")
- alert.informativeText = error.localizedDescription
- alert.alertStyle = .warning
- alert.runModal()
+ showDataAlert(title: tr("settings.importFailed"), message: error.localizedDescription)
}
}
+ }
+
+ private func showDataAlert(title: String, message: String) {
+ let alert = NSAlert()
+ alert.messageText = title
+ alert.informativeText = message
+ alert.alertStyle = .warning
+ alert.runModal()
}
private var appVersion: String {
@@ -609,25 +696,53 @@
private var buildVersion: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
}
+ private var languageColumns: [GridItem] {
+ [
+ GridItem(.flexible(minimum: 220), spacing: 18, alignment: .top),
+ GridItem(.flexible(minimum: 220), spacing: 18, alignment: .top),
+ ]
+ }
var body: some View {
ZStack {
TabView {
// Tab 1: Language
- Form {
- Section {
- Picker(tr("settings.languagePicker"), selection: $selectedLanguage) {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(tr("settings.language"))
+ .font(.headline)
+ Text(tr("settings.languageDesc"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ ScrollView {
+ LazyVGrid(columns: languageColumns, alignment: .leading, spacing: 6) {
ForEach(L10n.supported, id: \.code) { language in
- Text(language.name).tag(language.code)
+ Button {
+ selectedLanguage = language.code
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: selectedLanguage == language.code ? "checkmark" : "circle")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(selectedLanguage == language.code ? Color.accentColor : Color.secondary.opacity(0.28))
+ .frame(width: 16)
+ Text(language.name)
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 5)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("\(tr("settings.languagePicker")) \(language.name)")
}
}
- .pickerStyle(.radioGroup)
- } header: {
- Text(tr("settings.language"))
- } footer: {
- Text(tr("settings.languageDesc"))
+ .padding(.vertical, 4)
}
}
+ .frame(maxWidth: settingsContentWidth, alignment: .leading)
.tabItem { Label(tr("settings.language"), systemImage: "globe") }
.padding()
@@ -635,14 +750,17 @@
VStack(alignment: .leading, spacing: 0) {
// Toggle row — centered as a rectangular block
HStack(spacing: 20) {
- Toggle(tr("settings.launchAtLogin"), isOn: $launchAtLogin)
- .onChange(of: launchAtLogin) { _, enabled in
- if enabled {
- AppDelegate.enableLaunchAtLogin()
- } else {
- AppDelegate.disableLaunchAtLogin()
+ if AppDelegate.supportsLaunchAtLogin {
+ Toggle(tr("settings.launchAtLogin"), isOn: $launchAtLogin)
+ .onChange(of: launchAtLogin) { _, enabled in
+ if enabled {
+ AppDelegate.enableLaunchAtLogin()
+ } else {
+ AppDelegate.disableLaunchAtLogin()
+ }
}
- }
+ }
+ Toggle(tr("settings.showMenuBarIcon"), isOn: $showMenuBarIcon)
Toggle(tr("settings.showInDock"), isOn: $showDockIcon)
Toggle(tr("settings.hideAppNames"), isOn: $hideAppNames)
}
@@ -795,7 +913,6 @@
.tabItem { Label(tr("settings.about"), systemImage: "info.circle") }
.padding()
}
- .id(languageRefreshID)
.onChange(of: selectedLanguage) { _, code in
L10n.switchTo(code)
}
@@ -818,12 +935,11 @@
.transition(.opacity)
}
}
- .frame(minWidth: settingsWindowWidth, maxWidth: settingsWindowWidth, minHeight: 420)
+ .frame(width: settingsWindowWidth, height: 460)
}
private func showLanguageRefresh() {
isRefreshingLanguage = true
- languageRefreshID = UUID()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
withAnimation(.easeOut(duration: 0.18)) {
isRefreshingLanguage = false
--
Gitblit v1.9.3