#!/usr/bin/env bash
|
set -euo pipefail
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
python3 - "$ROOT_DIR" <<'PY'
|
import re
|
import sys
|
from pathlib import Path
|
|
root = Path(sys.argv[1])
|
app_dir = root / "Apptag"
|
|
|
def fail(message: str) -> None:
|
raise SystemExit(f"FAIL: {message}")
|
|
|
def read(name: str) -> str:
|
path = app_dir / name
|
try:
|
return path.read_text(encoding="utf-8")
|
except Exception as error:
|
fail(f"could not read {path}: {error}")
|
|
|
def require(source: str, needle: str, message: str) -> None:
|
if needle not in source:
|
fail(message)
|
|
|
def function_body(source: str, signature: str) -> str:
|
start = source.find(signature)
|
if start == -1:
|
fail(f"missing function signature: {signature}")
|
brace = source.find("{", start)
|
if brace == -1:
|
fail(f"missing function body for: {signature}")
|
depth = 0
|
for index in range(brace, len(source)):
|
char = source[index]
|
if char == "{":
|
depth += 1
|
elif char == "}":
|
depth -= 1
|
if depth == 0:
|
return source[brace + 1:index]
|
fail(f"unterminated function body for: {signature}")
|
|
|
pro = read("ProEntitlement.swift")
|
data_layer = read("DataLayer.swift")
|
app = read("ApptagApp.swift")
|
theme = read("AppGridTheme.swift")
|
preferences = read("PreferencesView.swift")
|
content = read("ContentView.swift")
|
access_views = read("ProAccessViews.swift")
|
|
for case in [
|
"premiumThemes",
|
"layoutImport",
|
"layoutExport",
|
"customTagColors",
|
"unlimitedNotes",
|
"persistentAppSorting",
|
"customHotkeys",
|
]:
|
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 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")
|
require(pro, 'static let premiumDisplayModes: Set<String> = ["coloredContainer", "coloredGridContainer"]', "colored container/grid display modes must be Pro-only")
|
require(pro, "static func effectiveDisplayMode(for storedDisplayMode: String) -> String", "effective display mode gate is missing")
|
require(pro, "func startDisplayModePreview(for displayMode: String)", "Pro display-mode trial preview is missing")
|
require(pro, "Product.products(for: [ProEntitlementConfig.lifetimeProductID])", "StoreKit product lookup is missing")
|
require(pro, "Transaction.currentEntitlements", "current entitlement restore/read path is missing")
|
require(pro, "Transaction.updates", "transaction update listener is missing")
|
require(pro, "AppTransaction.shared", "legacy original app version path is missing")
|
require(pro, "appTransaction.originalAppVersion", "legacy originalAppVersion check is missing")
|
require(pro, "appTransaction.originalPurchaseDate", "legacy originalPurchaseDate fallback is missing")
|
require(pro, "SemanticVersion(ProEntitlementConfig.firstFreeProVersion)", "legacy semantic version cutoff is missing")
|
require(pro, "ProEntitlementSnapshotStore", "sync entitlement snapshot for data-layer policy is missing")
|
require(pro, "manualNonEmptyNoteCount", "note quota must count existing manual notes")
|
require(pro, "manualNonEmptyNotePaths", "note quota must avoid double-counting existing notes")
|
require(pro, "case .premiumThemes, .layoutImport, .layoutExport, .customTagColors, .unlimitedNotes, .persistentAppSorting, .customHotkeys:", "custom tag colors and custom hotkeys must be unlocked only by the Pro access state")
|
require(pro, "NotificationCenter.default.post(name: .tagLauncherProEntitlementChanged", "Pro entitlement changes must notify hotkey registration")
|
|
effective_theme = function_body(pro, "static func effectiveTheme(for storedTheme: AppGridTheme) -> AppGridTheme")
|
require(effective_theme, "return .defaultLight", "locked premium theme must fall back to defaultLight")
|
require(effective_theme, "canUseTheme(storedTheme)", "effectiveTheme must check Pro theme entitlement")
|
|
effective_display_mode = function_body(pro, "static func effectiveDisplayMode(for storedDisplayMode: String) -> String")
|
require(effective_display_mode, "snapshot.previewDisplayMode", "effective display mode must prefer active preview")
|
require(effective_display_mode, "canUseDisplayMode(storedDisplayMode)", "effective display mode must check Pro entitlement")
|
require(effective_display_mode, "fallbackDisplayMode(for: storedDisplayMode)", "locked colored display mode must fall back to its free variant")
|
|
require(app, "ProEntitlementCenter.shared.start()", "App startup does not start Pro entitlement center")
|
require(app, "observeProEntitlementChanges()", "App startup must observe Pro entitlement changes for custom hotkeys")
|
require(app, "applyCustomHotkey(_ hotkey: LauncherHotkey, for kind: LauncherHotkeyKind)", "AppDelegate custom hotkey save entry is missing")
|
require(app, "guard ProEntitlementPolicy.isUnlocked(.customHotkeys) else", "custom hotkey save/restore must be hard-gated by Pro")
|
require(app, "LauncherHotkeySettings.effectiveHotkey(for: kind)", "global hotkey registration must use the entitlement-aware effective shortcut")
|
|
require(theme, "var requiresPro: Bool", "AppGridTheme does not expose Pro requirement")
|
require(theme, "ProEntitlementConfig.freeThemes.contains(self)", "theme Pro requirement must use central free theme set")
|
require(theme, "static var premiumThemes: [AppGridTheme]", "theme premium list is missing")
|
|
export_body = function_body(data_layer, "static func exportTo(_ url: URL) throws")
|
require(export_body, "try ProEntitlementPolicy.requireUnlocked(.layoutExport)", "layout export is not locked at data layer")
|
if export_body.find("try ProEntitlementPolicy.requireUnlocked(.layoutExport)") > export_body.find("data.write"):
|
fail("layout export lock must run before writing data")
|
|
import_body = function_body(data_layer, "static func importFrom(_ url: URL) throws -> Store")
|
require(import_body, "try ProEntitlementPolicy.requireUnlocked(.layoutImport)", "layout import is not locked at data layer")
|
if import_body.find("try ProEntitlementPolicy.requireUnlocked(.layoutImport)") > import_body.find("Data(contentsOf: url)"):
|
fail("layout import lock must run before reading user-selected file")
|
|
reorder_body = function_body(data_layer, "static func reorderApps(inContainer containerID: String, orderedPaths: [String])")
|
require(reorder_body, "guard ProEntitlementPolicy.isUnlocked(.persistentAppSorting) else { return }", "app sorting persistence is not locked")
|
if reorder_body.find("guard ProEntitlementPolicy.isUnlocked(.persistentAppSorting) else { return }") > reorder_body.find("var store = TagDatabase.load()"):
|
fail("app sorting lock must run before loading/writing store")
|
|
note_body = function_body(data_layer, "static func setAppNote(_ note: String, for path: String) -> ProNoteSaveDecision")
|
require(note_body, "ProEntitlementPolicy.noteSaveDecision", "note save does not check Pro quota")
|
require(note_body, "guard case .allow = decision else", "blocked note save must return without mutating store")
|
if note_body.find("guard case .allow = decision else") > note_body.find("let previousStore = store"):
|
fail("note quota decision must happen before store mutation snapshot")
|
|
require(data_layer, "var customColor: TagCustomColor? = nil", "tag definitions must store an optional custom color")
|
require(data_layer, "struct TagCustomColor: Codable, Equatable", "custom tag color must be Codable and comparable")
|
require(data_layer, "static func nsColor(for index: Int, customColor: TagCustomColor?) -> NSColor", "tag rendering must prefer custom colors with base-color fallback")
|
set_color_body = function_body(data_layer, "static func setColor(_ color: Int, for tag: String)")
|
require(set_color_body, "tagDef.customColor = nil", "selecting a base tag color must clear the custom color")
|
set_custom_color_body = function_body(data_layer, "static func setCustomColor(_ customColor: TagCustomColor, for tag: String) -> Bool")
|
require(set_custom_color_body, "guard ProEntitlementPolicy.isUnlocked(.customTagColors) else", "custom tag color writes must be hard-gated by Pro")
|
if set_custom_color_body.find("guard ProEntitlementPolicy.isUnlocked(.customTagColors) else") > set_custom_color_body.find("var store = TagDatabase.load()"):
|
fail("custom tag color lock must run before loading/writing store")
|
|
export_ui = function_body(preferences, "private func exportTags()")
|
require(export_ui, "guard proEntitlement.isUnlocked else", "export UI must prompt Pro before file panel")
|
require(export_ui, "presentSettingsProPrompt(for: .layoutExport)", "export UI prompt feature is missing")
|
if export_ui.find("guard proEntitlement.isUnlocked else") > export_ui.find("prepareDataFilePanelPresentation()"):
|
fail("export UI must check Pro before opening file panel")
|
|
import_ui = function_body(preferences, "private func importTags()")
|
require(import_ui, "guard proEntitlement.isUnlocked else", "import UI must prompt Pro before file panel")
|
require(import_ui, "presentSettingsProPrompt(for: .layoutImport)", "import UI prompt feature is missing")
|
if import_ui.find("guard proEntitlement.isUnlocked else") > import_ui.find("prepareDataFilePanelPresentation()"):
|
fail("import UI must check Pro before opening file panel")
|
|
require(preferences, "ProStatusPill(text: tr(\"pro.card.badge\"), style: .locked, compact: true)", "locked data actions must show compact Pro pill")
|
require(preferences, "presentSettingsProPrompt(for: .premiumThemes)", "premium theme selection prompt is missing")
|
require(preferences, "presentSettingsProPrompt(for: .customHotkeys)", "custom hotkey Pro prompt is missing")
|
require(preferences, "presentSettingsProPrompt(for: .customTagColors)", "custom tag color Pro prompt is missing")
|
require(preferences, "private var settingsFixedProHeader: some View", "Settings pages must share one fixed-position Pro guidance header")
|
require(preferences, "private var settingsProHeaderText: String", "fixed Pro guidance header must switch copy by selected tab")
|
require(preferences, "private var settingsProHeaderFeature: ProFeature", "fixed Pro guidance header must route unlock prompts by selected tab")
|
require(preferences, "private var shouldCenterUnlockedIdentityHeader: Bool", "Language/Pro/About tabs need the centered unlocked Pro identity header")
|
center_identity_body = function_body(preferences, "private var shouldCenterUnlockedIdentityHeader: Bool")
|
require(center_identity_body, "guard proEntitlement.isUnlocked else { return false }", "centered Pro identity header must only apply after Pro unlock")
|
require(center_identity_body, "case .language, .pro, .about:", "centered Pro identity header must apply to Language, Pro, and About tabs")
|
require(center_identity_body, "case .general, .theme, .hotkeys, .tags, .data:", "centered Pro identity header must not affect the feature-specific settings tabs")
|
settings_header_text_body = function_body(preferences, "private var settingsProHeaderText: String")
|
require(settings_header_text_body, "case .language, .about:", "Language/About tabs must have an explicit fixed-header text branch")
|
require(settings_header_text_body, 'return ""', "Language/About tabs must not show the general Pro guidance sentence")
|
if "case .general, .language, .about:" in settings_header_text_body:
|
fail("Language/About tabs must not reuse the General tab Pro guidance sentence")
|
require(preferences, "private func proHeaderStatusLabel(compact: Bool = false) -> some View", "fixed Pro guidance header must render the free status as plain text")
|
require(preferences, 'Text(tr("settings.proStatus.freeUser"))', "Free status must render as the full current-user sentence instead of a Free pill")
|
require(preferences, "private var shouldShowSettingsFixedProHeader: Bool", "all settings tabs must use the fixed Pro header gate")
|
require(function_body(preferences, "private var shouldShowSettingsFixedProHeader: Bool"), "true", "fixed Pro header must be shown on Language and About tabs too")
|
if "selectedTab != .language && selectedTab != .about" in preferences:
|
fail("fixed Pro header must no longer be hidden on Language and About tabs")
|
require(preferences, "if shouldShowSettingsFixedProHeader", "Settings body must conditionally render the fixed Pro header")
|
fixed_header_body = function_body(preferences, "private var settingsFixedProHeader: some View")
|
require(fixed_header_body, "if shouldCenterUnlockedIdentityHeader", "fixed Pro header must use the centered unlocked identity branch")
|
require(fixed_header_body, 'text: tr("pro.status.unlocked")', "centered Pro identity header must start with the Pro unlocked pill")
|
require(fixed_header_body, "style: .unlocked", "centered Pro identity pill must use the unlocked style")
|
require(fixed_header_body, 'Text(tr("settings.proStatus.proUser"))', "centered Pro identity header must render the current Pro user sentence")
|
if fixed_header_body.find("if shouldCenterUnlockedIdentityHeader") > fixed_header_body.find('Text(tr("settings.proStatus.proUser"))'):
|
fail("centered Pro identity sentence must be inside the centered branch")
|
if fixed_header_body.count("Spacer(minLength: 0)") < 2:
|
fail("centered Pro identity header must be horizontally centered with leading and trailing spacers")
|
require(fixed_header_body, "if !proEntitlement.isUnlocked", "fixed Pro header must branch on unlocked state")
|
require(fixed_header_body, 'Button(tr("pro.card.restore"))', "fixed Pro header must keep restore purchase for free users")
|
if fixed_header_body.find("if !proEntitlement.isUnlocked") > fixed_header_body.find('Button(tr("pro.card.restore"))'):
|
fail("fixed Pro header restore purchase button must only render for users who have not unlocked Pro")
|
purchase_guidance_body = function_body(preferences, "private func proPurchaseGuidanceLine(")
|
require(purchase_guidance_body, "if !proEntitlement.isUnlocked", "purchase guidance restore actions must be hidden after Pro unlock")
|
for unlocked_benefit_key in [
|
"settings.proBenefit.general",
|
"pro.theme.status.unlocked",
|
"settings.proBenefit.hotkeys",
|
"settings.proBenefit.tags",
|
"settings.proBenefit.data",
|
]:
|
require(preferences, unlocked_benefit_key, f"missing unlocked Pro benefit line for {unlocked_benefit_key}")
|
for purchase_guidance_key in [
|
"settings.proGuide.general",
|
"settings.proGuide.hotkeys",
|
"settings.proGuide.tags",
|
"settings.proGuide.data",
|
]:
|
require(preferences, purchase_guidance_key, f"missing Pro purchase guidance line for {purchase_guidance_key}")
|
require(preferences, 'freeStatusKey: "settings.proStatus.unavailableFeature"', "Pro comparison locked rows must show unavailable for Free users")
|
require(preferences, 'freeStatusKey: "settings.proStatus.previewFiveMinutes"', "Pro comparison preview rows must show the 5-minute preview state for Free users")
|
require(preferences, 'proLockedStatusKey: "settings.proStatus.lockedFeature"', "Pro comparison Pro column must show supported before purchase")
|
require(preferences, 'proUnlockedStatusKey: "settings.proStatus.unlockedFeature"', "Pro comparison Pro column must show unlocked after purchase")
|
if preferences.count("freeStyle: .neutral") < 6 or preferences.count("proLockedStyle: .neutral") < 6 or preferences.count("proUnlockedStyle: .neutral") < 6:
|
fail("Pro comparison status chips must use the neutral style without crown icons")
|
if 'freeStatusKey: "settings.proStatus.limitedFeature"' in preferences or 'freeStatusKey: "settings.proStatus.unsupportedFeature"' in preferences:
|
fail("Pro comparison table must not use the old Limited/Unsupported status for locked rows")
|
if "settingsProStatusOverview(" in preferences or "SettingsProOverviewFeature" in preferences:
|
fail("settings pages must not repeat the bulky Pro status overview card across tabs")
|
for removed_about_pro_ui in [
|
"proStatusCard",
|
"settingsProComparisonTable",
|
"SettingsProComparisonFeature",
|
]:
|
if removed_about_pro_ui in preferences:
|
fail(f"about page must not render the removed Pro summary/comparison UI: {removed_about_pro_ui}")
|
require(preferences, "case pro", "Settings must include a dedicated Pro tab")
|
require(preferences, 'case .pro: return "pro.card.badge"', "Pro tab title must use the existing Pro label")
|
require(preferences, 'case .pro: return "crown"', "Pro tab must use the line-style crown icon")
|
for filled_tab_icon in ["paintpalette.fill", "tag.fill", "externaldrive.fill"]:
|
if filled_tab_icon in preferences:
|
fail(f"settings tab icons must use line style, found filled icon: {filled_tab_icon}")
|
require(preferences, "HStack(spacing: 6)", "settings tab bar spacing must be tightened for 8 tabs")
|
require(preferences, ".frame(width: 94, height: 72)", "settings tab buttons must be tightened for 8 tabs")
|
require(preferences, "case .pro:", "Settings tab switch must render the Pro tab")
|
require(content, 'static let pro = "pro"', "Settings tab target must include the Pro tab")
|
require(app, "openPreferences(targetTab: SettingsTabTarget.pro)", "status menu Pro CTA must open the Pro settings tab")
|
require(preferences, 'selectedTab == .pro', "fixed Pro header must render the Pro tab user status state")
|
require(preferences, "proTabComparisonTable(width: dataPanelWidth)", "Pro tab must render the Free-vs-Pro comparison table")
|
require(preferences, "private struct ProTabComparisonFeature", "Pro tab comparison row model is missing")
|
comparison_body = function_body(preferences, "private func proTabComparisonTable(width: CGFloat)")
|
if comparison_body.count(".frame(width: 118, alignment: .leading)") < 4:
|
fail("Pro comparison Free/Pro columns and status chips must be left-aligned")
|
if ".frame(width: 118, alignment: .center)" in comparison_body:
|
fail("Pro comparison status columns must not be centered")
|
if 'Text(tr("settings.proCompare.title"))' in preferences:
|
fail("Pro tab comparison table must not render the removed title")
|
require(preferences, 'Text(tr("settings.proCompare.free"))', "Pro tab comparison table free column is missing")
|
require(preferences, 'Text(tr("settings.proCompare.pro"))', "Pro tab comparison table Pro column is missing")
|
require(preferences, 'Image(systemName: "crown")', "Pro tab Pro column header must show a crown icon before the text")
|
if 'Text(tr("settings.language"))' in preferences:
|
fail("Language tab must not render the removed header/title block")
|
require(preferences, "centeredSettingsScrollContent<Content: View>", "settings pages with sparse content must use the shared global centering container")
|
require(preferences, "centeredSettingsScrollContent(width: settingsContentWidth)", "language picker matrix must use the shared centered settings container")
|
if preferences.count("centeredSettingsScrollContent(width: dataPanelWidth)") < 2:
|
fail("Pro and About pages must both use the shared centered settings container")
|
require(preferences, ".frame(minHeight: proxy.size.height, alignment: .center)", "centered settings content must be vertically centered in the visible area")
|
if "topLeadingAccessory: AnyView(" in preferences:
|
fail("Tags page must not render a second Pro guidance row inside the tag editor")
|
if preferences.count("ScrollView(.vertical, showsIndicators: true)") < 3:
|
fail("hotkeys/data/about pages must use vertical scroll containers to avoid clipping")
|
require(preferences, "NSEvent.addLocalMonitorForEvents(matching: .keyDown)", "custom hotkey recording must be local to Settings")
|
require(preferences, "proEntitlement.startThemePreview(for: theme, tuning:", "premium theme free preview is missing")
|
require(preferences, "proEntitlement.stopThemePreview()", "theme preview cleanup is missing")
|
require(preferences, "proEntitlement.startDisplayModePreview(for: mode)", "colored display mode free preview is missing")
|
require(preferences, "displayModeOptionAccessory(for mode: String)", "colored display mode Pro badge/countdown accessory is missing")
|
require(preferences, "isSelected: effectiveDisplayMode ==", "display mode buttons must select by effective display mode")
|
require(preferences, ".frame(width: 64, alignment: .trailing)", "display mode buttons must reserve a fixed accessory slot")
|
require(pro, "func remainingSeconds(at date: Date = Date()) -> Int", "theme preview remaining-seconds helper is missing")
|
require(pro, "ceil(endsAt.timeIntervalSince(date))", "theme preview countdown must derive from endsAt")
|
require(preferences, "pro.theme.status.previewCountdown", "theme preview status countdown localization is missing")
|
require(preferences, "formatThemePreviewCountdown(seconds:", "theme preview countdown formatter is missing")
|
require(preferences, ".countdown(text: themePreviewCountdownText ?? \"00:00\")", "current preview theme card must show countdown")
|
require(preferences, ".onReceive(themePreviewCountdownTimer)", "theme preview countdown UI timer is missing")
|
require(preferences, ".font(.system(size: 10, weight: .semibold, design: .monospaced))", "theme preview card countdown must use monospaced digits")
|
|
require(content, "@ObservedObject private var proEntitlement = ProEntitlementCenter.shared", "ContentView does not observe Pro entitlement")
|
require(content, "ProEntitlementPolicy.effectiveTheme(for: appGridTheme)", "ContentView does not apply effective Pro theme")
|
require(content, "ProEntitlementPolicy.effectiveDisplayMode(for: displayMode)", "ContentView does not apply effective Pro display mode")
|
require(content, "displayMode: renderedDisplayMode", "AppGrid does not render the effective display mode")
|
require(content, "presentProPrompt(for: .unlimitedNotes", "note quota prompt is missing from AppGrid")
|
require(content, "tr(\"pro.sorting.previewToast\")", "free sorting preview toast is missing")
|
require(content, "if let preview = proEntitlement.themePreviewState", "theme preview acceptance path is missing")
|
require(content, "ProUpgradePromptView(", "AppGrid Pro upgrade prompt view is missing")
|
|
require(access_views, "struct ProStatusPill", "ProStatusPill view is missing")
|
require(access_views, "struct ProUpgradePromptView", "ProUpgradePromptView is missing")
|
require(access_views, "let onUnlock: () -> Void", "Pro upgrade prompt purchase callback is missing")
|
require(access_views, "let onRestore: () -> Void", "Pro upgrade prompt restore callback is missing")
|
if "localizedCaseInsensitiveContains" in access_views:
|
fail("ProStatusPill must not hide the crown based on localized text content")
|
require(content, "onUnlock: { proEntitlement.purchasePro() }", "AppGrid Pro prompt is not wired to purchase action")
|
require(content, "onRestore: { proEntitlement.restorePurchases() }", "AppGrid Pro prompt is not wired to restore action")
|
|
tag_editor = read("TagEditorView.swift")
|
require(tag_editor, "ColorPicker(", "Pro custom tag colors must use a native macOS color picker path")
|
require(tag_editor, "onLockedCustomColor", "locked custom color entry must route to the Pro prompt")
|
require(tag_editor, "TagEditor.setCustomColor", "tag editor must persist Pro custom colors")
|
require(tag_editor, "tagCustomColors.removeValue(forKey: tagName)", "base color selection must remove existing custom color")
|
require(content, "tagCustomColors = snapshot.tagDefinitions.compactMapValues { $0.customColor }", "AppGrid snapshots must propagate custom tag colors")
|
require(content, "customColor: tagCustomColors[$0.name]", "tag navigation must receive custom tag colors")
|
app_grid = read("AppGridCollectionView.swift")
|
tag_nav = read("TagNavigationView.swift")
|
require(app_grid, "let tagCustomColors: [String: TagCustomColor]", "AppGrid must accept custom tag colors")
|
require(app_grid, "func tagColor(for groupName: String) -> NSColor", "AppGrid must resolve custom tag color per group")
|
require(tag_nav, "let customColor: TagCustomColor?", "Tag navigation items must carry custom colors")
|
require(tag_nav, "TagColor.nsColor(for: item.colorIndex, customColor: item.customColor)", "Tag navigation must render custom colors")
|
|
print("PASS Pro feature gate QA: StoreKit entitlement, legacy unlock, theme gating, import/export locks, 3-note quota, custom tag colors, custom hotkeys, sorting preview, and Pro UI prompts are wired")
|
PY
|