From b2a06ec5ebc86c0241e635782ae5a032ab5aad0a Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 02 Jul 2026 02:43:51 +0800
Subject: [PATCH] Prepare TagLauncher 8.3.2 App Store candidate

---
 src/Apptag/TagNavigationView.swift          |   23 ++
 src/Apptag/AppGridCollectionView.swift      |   10 
 src/Scripts/usage_tips_qa.sh                |   12 +
 src/Apptag/AppGridSupport.swift             |  155 ++++++++++++++++-
 src/Scripts/windowserver_event_safety_qa.sh |  115 ++++++++++++
 CODEGRAPH.md                                |    6 
 src/Scripts/app_note_bubble_input_qa.sh     |  103 +++++++++++
 src/Apptag/ProEntitlement.swift             |    2 
 src/CHANGELOG.md                            |    6 
 src/Apptag/Info.plist                       |    4 
 src/Apptag/ContentView.swift                |   25 ++
 src/Scripts/pro_feature_gate_qa.sh          |    2 
 src/Apptag/QuickSearch.swift                |   67 ++++++-
 13 files changed, 497 insertions(+), 33 deletions(-)

diff --git a/CODEGRAPH.md b/CODEGRAPH.md
index 1c910f1..ba545ee 100644
--- a/CODEGRAPH.md
+++ b/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 容器保持统一半透明玻璃面板,不做容器内部渐变。
diff --git a/src/Apptag/AppGridCollectionView.swift b/src/Apptag/AppGridCollectionView.swift
index ba2b864..5b7bab6 100644
--- a/src/Apptag/AppGridCollectionView.swift
+++ b/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) {
diff --git a/src/Apptag/AppGridSupport.swift b/src/Apptag/AppGridSupport.swift
index 8ff238e..397a995 100644
--- a/src/Apptag/AppGridSupport.swift
+++ b/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 {
diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index 3fede8e..9f2802e 100644
--- a/src/Apptag/ContentView.swift
+++ b/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 }
diff --git a/src/Apptag/Info.plist b/src/Apptag/Info.plist
index acf4f6c..cd98c63 100644
--- a/src/Apptag/Info.plist
+++ b/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>
diff --git a/src/Apptag/ProEntitlement.swift b/src/Apptag/ProEntitlement.swift
index b0b6f46..4d5fc2b 100644
--- a/src/Apptag/ProEntitlement.swift
+++ b/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]
diff --git a/src/Apptag/QuickSearch.swift b/src/Apptag/QuickSearch.swift
index c7b25eb..f4365c4 100644
--- a/src/Apptag/QuickSearch.swift
+++ b/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)
diff --git a/src/Apptag/TagNavigationView.swift b/src/Apptag/TagNavigationView.swift
index ce079c7..551eaa1 100644
--- a/src/Apptag/TagNavigationView.swift
+++ b/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,
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index b0b9c02..cfe96db 100644
--- a/src/CHANGELOG.md
+++ b/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
 
 - 修复设置窗口在切换语言后,标题栏仍停留在旧语言的问题;标题会在设置窗口出现和语言切换通知时同步刷新
diff --git a/src/Scripts/app_note_bubble_input_qa.sh b/src/Scripts/app_note_bubble_input_qa.sh
new file mode 100755
index 0000000..043552c
--- /dev/null
+++ b/src/Scripts/app_note_bubble_input_qa.sh
@@ -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
diff --git a/src/Scripts/pro_feature_gate_qa.sh b/src/Scripts/pro_feature_gate_qa.sh
index b9d419f..a41712c 100755
--- a/src/Scripts/pro_feature_gate_qa.sh
+++ b/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")
diff --git a/src/Scripts/usage_tips_qa.sh b/src/Scripts/usage_tips_qa.sh
index be9e0d4..ba86461 100755
--- a/src/Scripts/usage_tips_qa.sh
+++ b/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*\}",
diff --git a/src/Scripts/windowserver_event_safety_qa.sh b/src/Scripts/windowserver_event_safety_qa.sh
new file mode 100755
index 0000000..b413056
--- /dev/null
+++ b/src/Scripts/windowserver_event_safety_qa.sh
@@ -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

--
Gitblit v1.9.3