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