Ariver
2026-07-02 b2a06ec5ebc86c0241e635782ae5a032ab5aad0a
Prepare TagLauncher 8.3.2 App Store candidate
11 files modified
2 files added
530 ■■■■■ changed files
CODEGRAPH.md 6 ●●●● patch | view | raw | blame | history
src/Apptag/AppGridCollectionView.swift 10 ●●●●● patch | view | raw | blame | history
src/Apptag/AppGridSupport.swift 155 ●●●●● patch | view | raw | blame | history
src/Apptag/ContentView.swift 25 ●●●●● patch | view | raw | blame | history
src/Apptag/Info.plist 4 ●●●● patch | view | raw | blame | history
src/Apptag/ProEntitlement.swift 2 ●●● patch | view | raw | blame | history
src/Apptag/QuickSearch.swift 67 ●●●● patch | view | raw | blame | history
src/Apptag/TagNavigationView.swift 23 ●●●●● patch | view | raw | blame | history
src/CHANGELOG.md 6 ●●●●● patch | view | raw | blame | history
src/Scripts/app_note_bubble_input_qa.sh 103 ●●●●● patch | view | raw | blame | history
src/Scripts/pro_feature_gate_qa.sh 2 ●●● patch | view | raw | blame | history
src/Scripts/usage_tips_qa.sh 12 ●●●●● patch | view | raw | blame | history
src/Scripts/windowserver_event_safety_qa.sh 115 ●●●●● patch | view | raw | blame | history
CODEGRAPH.md
@@ -183,6 +183,10 @@
  - 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`
@@ -227,7 +231,7 @@
- 自定义标签颜色是 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 容器保持统一半透明玻璃面板,不做容器内部渐变。
src/Apptag/AppGridCollectionView.swift
@@ -764,7 +764,7 @@
            ]
        ) { [weak self] event in
            guard let self,
                  self.window != nil,
                  event.window === self.window,
                  self.handleUsageTipsMouseEvent(
                    event,
                    triggerButtons: event.type == .leftMouseDown
@@ -778,9 +778,7 @@
        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() {
@@ -1256,9 +1254,7 @@
        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) {
src/Apptag/AppGridSupport.swift
@@ -59,19 +59,20 @@
            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))
@@ -113,13 +114,145 @@
                .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 {
src/Apptag/ContentView.swift
@@ -456,6 +456,7 @@
    @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
@@ -588,6 +589,7 @@
            .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)
@@ -864,6 +866,7 @@
        quickSearchVisible = true
        quickSearchOpenedAt = Date()
        quickSearchQuery = ""
        quickSearchCompositionActive = false
        quickSearchManualSelection = false
        quickSearchErrorMessage = nil
        quickSearchFocusToken &+= 1
@@ -907,10 +910,14 @@
        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
@@ -946,6 +953,16 @@
            "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
    }
@@ -2441,6 +2458,14 @@
        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 }
src/Apptag/Info.plist
@@ -19,7 +19,7 @@
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleShortVersionString</key>
    <string>8.3.1</string>
    <string>8.3.2</string>
    <key>CFBundleURLTypes</key>
    <array>
        <dict>
@@ -32,7 +32,7 @@
        </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>
src/Apptag/ProEntitlement.swift
@@ -162,7 +162,7 @@
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]
src/Apptag/QuickSearch.swift
@@ -6,6 +6,7 @@
    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")
}
@@ -849,12 +850,15 @@
                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() {
@@ -899,6 +903,10 @@
            self.panel = panel
            self.hostingView = hostingView
            return panel
        }
        private func shouldOrderPanelForInput(_ panel: QuickSearchPanel) -> Bool {
            !panel.isVisible || !panel.isKeyWindow || NSApp.keyWindow !== panel
        }
        private func scheduleDeferredApply(
@@ -1651,9 +1659,10 @@
    }
    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
@@ -1671,10 +1680,13 @@
                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)
            }
        }
@@ -1688,22 +1700,36 @@
        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)
@@ -1729,10 +1755,33 @@
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)
src/Apptag/TagNavigationView.swift
@@ -498,16 +498,37 @@
        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,
src/CHANGELOG.md
@@ -2,6 +2,12 @@
## [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
- 修复设置窗口在切换语言后,标题栏仍停留在旧语言的问题;标题会在设置窗口出现和语言切换通知时同步刷新
src/Scripts/app_note_bubble_input_qa.sh
New file
@@ -0,0 +1,103 @@
#!/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
src/Scripts/pro_feature_gate_qa.sh
@@ -69,7 +69,7 @@
    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")
src/Scripts/usage_tips_qa.sh
@@ -316,6 +316,11 @@
    "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",
@@ -328,6 +333,13 @@
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*\}",
src/Scripts/windowserver_event_safety_qa.sh
New file
@@ -0,0 +1,115 @@
#!/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