Prepare TagLauncher 8.3.2 App Store candidate
11 files modified
2 files added
| | |
| | | - Quick Search 系统应用回归。 |
| | | - `src/Scripts/quick_search_launch_contract_qa.sh` |
| | | - Quick Search 启动路径静态门禁;检查 in-app mouse monitor 不吞结果行点击、面板是激活 key panel、panel-level `sendEvent` 点击兜底存在、Return submit fallback 存在、点击 / submit 都接到 `NSWorkspace.openApplication` 启动路径。 |
| | | - `src/Scripts/windowserver_event_safety_qa.sh` |
| | | - WindowServer / TextInput 事件安全静态门禁;检查 Quick Search marked-text 输入保护、composition 期间不响应鼠标 / 背景关闭、面板置前去重,以及 TagNavigation 阻塞式事件循环兜底退出。 |
| | | - `src/Scripts/app_note_bubble_input_qa.sh` |
| | | - App note 泡泡输入框 IME 安全门禁;检查备注泡泡使用原生 `NSTextField` 桥接、marked text 期间不覆盖/截断/提交,最终提交仍受备注长度上限约束。 |
| | | - `src/Scripts/pro_custom_hotkeys_qa.sh` |
| | | - Pro 自定义快捷键静态与 Carbon 运行时门禁;检查 Pro gate、录制期间暂停/恢复全局快捷键、事务式注册保存、重复校验、禁用重权限键盘 API、设置页 Pro 按钮组合和 29 语种文案。 |
| | | - `src/Scripts/pro_tag_custom_color_qa.sh` |
| | |
| | | - 自定义标签颜色是 Pro 功能:免费用户可继续使用基础色,已有自定义色继续显示,不能新增或修改为新自定义色;改回基础色必须允许且会清除 `customColor`。 |
| | | - Pro 状态 UI 必须优先使用设置页签下方固定位置的统一 header;不得在各 tab 内重复塞第二条 Pro 引导。About 不承载 Pro 汇总卡或免费 / Pro 差异说明,Data 不承载营销式大卡,避免挤压数据管理布局。 |
| | | - 免费主题固定为默认、深蓝、黑色;其它主题只能预览或在 Pro 解锁后持久化。 |
| | | - `originalAppVersion` 早于免费 + Pro 首个版本的历史用户必须自动获得 Pro;`originalPurchaseDate` 只能作为版本不可判断时的兜底。 |
| | | - `originalAppVersion` 早于免费 + Pro 首个版本的历史用户必须自动获得 Pro;`originalPurchaseDate` 只能作为版本不可判断时的兜底。当前免费 + Pro 首个版本边界由 `ProEntitlementConfig.firstFreeProVersion` 统一维护为 `8.3.2`,用于保护 8.2.9 及更早付费下载用户。 |
| | | - 默认主题必须保留原浅色毛玻璃 AppGrid。 |
| | | - 黑色主题背景必须为 100% 纯黑;深蓝和黑色使用深色玻璃;粉色、紫色、绿色、蓝色、炫彩使用浅色玻璃以保证明亮和可读。 |
| | | - 单个 AppGrid 容器保持统一半透明玻璃面板,不做容器内部渐变。 |
| | |
| | | ] |
| | | ) { [weak self] event in |
| | | guard let self, |
| | | self.window != nil, |
| | | event.window === self.window, |
| | | self.handleUsageTipsMouseEvent( |
| | | event, |
| | | triggerButtons: event.type == .leftMouseDown |
| | |
| | | if event.window === window { |
| | | return convert(event.locationInWindow, from: nil) |
| | | } |
| | | guard let window else { return .zero } |
| | | let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) |
| | | return convert(windowPoint, from: nil) |
| | | return .zero |
| | | } |
| | | |
| | | private func removeUsageTipsMouseMonitor() { |
| | |
| | | if event.window === window { |
| | | return convert(event.locationInWindow, from: nil) |
| | | } |
| | | guard let window else { return .zero } |
| | | let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) |
| | | return convert(windowPoint, from: nil) |
| | | return .zero |
| | | } |
| | | |
| | | private func setPointerInside(_ inside: Bool) { |
| | |
| | | |
| | | if isEditing { |
| | | VStack(alignment: .trailing, spacing: 4) { |
| | | TextField(tr("appNote.placeholder"), text: limitedDraft) |
| | | .textFieldStyle(.plain) |
| | | .font(.system(size: 15, weight: .medium)) |
| | | .foregroundStyle(.white) |
| | | AppBubbleNoteTextField( |
| | | text: $draftNote, |
| | | placeholder: tr("appNote.placeholder"), |
| | | isFocused: noteFocused, |
| | | onCommit: onCommit, |
| | | onCancel: onCancel |
| | | ) |
| | | .padding(.horizontal, 12) |
| | | .frame(height: 36) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 9) |
| | | .fill(Color.white.opacity(0.12)) |
| | | ) |
| | | .focused(noteFocused) |
| | | .onSubmit(onCommit) |
| | | Text("\(draftNote.count) / \(TagDatabase.maxAppNoteLength)") |
| | | Text("\(min(draftNote.count, TagDatabase.maxAppNoteLength)) / \(TagDatabase.maxAppNoteLength)") |
| | | .font(.system(size: 10, weight: .medium)) |
| | | .foregroundStyle(.white.opacity(0.35)) |
| | | |
| | |
| | | .offset(x: arrowOffset, y: placement == .above ? 8 : -8) |
| | | } |
| | | } |
| | | } |
| | | |
| | | private var limitedDraft: Binding<String> { |
| | | Binding( |
| | | get: { draftNote }, |
| | | set: { draftNote = String($0.prefix(TagDatabase.maxAppNoteLength)) } |
| | | private struct AppBubbleNoteTextField: NSViewRepresentable { |
| | | @Binding var text: String |
| | | let placeholder: String |
| | | var isFocused: FocusState<Bool>.Binding |
| | | let onCommit: () -> Void |
| | | let onCancel: () -> Void |
| | | |
| | | func makeCoordinator() -> Coordinator { |
| | | Coordinator( |
| | | text: $text, |
| | | isFocused: isFocused, |
| | | onCommit: onCommit, |
| | | onCancel: onCancel |
| | | ) |
| | | } |
| | | |
| | | func makeNSView(context: Context) -> AppBubbleNativeTextField { |
| | | let field = AppBubbleNativeTextField() |
| | | field.isBordered = false |
| | | field.isBezeled = false |
| | | field.drawsBackground = false |
| | | field.focusRingType = .none |
| | | field.font = NSFont.systemFont(ofSize: 15, weight: .medium) |
| | | field.textColor = .white |
| | | field.placeholderString = placeholder |
| | | field.delegate = context.coordinator |
| | | field.target = context.coordinator |
| | | field.action = #selector(Coordinator.submitAction(_:)) |
| | | field.cell?.usesSingleLineMode = true |
| | | field.cell?.wraps = false |
| | | field.lineBreakMode = .byTruncatingTail |
| | | field.setAccessibilityLabel(placeholder) |
| | | context.coordinator.field = field |
| | | return field |
| | | } |
| | | |
| | | func updateNSView(_ field: AppBubbleNativeTextField, context: Context) { |
| | | context.coordinator.text = $text |
| | | context.coordinator.isFocused = isFocused |
| | | context.coordinator.onCommit = onCommit |
| | | context.coordinator.onCancel = onCancel |
| | | context.coordinator.field = field |
| | | field.placeholderString = placeholder |
| | | field.setAccessibilityLabel(placeholder) |
| | | |
| | | if !field.hasMarkedTextInput, field.stringValue != text { |
| | | field.stringValue = text |
| | | } |
| | | |
| | | guard isFocused.wrappedValue, field.window != nil, field.currentEditor() == nil else { return } |
| | | DispatchQueue.main.async { [weak field] in |
| | | guard let field, field.window != nil, field.currentEditor() == nil else { return } |
| | | field.window?.makeFirstResponder(field) |
| | | } |
| | | } |
| | | |
| | | final class Coordinator: NSObject, NSTextFieldDelegate { |
| | | var text: Binding<String> |
| | | var isFocused: FocusState<Bool>.Binding |
| | | var onCommit: () -> Void |
| | | var onCancel: () -> Void |
| | | weak var field: AppBubbleNativeTextField? |
| | | |
| | | init( |
| | | text: Binding<String>, |
| | | isFocused: FocusState<Bool>.Binding, |
| | | onCommit: @escaping () -> Void, |
| | | onCancel: @escaping () -> Void |
| | | ) { |
| | | self.text = text |
| | | self.isFocused = isFocused |
| | | self.onCommit = onCommit |
| | | self.onCancel = onCancel |
| | | } |
| | | |
| | | func controlTextDidChange(_ notification: Notification) { |
| | | guard let field = notification.object as? AppBubbleNativeTextField, |
| | | !field.hasMarkedTextInput |
| | | else { return } |
| | | syncFinalText(from: field) |
| | | } |
| | | |
| | | func controlTextDidEndEditing(_ notification: Notification) { |
| | | if let field = notification.object as? AppBubbleNativeTextField { |
| | | syncFinalText(from: field) |
| | | } |
| | | isFocused.wrappedValue = false |
| | | } |
| | | |
| | | func control( |
| | | _ control: NSControl, |
| | | textView: NSTextView, |
| | | doCommandBy commandSelector: Selector |
| | | ) -> Bool { |
| | | if textView.hasMarkedText() { |
| | | return false |
| | | } |
| | | |
| | | switch commandSelector { |
| | | case #selector(NSResponder.insertNewline(_:)): |
| | | if let field = control as? AppBubbleNativeTextField { |
| | | syncFinalText(from: field) |
| | | } |
| | | onCommit() |
| | | return true |
| | | case #selector(NSResponder.cancelOperation(_:)): |
| | | isFocused.wrappedValue = false |
| | | onCancel() |
| | | return true |
| | | default: |
| | | return false |
| | | } |
| | | } |
| | | |
| | | @objc func submitAction(_ sender: Any?) { |
| | | guard let field = sender as? AppBubbleNativeTextField, |
| | | !field.hasMarkedTextInput |
| | | else { return } |
| | | syncFinalText(from: field) |
| | | onCommit() |
| | | } |
| | | |
| | | private func syncFinalText(from field: AppBubbleNativeTextField) { |
| | | guard !field.hasMarkedTextInput else { return } |
| | | let limited = String(field.stringValue.prefix(TagDatabase.maxAppNoteLength)) |
| | | if field.stringValue != limited { |
| | | field.stringValue = limited |
| | | } |
| | | text.wrappedValue = limited |
| | | } |
| | | } |
| | | } |
| | | |
| | | private final class AppBubbleNativeTextField: NSTextField { |
| | | var hasMarkedTextInput: Bool { |
| | | (currentEditor() as? NSTextView)?.hasMarkedText() == true |
| | | } |
| | | } |
| | | |
| | | private struct BubbleArrow: Shape { |
| | |
| | | @State private var quickSearchOpenedAt: Date? = nil |
| | | @State private var quickSearchErrorMessage: String? = nil |
| | | @State private var initialQuickSearchConsumed = false |
| | | @State private var quickSearchCompositionActive = false |
| | | |
| | | init(hideOverlay: @escaping () -> Void, initialQuickSearchSource: String? = nil) { |
| | | self.hideOverlay = hideOverlay |
| | |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide), perform: handleOverlayDidHide) |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested), perform: handleQuickSearchRequested) |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchDismissRequested), perform: handleQuickSearchDismissRequested) |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchCompositionChanged), perform: handleQuickSearchCompositionChanged) |
| | | .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification), perform: handleApplicationDidBecomeActive) |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange), perform: handleDataDidChange) |
| | | .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange), perform: handleAppLanguageDidChange) |
| | |
| | | quickSearchVisible = true |
| | | quickSearchOpenedAt = Date() |
| | | quickSearchQuery = "" |
| | | quickSearchCompositionActive = false |
| | | quickSearchManualSelection = false |
| | | quickSearchErrorMessage = nil |
| | | quickSearchFocusToken &+= 1 |
| | |
| | | if shouldIgnoreEarlyQuickSearchDismiss(from: dismissalSource) { |
| | | return |
| | | } |
| | | if shouldIgnoreQuickSearchDismissDuringComposition(from: dismissalSource) { |
| | | return |
| | | } |
| | | let shouldHideOverlay = quickSearchVisible && quickSearchCloseHidesOverlay && hideOverlayIfNeeded |
| | | guard quickSearchVisible else { return } |
| | | quickSearchVisible = false |
| | | quickSearchOpenedAt = nil |
| | | quickSearchCompositionActive = false |
| | | quickSearchQuery = "" |
| | | quickSearchResults = [] |
| | | quickSearchSelectedID = nil |
| | |
| | | "source": source, |
| | | "age": String(format: "%.3f", age), |
| | | "gracePeriod": String(format: "%.2f", gracePeriod) |
| | | ]) |
| | | return true |
| | | } |
| | | |
| | | private func shouldIgnoreQuickSearchDismissDuringComposition(from source: String?) -> Bool { |
| | | guard quickSearchCompositionActive, |
| | | source == QuickSearchDismissSource.mouse || source == QuickSearchDismissSource.backdrop |
| | | else { return false } |
| | | Diagnostics.log("content.quickSearch.ignoredCompositionDismiss", [ |
| | | "source": source |
| | | ]) |
| | | return true |
| | | } |
| | |
| | | closeQuickSearch(dismissalSource: source) |
| | | } |
| | | |
| | | private func handleQuickSearchCompositionChanged(_ notification: Notification) { |
| | | guard quickSearchVisible else { |
| | | quickSearchCompositionActive = false |
| | | return |
| | | } |
| | | quickSearchCompositionActive = (notification.userInfo?["active"] as? Bool) ?? false |
| | | } |
| | | |
| | | private func handleApplicationDidBecomeActive(_ notification: Notification) { |
| | | _ = notification |
| | | guard allApps.isEmpty, !refreshInProgress else { return } |
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>8.3.1</string> |
| | | <string>8.3.2</string> |
| | | <key>CFBundleURLTypes</key> |
| | | <array> |
| | | <dict> |
| | |
| | | </dict> |
| | | </array> |
| | | <key>CFBundleVersion</key> |
| | | <string>20260701.2302</string> |
| | | <string>20260702.0231</string> |
| | | <key>LSApplicationCategoryType</key> |
| | | <string>public.app-category.utilities</string> |
| | | <key>LSMinimumSystemVersion</key> |
| | |
| | | enum ProEntitlementConfig { |
| | | // 发布前只改这里,不允许把商品信息和边界版本散落到业务模块。 |
| | | static let lifetimeProductID = "com.taglauncher.pro.lifetime" |
| | | static let firstFreeProVersion = "8.1.0" |
| | | static let firstFreeProVersion = "8.3.2" |
| | | static let legacyFallbackOriginalPurchaseDateISO8601: String? = nil |
| | | |
| | | static let freeThemes: Set<AppGridTheme> = [.defaultLight, .black] |
| | |
| | | static let tagLauncherQuickSearchRequested = Notification.Name("TagLauncherQuickSearchRequested") |
| | | static let tagLauncherQuickSearchDismissRequested = Notification.Name("TagLauncherQuickSearchDismissRequested") |
| | | static let tagLauncherQuickSearchVisibilityChanged = Notification.Name("TagLauncherQuickSearchVisibilityChanged") |
| | | static let tagLauncherQuickSearchCompositionChanged = Notification.Name("TagLauncherQuickSearchCompositionChanged") |
| | | static let tagLauncherHotkeyRegistrationChanged = Notification.Name("TagLauncherHotkeyRegistrationChanged") |
| | | static let tagLauncherProEntitlementChanged = Notification.Name("TagLauncherProEntitlementChanged") |
| | | } |
| | |
| | | self.parentWindow = parentWindow |
| | | } |
| | | |
| | | let shouldOrderForInput = shouldOrderPanelForInput(panel) |
| | | if panel.frame != frame { |
| | | panel.setFrame(frame, display: true) |
| | | } |
| | | NSApp.activate(ignoringOtherApps: true) |
| | | panel.makeKeyAndOrderFront(nil) |
| | | panel.orderFrontRegardless() |
| | | if shouldOrderForInput { |
| | | NSApp.activate(ignoringOtherApps: true) |
| | | panel.makeKeyAndOrderFront(nil) |
| | | panel.orderFrontRegardless() |
| | | } |
| | | } |
| | | |
| | | func closePanel() { |
| | |
| | | self.panel = panel |
| | | self.hostingView = hostingView |
| | | return panel |
| | | } |
| | | |
| | | private func shouldOrderPanelForInput(_ panel: QuickSearchPanel) -> Bool { |
| | | !panel.isVisible || !panel.isKeyWindow || NSApp.keyWindow !== panel |
| | | } |
| | | |
| | | private func scheduleDeferredApply( |
| | |
| | | } |
| | | |
| | | func updateNSView(_ field: QuickSearchNativeTextField, context: Context) { |
| | | if field.stringValue != text { |
| | | if !field.isComposingText, field.stringValue != text { |
| | | field.stringValue = text |
| | | } |
| | | field.publishCompositionStateIfNeeded() |
| | | field.placeholderString = placeholder |
| | | field.onCommand = onCommand |
| | | field.target = context.coordinator |
| | |
| | | guard let field, |
| | | let window = field.window, |
| | | window.isVisible, |
| | | window.firstResponder !== field |
| | | field.currentEditor() == nil |
| | | else { return } |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | if !window.isKeyWindow || NSApp.keyWindow !== window { |
| | | NSApp.activate(ignoringOtherApps: true) |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | } |
| | | window.makeFirstResponder(field) |
| | | } |
| | | } |
| | |
| | | var text: Binding<String> |
| | | var lastFocusToken = 0 |
| | | var onCommand: ((QuickSearchCommand) -> Void)? |
| | | weak var field: NSTextField? |
| | | weak var field: QuickSearchNativeTextField? |
| | | |
| | | init(text: Binding<String>) { |
| | | self.text = text |
| | | } |
| | | |
| | | func controlTextDidChange(_ obj: Notification) { |
| | | guard let field = obj.object as? NSTextField else { return } |
| | | guard let field = obj.object as? QuickSearchNativeTextField else { return } |
| | | field.publishCompositionStateIfNeeded() |
| | | if !field.isComposingText { |
| | | text.wrappedValue = field.stringValue |
| | | } |
| | | } |
| | | |
| | | func controlTextDidEndEditing(_ obj: Notification) { |
| | | guard let field = obj.object as? QuickSearchNativeTextField else { return } |
| | | field.publishCompositionState(active: false, force: true) |
| | | text.wrappedValue = field.stringValue |
| | | } |
| | | |
| | | @objc func submitAction(_ sender: Any?) { |
| | | guard field?.isComposingText != true else { return } |
| | | onCommand?(.submit) |
| | | } |
| | | |
| | | func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { |
| | | if textView.hasMarkedText() { |
| | | (control as? QuickSearchNativeTextField)?.publishCompositionState(active: true) |
| | | return false |
| | | } |
| | | switch commandSelector { |
| | | case #selector(NSResponder.moveUp(_:)): |
| | | onCommand?(.moveUp) |
| | |
| | | |
| | | private final class QuickSearchNativeTextField: NSTextField { |
| | | var onCommand: ((QuickSearchCommand) -> Void)? |
| | | private var lastPublishedCompositionActive = false |
| | | |
| | | override var acceptsFirstResponder: Bool { true } |
| | | |
| | | var isComposingText: Bool { |
| | | (currentEditor() as? NSTextView)?.hasMarkedText() == true |
| | | } |
| | | |
| | | func publishCompositionStateIfNeeded() { |
| | | publishCompositionState(active: isComposingText) |
| | | } |
| | | |
| | | func publishCompositionState(active: Bool, force: Bool = false) { |
| | | guard force || active != lastPublishedCompositionActive else { return } |
| | | lastPublishedCompositionActive = active |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchCompositionChanged, |
| | | object: self, |
| | | userInfo: ["active": active] |
| | | ) |
| | | } |
| | | |
| | | override func keyDown(with event: NSEvent) { |
| | | guard !isComposingText else { |
| | | super.keyDown(with: event) |
| | | return |
| | | } |
| | | switch Int(event.keyCode) { |
| | | case kVK_UpArrow: |
| | | onCommand?(.moveUp) |
| | |
| | | |
| | | let start = Date() |
| | | let longPressDuration: TimeInterval = 0.35 |
| | | let maxTrackingDuration: TimeInterval = 8 |
| | | var reorderActive = false |
| | | var finished = false |
| | | |
| | | while !finished { |
| | | guard let window, |
| | | window.isVisible, |
| | | NSApp.isActive, |
| | | Date().timeIntervalSince(start) < maxTrackingDuration |
| | | else { |
| | | if reorderActive { |
| | | onReorderEnded() |
| | | } |
| | | break |
| | | } |
| | | |
| | | if !reorderActive && Date().timeIntervalSince(start) >= longPressDuration { |
| | | reorderActive = true |
| | | onReorderBegan(item.id) |
| | | } |
| | | |
| | | let nextEvent = window?.nextEvent( |
| | | if NSEvent.pressedMouseButtons & (1 << 0) == 0 { |
| | | if reorderActive { |
| | | onReorderEnded() |
| | | } else { |
| | | onActivate(item.id) |
| | | } |
| | | break |
| | | } |
| | | |
| | | let nextEvent = window.nextEvent( |
| | | matching: [.leftMouseDragged, .leftMouseUp], |
| | | until: Date().addingTimeInterval(0.04), |
| | | inMode: .eventTracking, |
| | |
| | | |
| | | ## [Unreleased] |
| | | |
| | | ## [8.3.2] — 2026-07-02 |
| | | |
| | | - 修复 App Store 从付费下载切换为免费下载 + Pro 内购时的旧付费用户迁移边界:`8.3.2` 之前通过 App Store 付费下载过 TagLauncher 的用户会自动保留 Pro 权益 |
| | | - 更新 Pro 门禁 QA,防止旧付费用户自动 Pro 的版本边界回退到早于 8.2.9 的错误值 |
| | | - 版本号更新为 `8.3.2`,Build 更新为 `20260702.0231` |
| | | |
| | | ## [8.3.1] — 2026-07-01 |
| | | |
| | | - 修复设置窗口在切换语言后,标题栏仍停留在旧语言的问题;标题会在设置窗口出现和语言切换通知时同步刷新 |
| New file |
| | |
| | | #!/usr/bin/env bash |
| | | set -euo pipefail |
| | | |
| | | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| | | APP_GRID_SUPPORT_SWIFT="$ROOT_DIR/Apptag/AppGridSupport.swift" |
| | | CONTENT_VIEW_SWIFT="$ROOT_DIR/Apptag/ContentView.swift" |
| | | |
| | | fail() { |
| | | printf 'FAIL: %s\n' "$*" >&2 |
| | | exit 1 |
| | | } |
| | | |
| | | [[ -f "$APP_GRID_SUPPORT_SWIFT" ]] || fail "missing AppGridSupport.swift" |
| | | [[ -f "$CONTENT_VIEW_SWIFT" ]] || fail "missing ContentView.swift" |
| | | |
| | | python3 - "$APP_GRID_SUPPORT_SWIFT" "$CONTENT_VIEW_SWIFT" <<'PY' |
| | | import pathlib |
| | | import re |
| | | import sys |
| | | |
| | | support = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") |
| | | content_view = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8") |
| | | |
| | | |
| | | def fail(message: str) -> None: |
| | | print(f"FAIL: {message}", file=sys.stderr) |
| | | sys.exit(1) |
| | | |
| | | |
| | | def require(pattern: str, source: str, message: str) -> None: |
| | | if not re.search(pattern, source, re.S): |
| | | fail(message) |
| | | |
| | | |
| | | require( |
| | | r"private\s+struct\s+AppBubbleNoteTextField\s*:\s*NSViewRepresentable", |
| | | support, |
| | | "App note bubble editor must use an AppKit-backed NSTextField bridge", |
| | | ) |
| | | require( |
| | | r"private\s+final\s+class\s+AppBubbleNativeTextField\s*:\s*NSTextField", |
| | | support, |
| | | "App note bubble editor must use a native NSTextField subclass", |
| | | ) |
| | | require( |
| | | r"AppBubbleNoteTextField\s*\((?P<body>.*?)text\s*:\s*\$draftNote(?P<body2>.*?)isFocused\s*:\s*noteFocused(?P<body3>.*?)onCommit\s*:\s*onCommit(?P<body4>.*?)onCancel\s*:\s*onCancel", |
| | | support, |
| | | "AppNameBubble must route draft text, focus, submit, and cancel through the native note editor", |
| | | ) |
| | | |
| | | if re.search(r"TextField\s*\(\s*tr\s*\(\s*\"appNote\.placeholder\"", support): |
| | | fail("App note bubble must not use SwiftUI TextField inside the nonactivating overlay") |
| | | if "limitedDraft" in support: |
| | | fail("App note bubble must not truncate draft text through a SwiftUI Binding setter") |
| | | |
| | | require( |
| | | r"func\s+updateNSView\s*\((?P<body>.*?)if\s+!field\.hasMarkedTextInput\s*,\s*field\.stringValue\s*!=\s*text", |
| | | support, |
| | | "updateNSView must not overwrite the native editor while IME marked text is active", |
| | | ) |
| | | require( |
| | | r"func\s+controlTextDidChange\s*\((?P<body>.*?)!field\.hasMarkedTextInput(?P<body2>.*?)syncFinalText\s*\(\s*from\s*:\s*field\s*\)", |
| | | support, |
| | | "controlTextDidChange must ignore interim IME marked text and only sync finalized text", |
| | | ) |
| | | require( |
| | | r"doCommandBy\s+commandSelector\s*:\s*Selector(?P<body>.*?)if\s+textView\.hasMarkedText\(\)\s*\{\s*return\s+false\s*\}", |
| | | support, |
| | | "IME command handling must stay with NSTextView while marked text is active", |
| | | ) |
| | | require( |
| | | r"case\s+#selector\s*\(\s*NSResponder\.insertNewline\(_:\)\s*\)(?P<body>.*?)syncFinalText\s*\(\s*from\s*:\s*field\s*\)(?P<body2>.*?)onCommit\(\)(?P<body3>.*?)return\s+true", |
| | | support, |
| | | "Return must commit only after final text is synced", |
| | | ) |
| | | require( |
| | | r"case\s+#selector\s*\(\s*NSResponder\.cancelOperation\(_:\)\s*\)(?P<body>.*?)onCancel\(\)(?P<body2>.*?)return\s+true", |
| | | support, |
| | | "Escape must retain the existing app note bubble cancel behavior after IME marked text is finalized", |
| | | ) |
| | | require( |
| | | r"@objc\s+func\s+submitAction\s*\((?P<body>.*?)!field\.hasMarkedTextInput(?P<body2>.*?)syncFinalText\s*\(\s*from\s*:\s*field\s*\)(?P<body3>.*?)onCommit\(\)", |
| | | support, |
| | | "NSTextField submit action must ignore marked text and commit finalized text only", |
| | | ) |
| | | require( |
| | | r"private\s+func\s+syncFinalText\s*\((?P<body>.*?)prefix\s*\(\s*TagDatabase\.maxAppNoteLength\s*\)(?P<body2>.*?)text\.wrappedValue\s*=\s*limited", |
| | | support, |
| | | "finalized note text must still be capped at TagDatabase.maxAppNoteLength", |
| | | ) |
| | | require( |
| | | r"min\s*\(\s*draftNote\.count\s*,\s*TagDatabase\.maxAppNoteLength\s*\)", |
| | | support, |
| | | "bubble character counter must not exceed the note length limit during interim input", |
| | | ) |
| | | require( |
| | | r"private\s+func\s+commitBubbleNote\s*\(\s*\)(?P<body>.*?)TagDatabase\.maxAppNoteLength", |
| | | content_view, |
| | | "commitBubbleNote must retain the final note length cap", |
| | | ) |
| | | |
| | | print("PASS app note bubble input QA: native NSTextField bridge, IME marked-text guards, and final note length cap are present") |
| | | PY |
| | |
| | | require(pro, f"case {case}", f"missing ProFeature.{case}") |
| | | |
| | | require(pro, 'static let lifetimeProductID = "com.taglauncher.pro.lifetime"', "StoreKit lifetime product id changed or missing") |
| | | require(pro, 'static let firstFreeProVersion = "8.1.0"', "legacy unlock cutoff must be 8.1.0") |
| | | require(pro, 'static let firstFreeProVersion = "8.3.2"', "legacy unlock cutoff must protect paid downloads before 8.3.2") |
| | | require(pro, 'static let qaStateEnvKey = "TAGLAUNCHER_QA_PRO_STATE"', "QA Pro-state override is missing") |
| | | require(pro, "static let freeNoteLimit = 3", "free note limit must be 3") |
| | | require(pro, "static let freeThemes: Set<AppGridTheme> = [.defaultLight, .black]", "free theme set must be default/black") |
| | |
| | | "AppGrid host must intercept usage tips mouseDown/mouseUp events before NSCollectionView app items receive them", |
| | | ) |
| | | require( |
| | | r"guard\s+let\s+self\s*,\s*event\.window\s*===\s*self\.window\s*,\s*self\.handleUsageTipsMouseEvent", |
| | | app_grid, |
| | | "usage tips local mouse monitor must only consume events from its own AppGrid window", |
| | | ) |
| | | require( |
| | | r"triggerButtons\s*:\s*event\.type\s*==\s*\.leftMouseDown", |
| | | app_grid, |
| | | "usage tips monitor must only trigger page changes on left mouseDown", |
| | |
| | | if re.search(r"handleMouseEventFromHost\s*\(\s*_\s+event\s*:\s*NSEvent,\s*triggerButtons\s*:\s*Bool\s*\).*?target\.mouseDown", app_grid, re.S): |
| | | fail("usage tips host-routed clicks must not redispatch arbitrary mouseDown events") |
| | | |
| | | for function_name in ["usageTipsHostPoint", "usageTipsPoint"]: |
| | | require( |
| | | rf"private\s+func\s+{function_name}\s*\(\s*for\s+event\s*:\s*NSEvent\s*\)\s*->\s*NSPoint\s*\{{\s*if\s+event\.window\s*===\s*window\s*\{{(?P<body>.*?)return\s+convert\s*\(\s*event\.locationInWindow\s*,\s*from\s*:\s*nil\s*\)(?P<body2>.*?)\}}\s*return\s+\.zero\s*\}}", |
| | | app_grid, |
| | | f"{function_name} must return zero for events outside its own window", |
| | | ) |
| | | |
| | | for token in [ |
| | | r"private\s+final\s+class\s+AppGridUsageTipIconButton\s*:\s*NSView\b", |
| | | r"override\s+func\s+acceptsFirstMouse\s*\(\s*for\s+event\s*:\s*NSEvent\?\s*\)\s*->\s*Bool\s*\{\s*true\s*\}", |
| New file |
| | |
| | | #!/usr/bin/env bash |
| | | set -euo pipefail |
| | | |
| | | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| | | QUICK_SEARCH="$ROOT_DIR/Apptag/QuickSearch.swift" |
| | | CONTENT_VIEW="$ROOT_DIR/Apptag/ContentView.swift" |
| | | TAG_NAV="$ROOT_DIR/Apptag/TagNavigationView.swift" |
| | | |
| | | python3 - "$QUICK_SEARCH" "$CONTENT_VIEW" "$TAG_NAV" <<'PY' |
| | | import re |
| | | import sys |
| | | from pathlib import Path |
| | | |
| | | quick_search = Path(sys.argv[1]).read_text(encoding="utf-8") |
| | | content_view = Path(sys.argv[2]).read_text(encoding="utf-8") |
| | | tag_nav = Path(sys.argv[3]).read_text(encoding="utf-8") |
| | | |
| | | |
| | | def fail(message: str) -> None: |
| | | print(f"FAIL: {message}", file=sys.stderr) |
| | | sys.exit(1) |
| | | |
| | | |
| | | def require(condition: bool, message: str) -> None: |
| | | if not condition: |
| | | fail(message) |
| | | |
| | | |
| | | def require_contains(source: str, needle: str, message: str) -> None: |
| | | require(needle in source, message) |
| | | |
| | | |
| | | require_contains( |
| | | quick_search, |
| | | "tagLauncherQuickSearchCompositionChanged", |
| | | "Quick Search must publish IME composition state", |
| | | ) |
| | | require_contains( |
| | | quick_search, |
| | | "if !field.isComposingText, field.stringValue != text", |
| | | "Quick Search updateNSView must not overwrite marked text", |
| | | ) |
| | | require_contains( |
| | | quick_search, |
| | | "if textView.hasMarkedText()", |
| | | "Quick Search doCommandBy must pass marked text commands through to IME", |
| | | ) |
| | | require_contains( |
| | | quick_search, |
| | | "guard !isComposingText else", |
| | | "Quick Search keyDown must pass marked text key events through to IME", |
| | | ) |
| | | require_contains( |
| | | quick_search, |
| | | "guard field?.isComposingText != true else { return }", |
| | | "Quick Search submit fallback must not launch while composing text", |
| | | ) |
| | | |
| | | apply_match = re.search( |
| | | r"func apply\(_ configuration: QuickSearchPanelConfiguration, anchorView: QuickSearchPanelAnchorView\).*?func closePanel\(\)", |
| | | quick_search, |
| | | re.S, |
| | | ) |
| | | require(apply_match is not None, "Quick Search panel apply block is missing") |
| | | apply_body = apply_match.group(0) |
| | | require_contains( |
| | | apply_body, |
| | | "let shouldOrderForInput = shouldOrderPanelForInput(panel)", |
| | | "Quick Search panel must compute whether ordering is actually needed", |
| | | ) |
| | | require_contains( |
| | | apply_body, |
| | | "if shouldOrderForInput", |
| | | "Quick Search panel activate/orderFront must be gated", |
| | | ) |
| | | require_contains( |
| | | quick_search, |
| | | "field.currentEditor() == nil", |
| | | "Quick Search focus retry must stop once the field editor is active", |
| | | ) |
| | | |
| | | require_contains( |
| | | content_view, |
| | | "@State private var quickSearchCompositionActive = false", |
| | | "ContentView must track Quick Search composition state", |
| | | ) |
| | | require_contains( |
| | | content_view, |
| | | "shouldIgnoreQuickSearchDismissDuringComposition", |
| | | "ContentView must ignore mouse/backdrop dismiss while composing text", |
| | | ) |
| | | require_contains( |
| | | content_view, |
| | | "source == QuickSearchDismissSource.mouse || source == QuickSearchDismissSource.backdrop", |
| | | "composition dismiss guard must be scoped to mouse/backdrop dismissals", |
| | | ) |
| | | |
| | | mouse_match = re.search( |
| | | r"override func mouseDown\(with event: NSEvent\).*?@objc private func performActivation", |
| | | tag_nav, |
| | | re.S, |
| | | ) |
| | | require(mouse_match is not None, "TagNavigation mouseDown block is missing") |
| | | mouse_body = mouse_match.group(0) |
| | | for needle, message in [ |
| | | ("let maxTrackingDuration: TimeInterval = 8", "TagNavigation tracking loop must have a hard time limit"), |
| | | ("window.isVisible", "TagNavigation tracking loop must exit when the window is gone or hidden"), |
| | | ("NSApp.isActive", "TagNavigation tracking loop must exit when the app loses active state"), |
| | | ("NSEvent.pressedMouseButtons & (1 << 0) == 0", "TagNavigation tracking loop must recover if mouseUp is lost"), |
| | | ("onReorderEnded()", "TagNavigation abnormal exits must clean up an active reorder"), |
| | | ]: |
| | | require_contains(mouse_body, needle, message) |
| | | |
| | | print("PASS windowserver event safety QA") |
| | | PY |