Polish Pro status UI and app grid layout
| | |
| | | - `AppDelegate`:菜单栏状态项、Dock 显示策略、热键注册、窗口打开、Quick Search 触发、App 生命周期。 |
| | | - 支持从标签导航双击进入设置页的 Tags tab。 |
| | | - 启动时初始化 `ProEntitlementCenter`,保证免费 / Pro / 老用户自动 Pro 状态进入全局同步快照。 |
| | | - 菜单栏下拉菜单包含免费 / Pro 当前身份状态项;免费状态点击进入 About 页的 Pro 卡片,Pro 状态使用金色皇冠图标。 |
| | | - `src/Apptag/OverlayWindowController.swift` |
| | | - `OverlayPanel` / `OverlayWindowController`:AppGrid 浮层窗口与显示/隐藏控制。 |
| | | - `src/Apptag/ContentView.swift` |
| | |
| | | - 编辑模式使用 runtime theme override:任何主题下进入编辑都临时显示默认浅色毛玻璃。 |
| | | - 使用最近一次完整 `AppLibrarySnapshot` 先渲染 AppGrid,再后台刷新,避免启动/重开时立刻显示转圈。 |
| | | - 使用技巧关闭提醒由 `ContentView` 统一弹 modal 并写入 `hideUsageTips` / `skipUsageTipsCloseReminder`。 |
| | | - Quick Search 点击 TagLauncher 自身结果时只关闭当前浮层,不走外部自启动,避免 AppGrid 被重新拉起。 |
| | | - `src/Apptag/PreferencesView.swift` |
| | | - `PreferencesView`:设置窗口、语言、通用、主题、快捷键、标签、数据、关于等设置页。 |
| | | - Theme tab 只写视觉偏好 `appGridThemeID`,不得改变标签、排序、备注或分类数据。 |
| | | - Pro 升级 / 权益卡位于 About tab;Data tab 只承载分类方案、导入导出和气泡显示位置等数据管理入口。 |
| | | |
| | | ## AppGrid 显示与交互 |
| | | |
| | |
| | | - `AppGridIconNSView`:应用图标单元、点击、hover 气泡、拖拽起点。 |
| | | - 底部使用技巧是原生 AppKit overlay,不允许改成 SwiftUI;必须拦截自身点击区域。 |
| | | - 不拥有 AppGrid 全屏渐变背景,只接收 theme 推导出的玻璃和文本可读性 token。 |
| | | - 样式 2 / 样式 3 的瀑布流容器布局会把 Mac 自带容器固定到底部并横向占满。 |
| | | - `src/Apptag/AppGridTheme.swift` |
| | | - AppGrid 主题事实源:theme id、localization key、设置页 swatch、全屏渐变背景、玻璃明暗、编辑模式 token。 |
| | | - 持久化 key:`appGridThemeID`。 |
| | |
| | | - `ProEntitlementPolicy`:数据层可同步调用的 Pro 门禁;覆盖主题、导入 / 导出、备注额度和应用排序持久化。 |
| | | - `ProEntitlementConfig`:一次性买断商品 ID、免费 + Pro 首个版本、免费主题集合、免费备注额度和 QA 环境变量的单一配置点。 |
| | | - `src/Apptag/ProAccessViews.swift` |
| | | - `ProStatusPill`:高级功能入口的小型 Pro 胶囊。 |
| | | - `ProStatusPill`:高级功能入口的小型 Pro 胶囊;Pro / 已锁定高级功能使用金色皇冠体系,免费状态使用中性胶囊。 |
| | | - `ProUpgradePromptView`:功能被锁时的统一解锁提示,购买 / 恢复动作由调用方闭包绑定。 |
| | | - `src/Apptag/AppGridSupport.swift` |
| | | - 图标气泡、基础 metrics、气泡 placement 支撑。 |
| | |
| | | - `LauncherHotkeyRegistrationStore`:快捷键注册状态持久化。 |
| | | - `QuickSearchDocument` / `QuickSearchEngine`:搜索索引与排序。 |
| | | - `QuickSearchPanelPresentationView` / `QuickSearchOverlayView` / `QuickSearchResultListHostView`:Quick Search 浮层 UI 与结果列表。 |
| | | - `QuickSearchResultRowView`:结果行内部文字、图标、标签区域统一命中到整行,保证点击任意可见区域都能启动并关闭浮层。 |
| | | |
| | | ## 智能分类与 Apple 默认应用资料 |
| | | |
| | |
| | | - 不得把主题、视觉偏好、窗口状态混入 `TagDatabase` 的分类/标签业务数据。 |
| | | - 主题变化只允许影响 AppGrid 视觉,不得改变标签数据、排序、备注、SmartStart/category scheme、Quick Search、拖拽语义或导入导出。 |
| | | - Pro 门禁不得写脏免费用户真实数据:主题预览不写 `appGridThemeID`,排序预览不写 `containerAppOrder`,第 6 个新备注不写 `appNotes` / `appNoteMetadata`,导入 / 导出拦截必须早于文件面板和文件读写。 |
| | | - Pro 升级 / 权益卡应放在 About tab;Data tab 不承载 Pro 营销卡,避免挤压数据管理布局。 |
| | | - 免费主题固定为默认、深蓝、黑色;其它主题只能预览或在 Pro 解锁后持久化。 |
| | | - `originalAppVersion` 早于免费 + Pro 首个版本的历史用户必须自动获得 Pro;`originalPurchaseDate` 只能作为版本不可判断时的兜底。 |
| | | - 默认主题必须保留原浅色毛玻璃 AppGrid。 |
| | |
| | | - 旧用户 `useDarkAppGrid=true` 必须迁移到 `deepBlue` 主题。 |
| | | - AppGrid 启动或重开不应因为新 `ContentView` 初始 `allApps.isEmpty` 立刻显示转圈;应先复用最近一次完整快照,并延迟显示 loading。 |
| | | - Quick Search 显示标题应优先使用解析后的 `AppInfo.displayName`,内部 bundle 名只作为搜索字段。 |
| | | - Quick Search 结果行内部文字 / 图标区域不得吞掉点击;点中行内任意可见区域都应按结果启动路径处理。 |
| | | - Quick Search-only 点击 TagLauncher 自身结果必须关闭浮层,不得重新打开 AppGrid。 |
| | | - Apple 默认应用备注必须按当前语言显示,不允许 A 语言出现 B 语言备注。 |
| | | - SmartStart/default system tag 初始化标签必须按当前语言显示;升级用户启动后应重刷带 `systemCategoryID` 的系统标签,不能长期保留旧英文初始化标签。 |
| | | - 标签导航单击必须立即滚动;hover 必须保持受控自动滚动;长按排序必须保留;双击可以打开标签设置页但不得替代前三者。 |
| | |
| | | let columnWidth = floor((availableWidth - gap * CGFloat(columnCount - 1)) / CGFloat(columnCount)) |
| | | var columnHeights = Array(repeating: outerPadding, count: columnCount) |
| | | var items: [LayoutItem] = [] |
| | | let bottomPinnedGroups = groups.enumerated().filter { $0.element.containerID == AppContainerID.appleBuiltIn } |
| | | let bottomPinnedIndexes = Set(bottomPinnedGroups.map { $0.offset }) |
| | | |
| | | for (index, group) in groups.enumerated() { |
| | | if bottomPinnedIndexes.contains(index) { continue } |
| | | |
| | | let columnIndex = columnHeights.indices.min { columnHeights[$0] < columnHeights[$1] } ?? 0 |
| | | let x = outerPadding + CGFloat(columnIndex) * (columnWidth + gap) |
| | | let y = columnHeights[columnIndex] |
| | |
| | | columnHeights[columnIndex] = y + height + gap |
| | | } |
| | | |
| | | let tallest = columnHeights.max() ?? outerPadding |
| | | let contentHeight = (groups.isEmpty ? outerPadding * 2 : tallest - gap + outerPadding) |
| | | var contentBottom = columnHeights.max() ?? outerPadding |
| | | for (index, group) in bottomPinnedGroups { |
| | | let y = max(outerPadding, contentBottom) |
| | | let height = AppGridCollectionMetrics.cardHeight( |
| | | appCount: group.apps.count, |
| | | width: availableWidth, |
| | | iconSize: iconSize |
| | | ) |
| | | items.append( |
| | | LayoutItem( |
| | | index: index, |
| | | frame: NSRect(x: outerPadding, y: y, width: availableWidth, height: height) |
| | | ) |
| | | ) |
| | | contentBottom = y + height + gap |
| | | } |
| | | |
| | | let contentHeight = (items.isEmpty ? outerPadding * 2 : contentBottom - gap + outerPadding) |
| | | + max(0, bottomContentPadding) |
| | | return LayoutPlan( |
| | | items: items, |
| | |
| | | private static let statusItemButtonIdentifier = NSUserInterfaceItemIdentifier("TagLauncherStatusItemButton") |
| | | private static let statusItemAccessibilityLabel = AppIdentity.displayName |
| | | private static let showAppListMenuItemIdentifier = NSUserInterfaceItemIdentifier("TagLauncherShowAppListMenuItem") |
| | | private static let proStatusMenuItemIdentifier = NSUserInterfaceItemIdentifier("TagLauncherProStatusMenuItem") |
| | | private static let helpMenuItemIdentifier = NSUserInterfaceItemIdentifier("TagLauncherHelpMenu") |
| | | private static let downloadHelpMenuItemIdentifier = NSUserInterfaceItemIdentifier("TagLauncherDownloadHelpMenuItem") |
| | | private static let externalActivationNotification = Notification.Name("TagLauncherExternalActivationRequested") |
| | |
| | | versionItem.isEnabled = false |
| | | menu.addItem(versionItem) |
| | | |
| | | let proStatusItem = makeProStatusMenuItem() |
| | | menu.addItem(proStatusItem) |
| | | |
| | | menu.addItem(.separator()) |
| | | let prefsItem = NSMenuItem( |
| | | title: tr("menu.preferences"), |
| | |
| | | } |
| | | |
| | | func menuWillOpen(_ menu: NSMenu) { |
| | | configureProStatusMenuItem(in: menu) |
| | | statusMenuScreenForNextOverlay = overlayController.screenContainingCurrentPointer() |
| | | ?? statusItem?.button?.window?.screen |
| | | ?? NSScreen.main |
| | |
| | | |
| | | image.isTemplate = true |
| | | image.accessibilityDescription = "TagLauncher" |
| | | return image |
| | | } |
| | | |
| | | private func makeProStatusMenuItem() -> NSMenuItem { |
| | | let item = NSMenuItem( |
| | | title: "", |
| | | action: #selector(openProStatusFromStatusMenu(_:)), |
| | | keyEquivalent: "" |
| | | ) |
| | | item.identifier = Self.proStatusMenuItemIdentifier |
| | | item.target = self |
| | | configureProStatusMenuItem(item) |
| | | return item |
| | | } |
| | | |
| | | private func configureProStatusMenuItem(in menu: NSMenu) { |
| | | guard let item = menu.items.first(where: { $0.identifier == Self.proStatusMenuItemIdentifier }) else { |
| | | return |
| | | } |
| | | configureProStatusMenuItem(item) |
| | | } |
| | | |
| | | private func configureProStatusMenuItem(_ item: NSMenuItem) { |
| | | let isUnlocked = ProEntitlementPolicy.accessState().isUnlocked |
| | | item.title = tr(isUnlocked ? "menu.proStatus.unlocked" : "menu.proStatus.free") |
| | | item.action = #selector(openProStatusFromStatusMenu(_:)) |
| | | item.target = self |
| | | item.isEnabled = true |
| | | item.image = isUnlocked ? makeProStatusMenuIcon() : nil |
| | | } |
| | | |
| | | private func makeProStatusMenuIcon() -> NSImage? { |
| | | guard let symbol = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Pro") else { |
| | | return nil |
| | | } |
| | | let configuration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold) |
| | | let configuredSymbol = symbol.withSymbolConfiguration(configuration) ?? symbol |
| | | let image = NSImage(size: NSSize(width: 16, height: 16)) |
| | | image.lockFocus() |
| | | NSColor(red: 0.96, green: 0.63, blue: 0.10, alpha: 1.0).set() |
| | | let iconRect = NSRect(x: 1.5, y: 1.5, width: 13, height: 13) |
| | | configuredSymbol.draw(in: iconRect, from: .zero, operation: .sourceOver, fraction: 1.0) |
| | | NSColor(red: 0.96, green: 0.63, blue: 0.10, alpha: 1.0).set() |
| | | iconRect.fill(using: .sourceAtop) |
| | | image.unlockFocus() |
| | | image.isTemplate = false |
| | | image.accessibilityDescription = "Pro" |
| | | return image |
| | | } |
| | | |
| | |
| | | openPreferences(targetTab: nil) |
| | | } |
| | | |
| | | @objc private func openProStatusFromStatusMenu(_ sender: NSMenuItem) { |
| | | openPreferences(targetTab: SettingsTabTarget.about) |
| | | } |
| | | |
| | | private func openPreferences(targetTab: String?) { |
| | | dismissQuickSearchIfNeeded() |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | |
| | | static let theme = "theme" |
| | | static let tags = "tags" |
| | | static let data = "data" |
| | | static let about = "about" |
| | | } |
| | | |
| | | // MARK: - Edit Phase |
| | |
| | | closeQuickSearch(hideOverlayIfNeeded: true) |
| | | hideOverlay() |
| | | } |
| | | if isCurrentApp(app) { |
| | | if closeQuickSearchOnSuccess && !closeQuickSearchAndOverlayBeforeOpening { |
| | | closeQuickSearch(hideOverlayIfNeeded: closeOverlayOnSuccess) |
| | | } |
| | | if closeOverlayOnSuccess && !closeQuickSearchAndOverlayBeforeOpening { |
| | | hideOverlay() |
| | | } |
| | | return |
| | | } |
| | | let configuration = NSWorkspace.OpenConfiguration() |
| | | NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in |
| | | DispatchQueue.main.async { |
| | |
| | | } |
| | | } |
| | | |
| | | private func isCurrentApp(_ app: AppInfo) -> Bool { |
| | | if app.bundleIdentifier?.caseInsensitiveCompare(AppIdentity.bundleIdentifier) == .orderedSame { |
| | | return true |
| | | } |
| | | |
| | | let currentURL = Bundle.main.bundleURL.standardizedFileURL.resolvingSymlinksInPath() |
| | | let appURL = app.path.standardizedFileURL.resolvingSymlinksInPath() |
| | | return currentURL == appURL |
| | | } |
| | | |
| | | func openApp(_ app: AppInfo) { |
| | | hideOverlay() |
| | | launchApp(app, closeOverlayOnSuccess: false) |
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>8.1.0</string> |
| | | <string>8.1.1</string> |
| | | <key>CFBundleVersion</key> |
| | | <string>20260626.1726</string> |
| | | <string>20260626.2229</string> |
| | | <key>LSApplicationCategoryType</key> |
| | | <string>public.app-category.utilities</string> |
| | | <key>LSMinimumSystemVersion</key> |
| | |
| | | "menu.language": "اللغة", |
| | | "menu.help": "المساعدة", |
| | | "menu.version": "الإصدار", |
| | | "menu.proStatus.free": "أنت حالياً مستخدم مجاني (الترقية إلى Pro)", |
| | | "menu.proStatus.unlocked": "أنت حالياً مستخدم Pro مميز", |
| | | "settings.language": "اللغة", |
| | | "settings.languagePicker": "لغة التطبيق:", |
| | | "settings.languageDesc": "اختر لغة القوائم والإعدادات.", |
| | |
| | | "menu.language": "اللغة", |
| | | "menu.help": "المساعدة", |
| | | "menu.version": "الإصدار", |
| | | "menu.proStatus.free": "أنت حالياً مستخدم مجاني (الترقية إلى Pro)", |
| | | "menu.proStatus.unlocked": "أنت حالياً مستخدم Pro مميز", |
| | | "settings.language": "اللغة", |
| | | "settings.languagePicker": "لغة التطبيق:", |
| | | "settings.languageDesc": "اختر اللغة المستخدمة في القوائم والإعدادات.", |
| | |
| | | "menu.language": "Jazyk", |
| | | "menu.help": "Nápověda", |
| | | "menu.version": "Verze", |
| | | "menu.proStatus.free": "Nyní jste uživatel bezplatné verze (upgradovat na Pro)", |
| | | "menu.proStatus.unlocked": "Nyní jste vážený uživatel verze Pro", |
| | | "settings.language": "Jazyk", |
| | | "settings.languagePicker": "Jazyk aplikace:", |
| | | "settings.languageDesc": "Vyberte jazyk používaný v nabídkách a nastaveních.", |
| | |
| | | "menu.language": "Sprog", |
| | | "menu.help": "Hjælp", |
| | | "menu.version": "Version", |
| | | "menu.proStatus.free": "Du er i øjeblikket gratisbruger (opgrader til Pro)", |
| | | "menu.proStatus.unlocked": "Du er i øjeblikket en værdsat Pro-bruger", |
| | | "settings.language": "Sprog", |
| | | "settings.languagePicker": "Appsprog:", |
| | | "settings.languageDesc": "Vælg sproget til menuer og indstillinger.", |
| | |
| | | "menu.language": "Sprache", |
| | | "menu.help": "Hilfe", |
| | | "menu.version": "Version", |
| | | "menu.proStatus.free": "Sie sind derzeit Nutzer der Gratisversion (Upgrade auf Pro)", |
| | | "menu.proStatus.unlocked": "Sie sind derzeit ein geschätzter Pro-Nutzer", |
| | | "settings.language": "Sprache", |
| | | "settings.languagePicker": "App-Sprache:", |
| | | "settings.languageDesc": "Wähle die Sprache für Menüs und Einstellungen.", |
| | |
| | | "menu.language": "Language", |
| | | "menu.help": "Help", |
| | | "menu.version": "Version", |
| | | "menu.proStatus.free": "You are currently a Free user (upgrade to Pro)", |
| | | "menu.proStatus.unlocked": "You are currently a valued Pro user", |
| | | "settings.language": "Language", |
| | | "settings.languagePicker": "App language:", |
| | | "settings.languageDesc": "Choose the language used by menus and settings.", |
| | |
| | | "menu.language": "Idioma", |
| | | "menu.help": "Ayuda", |
| | | "menu.version": "Versión", |
| | | "menu.proStatus.free": "Actualmente eres usuario gratuito (actualizar a Pro)", |
| | | "menu.proStatus.unlocked": "Actualmente eres un usuario Pro distinguido", |
| | | "settings.language": "Idioma", |
| | | "settings.languagePicker": "Idioma de la app:", |
| | | "settings.languageDesc": "Elige el idioma usado en los menús y ajustes.", |
| | |
| | | "menu.language": "Langue", |
| | | "menu.help": "Aide", |
| | | "menu.version": "Version", |
| | | "menu.proStatus.free": "Vous utilisez actuellement la version gratuite (passer à Pro)", |
| | | "menu.proStatus.unlocked": "Vous êtes actuellement un utilisateur Pro privilégié", |
| | | "settings.language": "Langue", |
| | | "settings.languagePicker": "Langue de l'app :", |
| | | "settings.languageDesc": "Choisissez la langue utilisée par les menus et les réglages.", |
| | |
| | | "menu.language": "Bahasa", |
| | | "menu.help": "Bantuan", |
| | | "menu.version": "Versi", |
| | | "menu.proStatus.free": "Saat ini Anda pengguna Gratis (tingkatkan ke Pro)", |
| | | "menu.proStatus.unlocked": "Saat ini Anda pengguna Pro istimewa", |
| | | "settings.language": "Bahasa", |
| | | "settings.languagePicker": "Bahasa aplikasi:", |
| | | "settings.languageDesc": "Pilih bahasa yang digunakan untuk menu dan pengaturan.", |
| | |
| | | "menu.language": "Lingua", |
| | | "menu.help": "Aiuto", |
| | | "menu.version": "Versione", |
| | | "menu.proStatus.free": "Attualmente sei un utente gratuito (passa a Pro)", |
| | | "menu.proStatus.unlocked": "Attualmente sei un utente Pro privilegiato", |
| | | "settings.language": "Lingua", |
| | | "settings.languagePicker": "Lingua app:", |
| | | "settings.languageDesc": "Scegli la lingua usata da menu e impostazioni.", |
| | |
| | | "menu.language": "言語", |
| | | "menu.help": "ヘルプ", |
| | | "menu.version": "バージョン", |
| | | "menu.proStatus.free": "現在は無料版ユーザーです(Pro にアップグレード)", |
| | | "menu.proStatus.unlocked": "現在は特別な Pro ユーザーです", |
| | | "settings.language": "言語", |
| | | "settings.languagePicker": "アプリの言語:", |
| | | "settings.languageDesc": "メニューと設定で使う言語を選択します。", |
| | |
| | | "menu.language": "언어", |
| | | "menu.help": "도움말", |
| | | "menu.version": "버전", |
| | | "menu.proStatus.free": "현재 무료 사용자입니다(Pro로 업그레이드)", |
| | | "menu.proStatus.unlocked": "현재 특별 Pro 사용자입니다", |
| | | "settings.language": "언어", |
| | | "settings.languagePicker": "앱 언어:", |
| | | "settings.languageDesc": "메뉴와 설정에서 사용할 언어를 선택합니다.", |
| | |
| | | "menu.language": "Bahasa", |
| | | "menu.help": "Bantuan", |
| | | "menu.version": "Versi", |
| | | "menu.proStatus.free": "Anda kini pengguna Percuma (naik taraf ke Pro)", |
| | | "menu.proStatus.unlocked": "Anda kini pengguna Pro istimewa", |
| | | "settings.language": "Bahasa", |
| | | "settings.languagePicker": "Bahasa aplikasi:", |
| | | "settings.languageDesc": "Pilih bahasa yang digunakan untuk menu dan tetapan.", |
| | |
| | | "menu.language": "Språk", |
| | | "menu.help": "Hjelp", |
| | | "menu.version": "Versjon", |
| | | "menu.proStatus.free": "Du er nå gratisbruker (oppgrader til Pro)", |
| | | "menu.proStatus.unlocked": "Du er nå en verdsatt Pro-bruker", |
| | | "settings.language": "Språk", |
| | | "settings.languagePicker": "Appspråk:", |
| | | "settings.languageDesc": "Velg språket som brukes i menyer og innstillinger.", |
| | |
| | | "menu.language": "Taal", |
| | | "menu.help": "Help", |
| | | "menu.version": "Versie", |
| | | "menu.proStatus.free": "U bent momenteel een gratis gebruiker (upgrade naar Pro)", |
| | | "menu.proStatus.unlocked": "U bent momenteel een gewaardeerde Pro-gebruiker", |
| | | "settings.language": "Taal", |
| | | "settings.languagePicker": "App-taal:", |
| | | "settings.languageDesc": "Kies de taal voor menu’s en instellingen.", |
| | |
| | | "menu.language": "Språk", |
| | | "menu.help": "Hjelp", |
| | | "menu.version": "Versjon", |
| | | "menu.proStatus.free": "Du er no gratisbrukar (oppgrader til Pro)", |
| | | "menu.proStatus.unlocked": "Du er no ein verdsett Pro-brukar", |
| | | "settings.language": "Språk", |
| | | "settings.languagePicker": "Appspråk:", |
| | | "settings.languageDesc": "Velg språket som brukes i menyer og innstillinger.", |
| | |
| | | "menu.language": "Språk", |
| | | "menu.help": "Hjelp", |
| | | "menu.version": "Versjon", |
| | | "menu.proStatus.free": "Du er nå gratisbruker (oppgrader til Pro)", |
| | | "menu.proStatus.unlocked": "Du er nå en verdsatt Pro-bruker", |
| | | "settings.language": "Språk", |
| | | "settings.languagePicker": "Appspråk:", |
| | | "settings.languageDesc": "Velg språket som brukes i menyer og innstillinger.", |
| | |
| | | "menu.language": "Język", |
| | | "menu.help": "Pomoc", |
| | | "menu.version": "Wersja", |
| | | "menu.proStatus.free": "Obecnie korzystasz z wersji bezpłatnej (uaktualnij do Pro)", |
| | | "menu.proStatus.unlocked": "Obecnie jesteś cenionym użytkownikiem Pro", |
| | | "settings.language": "Język", |
| | | "settings.languagePicker": "Język aplikacji:", |
| | | "settings.languageDesc": "Wybierz język menu i ustawień.", |
| | |
| | | "menu.language": "Idioma", |
| | | "menu.help": "Ajuda", |
| | | "menu.version": "Versão", |
| | | "menu.proStatus.free": "Você está na versão gratuita (atualizar para Pro)", |
| | | "menu.proStatus.unlocked": "Você é um usuário Pro especial", |
| | | "settings.language": "Idioma", |
| | | "settings.languagePicker": "Idioma do app:", |
| | | "settings.languageDesc": "Escolha o idioma usado nos menus e ajustes.", |
| | |
| | | "menu.language": "Limbă", |
| | | "menu.help": "Ajutor", |
| | | "menu.version": "Versiune", |
| | | "menu.proStatus.free": "În prezent sunteți utilizator gratuit (treceți la Pro)", |
| | | "menu.proStatus.unlocked": "În prezent sunteți un utilizator Pro apreciat", |
| | | "settings.language": "Limbă", |
| | | "settings.languagePicker": "Limba aplicației:", |
| | | "settings.languageDesc": "Alege limba folosită pentru meniuri și setări.", |
| | |
| | | "menu.language": "Язык", |
| | | "menu.help": "Справка", |
| | | "menu.version": "Версия", |
| | | "menu.proStatus.free": "Сейчас вы пользователь бесплатной версии (перейти на Pro)", |
| | | "menu.proStatus.unlocked": "Сейчас вы ценный пользователь Pro", |
| | | "settings.language": "Язык", |
| | | "settings.languagePicker": "Язык приложения:", |
| | | "settings.languageDesc": "Выберите язык меню и настроек.", |
| | |
| | | "menu.language": "Језик", |
| | | "menu.help": "Помоћ", |
| | | "menu.version": "Верзија", |
| | | "menu.proStatus.free": "Тренутно сте корисник бесплатне верзије (надоградите на Pro)", |
| | | "menu.proStatus.unlocked": "Тренутно сте цењени Pro корисник", |
| | | "settings.language": "Језик", |
| | | "settings.languagePicker": "Језик апликације:", |
| | | "settings.languageDesc": "Изаберите језик менија и подешавања.", |
| | |
| | | "menu.language": "Språk", |
| | | "menu.help": "Hjälp", |
| | | "menu.version": "Version", |
| | | "menu.proStatus.free": "Du är för närvarande gratisanvändare (uppgradera till Pro)", |
| | | "menu.proStatus.unlocked": "Du är för närvarande en uppskattad Pro-användare", |
| | | "settings.language": "Språk", |
| | | "settings.languagePicker": "Appspråk:", |
| | | "settings.languageDesc": "Välj språket som används i menyer och inställningar.", |
| | |
| | | "menu.language": "ภาษา", |
| | | "menu.help": "ช่วยเหลือ", |
| | | "menu.version": "เวอร์ชัน", |
| | | "menu.proStatus.free": "ตอนนี้คุณเป็นผู้ใช้ฟรี (อัปเกรดเป็น Pro)", |
| | | "menu.proStatus.unlocked": "ตอนนี้คุณเป็นผู้ใช้ Pro ระดับพิเศษ", |
| | | "settings.language": "ภาษา", |
| | | "settings.languagePicker": "ภาษาของแอป:", |
| | | "settings.languageDesc": "เลือกภาษาที่ใช้ในเมนูและการตั้งค่า", |
| | |
| | | "menu.language": "Dil", |
| | | "menu.help": "Yardım", |
| | | "menu.version": "Sürüm", |
| | | "menu.proStatus.free": "Şu anda ücretsiz kullanıcısınız (Pro’ya yükseltin)", |
| | | "menu.proStatus.unlocked": "Şu anda değerli bir Pro kullanıcısınız", |
| | | "settings.language": "Dil", |
| | | "settings.languagePicker": "Uygulama dili:", |
| | | "settings.languageDesc": "Menülerde ve ayarlarda kullanılan dili seçin.", |
| | |
| | | "menu.language": "Мова", |
| | | "menu.help": "Довідка", |
| | | "menu.version": "Версія", |
| | | "menu.proStatus.free": "Зараз ви користувач безкоштовної версії (оновити до Pro)", |
| | | "menu.proStatus.unlocked": "Зараз ви цінний користувач Pro", |
| | | "settings.language": "Мова", |
| | | "settings.languagePicker": "Мова застосунку:", |
| | | "settings.languageDesc": "Оберіть мову меню та налаштувань.", |
| | |
| | | "menu.language": "Ngôn ngữ", |
| | | "menu.help": "Trợ giúp", |
| | | "menu.version": "Phiên bản", |
| | | "menu.proStatus.free": "Bạn hiện là người dùng Miễn phí (nâng cấp lên Pro)", |
| | | "menu.proStatus.unlocked": "Bạn hiện là người dùng Pro danh giá", |
| | | "settings.language": "Ngôn ngữ", |
| | | "settings.languagePicker": "Ngôn ngữ ứng dụng:", |
| | | "settings.languageDesc": "Chọn ngôn ngữ dùng cho menu và cài đặt.", |
| | |
| | | "menu.language": "语言", |
| | | "menu.help": "帮助", |
| | | "menu.version": "版本", |
| | | "menu.proStatus.free": "当前您是:免费版(升级到 Pro)用户", |
| | | "menu.proStatus.unlocked": "当前您是:尊贵的 Pro 版用户", |
| | | "settings.language": "语言", |
| | | "settings.languagePicker": "应用语言:", |
| | | "settings.languageDesc": "选择菜单和设置面板使用的语言。", |
| | |
| | | "menu.language": "語言", |
| | | "menu.help": "說明", |
| | | "menu.version": "版本", |
| | | "menu.proStatus.free": "目前您是:免費版(升級到 Pro)使用者", |
| | | "menu.proStatus.unlocked": "目前您是:尊貴的 Pro 版使用者", |
| | | "settings.language": "語言", |
| | | "settings.languagePicker": "應用程式語言:", |
| | | "settings.languageDesc": "選擇選單和設定面板使用的語言。", |
| | |
| | | } |
| | | switch proEntitlement.accessState { |
| | | case .free: |
| | | return .locked |
| | | return .neutral |
| | | case .purchased: |
| | | return .unlocked |
| | | case .legacy: |
| | |
| | | ) |
| | | } |
| | | |
| | | private var dataProStatusCard: some View { |
| | | private var proStatusCard: some View { |
| | | HStack(alignment: .top, spacing: 18) { |
| | | VStack(alignment: .leading, spacing: 10) { |
| | | HStack(spacing: 10) { |
| | |
| | | Spacer(minLength: 36) |
| | | |
| | | VStack(alignment: .center, spacing: 24) { |
| | | dataProStatusCard |
| | | |
| | | dataSection(minHeight: 124) { |
| | | HStack(alignment: .center, spacing: 12) { |
| | | dataSectionTitle(tr("settings.initialLayout")) |
| | |
| | | case .about: |
| | | // Tab 6: About |
| | | VStack(spacing: 0) { |
| | | Spacer(minLength: 72) |
| | | Spacer(minLength: 44) |
| | | |
| | | HStack { |
| | | VStack(alignment: .center, spacing: 24) { |
| | | proStatusCard |
| | | |
| | | HStack(alignment: .center, spacing: 24) { |
| | | if let icon = NSImage(named: NSImage.applicationIconName) { |
| | | Image(nsImage: icon) |
| | | .resizable() |
| | | .frame(width: 128, height: 128) |
| | | .frame(width: 112, height: 112) |
| | | } |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Text("TagLauncher") |
| | |
| | | .foregroundStyle(.secondary) |
| | | } |
| | | } |
| | | .frame(width: 560, alignment: .leading) |
| | | .frame(width: dataPanelWidth, alignment: .leading) |
| | | } |
| | | .frame(width: dataPanelWidth, alignment: .center) |
| | | |
| | | Spacer(minLength: 30) |
| | | Spacer(minLength: 18) |
| | |
| | | var compact = false |
| | | |
| | | var body: some View { |
| | | HStack(spacing: compact ? 4 : 5) { |
| | | if showsCrown { |
| | | Image(systemName: "crown.fill") |
| | | .font(.system(size: compact ? 9 : 10, weight: .bold)) |
| | | } |
| | | |
| | | Text(text) |
| | | } |
| | | .font(.system(size: compact ? 10 : 11, weight: .semibold)) |
| | | .foregroundStyle(foregroundColor) |
| | | .lineLimit(1) |
| | |
| | | private var foregroundColor: Color { |
| | | switch style { |
| | | case .locked: |
| | | return Color.accentColor |
| | | return Color(red: 0.56, green: 0.35, blue: 0.03) |
| | | case .preview: |
| | | return Color(red: 0.30, green: 0.22, blue: 0.08) |
| | | case .unlocked, .legacy: |
| | | return Color(red: 0.10, green: 0.42, blue: 0.24) |
| | | return Color(red: 0.56, green: 0.35, blue: 0.03) |
| | | case .neutral: |
| | | return .secondary |
| | | } |
| | |
| | | private var backgroundColor: Color { |
| | | switch style { |
| | | case .locked: |
| | | return Color.accentColor.opacity(0.10) |
| | | return Color(red: 1.0, green: 0.78, blue: 0.22).opacity(0.18) |
| | | case .preview: |
| | | return Color.orange.opacity(0.14) |
| | | case .unlocked, .legacy: |
| | | return Color.green.opacity(0.12) |
| | | return Color(red: 1.0, green: 0.78, blue: 0.22).opacity(0.18) |
| | | case .neutral: |
| | | return Color.secondary.opacity(0.10) |
| | | } |
| | |
| | | private var strokeColor: Color { |
| | | switch style { |
| | | case .locked: |
| | | return Color.accentColor.opacity(0.26) |
| | | return Color(red: 0.93, green: 0.62, blue: 0.10).opacity(0.42) |
| | | case .preview: |
| | | return Color.orange.opacity(0.28) |
| | | case .unlocked, .legacy: |
| | | return Color.green.opacity(0.26) |
| | | return Color(red: 0.93, green: 0.62, blue: 0.10).opacity(0.42) |
| | | case .neutral: |
| | | return Color.secondary.opacity(0.18) |
| | | } |
| | | } |
| | | |
| | | private var showsCrown: Bool { |
| | | switch style { |
| | | case .locked, .unlocked, .legacy: |
| | | return text.localizedCaseInsensitiveContains("Pro") |
| | | case .preview, .neutral: |
| | | return false |
| | | } |
| | | } |
| | | } |
| | | |
| | | extension ProFeature { |
| | |
| | | trackingAreaRef = area |
| | | } |
| | | |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | guard !isHidden, alphaValue > 0, bounds.contains(point) else { return nil } |
| | | return self |
| | | } |
| | | |
| | | override func acceptsFirstMouse(for event: NSEvent?) -> Bool { |
| | | true |
| | | } |
| | | |
| | | override func mouseEntered(with event: NSEvent) { |
| | | super.mouseEntered(with: event) |
| | | if let result { |
| | |
| | | |
| | | ## [Unreleased] |
| | | |
| | | ## [8.1.1] — 2026-06-26 |
| | | |
| | | - 优化样式 2 / 样式 3 的 Mac 自带容器布局:瀑布流容器样式下固定到底部并横向占满,避免窄列过高挤压其它分组 |
| | | - 调整 Pro 状态入口:升级卡从“数据”页迁移到“关于”页,数据页重新专注于分类方案、导入导出和气泡显示位置设置 |
| | | - 菜单栏下拉项新增当前免费 / Pro 身份状态:免费用户可点击进入“关于”页升级;Pro 用户显示金色皇冠图标和 Pro 身份文案 |
| | | - 统一 Pro 胶囊为金色皇冠体系;免费版状态保持中性胶囊,避免把免费状态误读为已解锁 Pro |
| | | - 修复窗口回归中发现的 Quick Search 点击边界:结果行内文字 / 图标区域也能稳定触发启动和关闭;点击 TagLauncher 自身结果时只关闭当前浮层,不再通过外部激活把 AppGrid 重新拉起 |
| | | - 补齐 29 个语言包的菜单栏 Pro 状态文案,并修正土耳其语免费用户文案 typo |
| | | - 版本号更新为 `8.1.1`,Build 更新为 `20260626.2229` |
| | | |
| | | ## [8.1.0] — 2026-06-26 |
| | | |
| | | - 新增免费版 + Pro 一次性买断解锁体系:核心标签整理、基础启动和 Quick Search 保持免费可用,高级主题、导入 / 导出布局、无限自定义备注和应用图标排序持久化纳入 Pro |
| | |
| | | LAUNCH_AGENT_LABEL="com.taglauncher.app" |
| | | LAUNCH_AGENT_PLIST="$HOME/Library/LaunchAgents/$LAUNCH_AGENT_LABEL.plist" |
| | | SAVED_STATE_DIR="$HOME/Library/Saved Application State/$LAUNCH_AGENT_LABEL.savedState" |
| | | STORE_DIR="$HOME/Library/Application Support/TagLauncher" |
| | | STORE_PATH="$STORE_DIR/tags.json" |
| | | USER_GUI_DOMAIN="gui/$(id -u)" |
| | | RESTORE_LAUNCH_AGENT=false |
| | | LAUNCH_AGENT_PLIST_WAS_PRESENT=false |
| | | LAUNCH_AGENT_BACKUP="$(mktemp -t taglauncher-launchagent.XXXXXX.plist)" |
| | | STORE_WAS_PRESENT=false |
| | | STORE_BACKUP="$(mktemp -t taglauncher-tags.XXXXXX.json)" |
| | | DEFAULTS_DOMAIN="$LAUNCH_AGENT_LABEL" |
| | | SHOW_DOCK_ICON_WAS_SET=false |
| | | SHOW_DOCK_ICON_VALUE="" |
| | |
| | | if [[ -f "$LAUNCH_AGENT_PLIST" ]]; then |
| | | LAUNCH_AGENT_PLIST_WAS_PRESENT=true |
| | | cp "$LAUNCH_AGENT_PLIST" "$LAUNCH_AGENT_BACKUP" |
| | | fi |
| | | } |
| | | |
| | | backup_store() { |
| | | if [[ -f "$STORE_PATH" ]]; then |
| | | STORE_WAS_PRESENT=true |
| | | cp "$STORE_PATH" "$STORE_BACKUP" |
| | | fi |
| | | } |
| | | |
| | |
| | | fi |
| | | else |
| | | rm -f "$LAUNCH_AGENT_PLIST" |
| | | fi |
| | | } |
| | | |
| | | restore_store() { |
| | | kill_all_taglauncher_instances |
| | | if [[ "$STORE_WAS_PRESENT" == true ]]; then |
| | | mkdir -p "$STORE_DIR" |
| | | cp "$STORE_BACKUP" "$STORE_PATH" || true |
| | | else |
| | | rm -f "$STORE_PATH" |
| | | fi |
| | | } |
| | | |
| | |
| | | restore_user_defaults |
| | | restore_qa_environment |
| | | restore_launch_agent_plist |
| | | restore_store |
| | | if [[ "$RESTORE_LAUNCH_AGENT" == true && -f "$LAUNCH_AGENT_PLIST" ]]; then |
| | | launchctl bootstrap "$USER_GUI_DOMAIN" "$LAUNCH_AGENT_PLIST" >/dev/null 2>&1 || true |
| | | fi |
| | |
| | | trap cleanup EXIT |
| | | backup_user_defaults |
| | | backup_launch_agent_plist |
| | | backup_store |
| | | backup_qa_environment |
| | | |
| | | kill_all_taglauncher_instances() { |
| | |
| | | assert_single_qa_app_instance |
| | | } |
| | | |
| | | seed_quick_search_recent_result() { |
| | | local app_path="" |
| | | for candidate in \ |
| | | "/System/Applications/System Settings.app" \ |
| | | "/System/Applications/Utilities/Terminal.app" \ |
| | | "/System/Applications/Calculator.app" \ |
| | | "/System/Applications/TextEdit.app" |
| | | do |
| | | if [[ -d "$candidate" ]]; then |
| | | app_path="$candidate" |
| | | break |
| | | fi |
| | | done |
| | | |
| | | if [[ -z "$app_path" ]]; then |
| | | echo "FAIL: could not find a stable system app to seed Quick Search history" >&2 |
| | | exit 1 |
| | | fi |
| | | |
| | | mkdir -p "$STORE_DIR" |
| | | python3 - "$STORE_PATH" "$app_path" <<'PY' |
| | | import json |
| | | import os |
| | | import sys |
| | | from datetime import datetime, timezone |
| | | |
| | | store_path, app_path = sys.argv[1], sys.argv[2] |
| | | if os.path.exists(store_path): |
| | | with open(store_path, "r", encoding="utf-8") as handle: |
| | | try: |
| | | store = json.load(handle) |
| | | except json.JSONDecodeError: |
| | | store = {} |
| | | else: |
| | | store = {} |
| | | |
| | | store.setdefault("version", 1) |
| | | store.setdefault("appOpenCounts", {}) |
| | | store.setdefault("appLastOpenedAt", {}) |
| | | store["appOpenCounts"][app_path] = max(1, int(store["appOpenCounts"].get(app_path, 0)) + 1) |
| | | reference = datetime(2001, 1, 1, tzinfo=timezone.utc) |
| | | store["appLastOpenedAt"][app_path] = (datetime.now(timezone.utc) - reference).total_seconds() |
| | | |
| | | with open(store_path, "w", encoding="utf-8") as handle: |
| | | json.dump(store, handle, ensure_ascii=False, indent=2, sort_keys=True) |
| | | PY |
| | | } |
| | | |
| | | assert_swift="$(mktemp -t taglauncher-window-assert.XXXXXX.swift)" |
| | | coords_swift="$(mktemp -t taglauncher-window-coords.XXXXXX.swift)" |
| | | settings_ax_swift="$(mktemp -t taglauncher-settings-ax.XXXXXX.swift)" |
| | | screens_swift="$(mktemp -t taglauncher-screens.XXXXXX.swift)" |
| | | fullscreen_swift="$(mktemp -t taglauncher-fullscreen-target.XXXXXX.swift)" |
| | | trap 'cleanup; rm -f "$assert_swift" "$coords_swift" "$settings_ax_swift" "$screens_swift" "$fullscreen_swift" "$LAUNCH_AGENT_BACKUP"' EXIT |
| | | trap 'cleanup; rm -f "$assert_swift" "$coords_swift" "$settings_ax_swift" "$screens_swift" "$fullscreen_swift" "$LAUNCH_AGENT_BACKUP" "$STORE_BACKUP"' EXIT |
| | | |
| | | cat >"$assert_swift" <<'SWIFT' |
| | | import AppKit |
| | |
| | | kill_all_taglauncher_instances |
| | | sleep 0.4 |
| | | swift_assert no-overlay |
| | | seed_quick_search_recent_result |
| | | prepare_isolated_app_instance |
| | | assert_no_dock_tile |
| | | log "==> QA hidden Dock: clicking Quick Search result closes without showing App Grid" |