#!/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",
|
"unlimitedNotes",
|
"persistentAppSorting",
|
]:
|
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 = 5", "free note limit must remain explicit")
|
require(pro, "static let freeThemes: Set<AppGridTheme> = [.defaultLight, .black]", "free theme set must be default/black")
|
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")
|
|
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")
|
|
require(app, "ProEntitlementCenter.shared.start()", "App startup does not start Pro entitlement center")
|
|
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")
|
|
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, "proEntitlement.startThemePreview(for: theme, tuning:", "premium theme free preview is missing")
|
require(preferences, "proEntitlement.stopThemePreview()", "theme preview cleanup is missing")
|
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, "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")
|
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")
|
|
print("PASS Pro feature gate QA: StoreKit entitlement, legacy unlock, theme gating, import/export locks, note quota, sorting preview, and Pro UI prompts are wired")
|
PY
|