← Files OneSignalARCHIVED FILE
skills/setup/scripts/verify_integration.py
35.2 KB · Sep 30, 2026 · 22:50 UTC
#!/usr/bin/env python3
"""Deterministic structural verification of a written OneSignal integration.
The back slice of the "deterministic sandwich": after the agent writes code, run
checks a compiler cannot make. A build is a weak signal in both directions —
fabricated integrations compile (a dead NotificationCenter observer type-checks),
and correct ones fail (iOS pbxproj handed to the human; the Kotlin-floor blocker).
This asserts the constraint-following facts the plugin actually gets wrong.
Checks are grep/structure-level and platform-aware. Each returns pass/fail with a
severity. `error` failures set a nonzero exit; `warn` are advisory.
Usage:
verify_integration.py <project_dir> --platform android|ios|web|expo|react-native|flutter|cordova|capacitor|unity [--app-id UUID]
Exit: 0 all error-level checks pass; 1 an error-level check failed; 2 usage.
"""
import argparse
import json
import os
import re
import sys
EXCLUDE = {"node_modules", "build", ".gradle", "Pods", ".git", "dist", "DerivedData",
"__pycache__", ".dart_tool", "out"}
CODE_EXTS = {".kt", ".java", ".swift", ".m", ".ts", ".tsx", ".js", ".jsx", ".dart",
".gradle", ".kts", ".html", ".xml", ".rb", ".ruby", ".plist", ".json", ".toml"}
# .toml is needed for gradle/libs.versions.toml (version-catalog dependency decls).
# .json is needed for package.json / app.json (Expo/RN config, npm version ranges).
# A OneSignal dependency line carrying a range/dynamic version instead of a pin.
RANGE_PATTERNS = [
re.compile(r"OneSignal[\"']?\s*[,:]\s*[\"']?\[[^\]]*,"), # gradle [x, y]
re.compile(r"onesignal[^\n]*:\s*[\d.]+\s*\+"), # gradle x.y.+
# Gradle version catalog (.toml): declares with `key = "value"`, which the
# `:`-based gradle patterns can't see. Require the quoted-string context so a
# JS array assigned to an onesignal-named var (`= ["a", ...]`) isn't mistaken
# for a range. Covers both a [versions] alias and an inline [libraries] version.
re.compile(r"onesignal[^\n]*=\s*[\"']\[[^\]]*,", re.I), # catalog "[x, y]"
re.compile(r"onesignal[^\n]*=\s*[\"'][\d.]+\s*\+", re.I), # catalog "x.y.+"
re.compile(r"\.package\([^)]*onesignal[^)]*upToNext(?:Major|Minor)", re.I), # SPM OneSignal range fn
re.compile(r"\.package\([^)]*onesignal[^)]*from\s*:", re.I), # SPM OneSignal from:
re.compile(r"[\"'][^\"'\n]*onesignal[^\"'\n]*[\"']\s*:\s*[\"'][~^]"), # npm ^/~ in package.json
re.compile(r"onesignal_flutter:\s*[\"']?[\^~]"), # pubspec ^/~
re.compile(r"pod\s+['\"]OneSignal[^'\"]*['\"]\s*,\s*['\"]\s*[~>]"), # cocoapods ~>
]
def walk_files(root):
for dp, dn, fn in os.walk(root):
dn[:] = [d for d in dn if d not in EXCLUDE]
for f in fn:
if os.path.splitext(f)[1] in CODE_EXTS or f in ("Podfile", "Package.swift",
"project.pbxproj"):
yield os.path.join(dp, f)
def read(fp):
try:
return open(fp, encoding="utf-8", errors="ignore").read()
except Exception:
return ""
def grep(root, pattern, files=None):
"""Return list of (relpath, lineno) where pattern matches."""
rx = pattern if hasattr(pattern, "search") else re.compile(pattern)
hits = []
for fp in (files if files is not None else walk_files(root)):
for i, line in enumerate(read(fp).splitlines(), 1):
if rx.search(line):
hits.append((os.path.relpath(fp, root), i))
return hits
# Range forms: `from:`, `.upToNextMajor/Minor`, or the `"x"..<"y"` / `"x"..."y"`
# operators. The operator alternative is bounded by quotes on both sides so it
# matches a version range, not the `...` in an abbreviated URL path.
_SPM_RANGE_KW = re.compile(r"from\s*:|upToNext(?:Major|Minor)|\"\s*\.\.[.<]\s*\"", re.I)
def spm_range_hits(root, files=None):
"""Multi-line-aware scan for a OneSignal SPM `.package(...)` call that carries a
range (from:/upToNext/..<) instead of an exact pin. The per-line RANGE_PATTERNS
miss the Xcode-generated style where url and version sit on separate lines, so
parse each `.package(` call as a balanced-paren span across newlines."""
hits = []
for fp in (files if files is not None else walk_files(root)):
if os.path.basename(fp) != "Package.swift":
continue
text = read(fp)
i = 0
while True:
j = text.find(".package(", i)
if j < 0:
break
depth, end = 0, j + len(".package(") - 1
for end in range(end, len(text)):
if text[end] == "(":
depth += 1
elif text[end] == ")":
depth -= 1
if depth == 0:
break
call = text[j:end + 1]
if "onesignal" in call.lower() and _SPM_RANGE_KW.search(call):
hits.append((os.path.relpath(fp, root), text.count("\n", 0, j) + 1))
i = end + 1
return hits
_PBX_REPO_URL = re.compile(r"repositoryURL\s*=\s*\"?([^\";\n]+)")
_PBX_REQ_KIND = re.compile(r"kind\s*=\s*(\w+)")
def pbxproj_spm_range_hits(root, files=None):
"""The Xcode GUI writes an SPM dependency into project.pbxproj, not
Package.swift, so `spm_range_hits` never sees it. Each dependency is an
XCRemoteSwiftPackageReference entry with a repositoryURL and a nested
`requirement = { kind = ...; };` block; only `kind = exactVersion` is a pin.
The pbxproj is a NeXT-style plist — a balanced-brace scan over each entry is
enough, no full plist parser. Scoped to a OneSignal repositoryURL so a
third-party range does not trip the check."""
hits = []
for fp in (files if files is not None else walk_files(root)):
if os.path.basename(fp) != "project.pbxproj":
continue
text = read(fp)
for m in re.finditer(r"isa\s*=\s*XCRemoteSwiftPackageReference\b", text):
start = text.rfind("{", 0, m.start())
if start < 0:
continue
depth = 0
for end in range(start, len(text)):
if text[end] == "{":
depth += 1
elif text[end] == "}":
depth -= 1
if depth == 0:
break
block = text[start:end + 1]
url = _PBX_REPO_URL.search(block)
if not url or "onesignal" not in url.group(1).lower():
continue
kind = _PBX_REQ_KIND.search(block)
if kind and kind.group(1) != "exactVersion":
hits.append((os.path.relpath(fp, root), text.count("\n", 0, start) + 1))
return hits
class Checks:
def __init__(self, root, platform, app_id):
self.root, self.platform, self.app_id = root, platform, app_id
self.results = []
def add(self, name, passed, severity, detail=""):
self.results.append({"check": name, "passed": passed, "severity": severity, "detail": detail})
# ---- universal ----
def _range_scan_files(self):
files = list(walk_files(self.root))
if self.platform == "expo":
# Expo CNG: `npx expo prebuild` generates ios/ and android/, and the
# OneSignal config plugin writes a Podfile range (>= x, < y) there by
# design. Those dirs are regenerated build artifacts, not manifests
# the customer pins, so a range inside them is not a violation.
# Match any path segment, not only the top level, so a nested app
# root (e.g. a monorepo's packages/mobile/ios) is covered too.
files = [fp for fp in files
if not any(seg in ("ios", "android") for seg in
os.path.relpath(fp, self.root).split(os.sep)[:-1])]
return files
def no_version_range(self):
files = self._range_scan_files()
hits = []
for rx in RANGE_PATTERNS:
hits += grep(self.root, rx, files=files)
hits += spm_range_hits(self.root, files=files)
hits += pbxproj_spm_range_hits(self.root, files=files)
hits = sorted(set(hits)) # a single-line SPM range matches both paths
self.add("no_version_range", not hits, "error",
"" if not hits else f"range/dynamic version at {hits[:5]} — must be an exact pin")
def managed_marker_present(self):
hits = grep(self.root, r"onesignal:managed")
self.add("managed_marker_present", bool(hits), "warn",
"" if hits else "no onesignal:managed marker found — skill may not have engaged, or markers were dropped")
def no_placeholder_app_id(self):
hits = grep(self.root, r"YOUR_ONESIGNAL_APP_ID|<APP_ID>")
self.add("no_placeholder_app_id", not hits, "error",
"" if not hits else f"unreplaced placeholder App ID at {hits[:5]}")
def app_id_present(self):
if not self.app_id:
self.add("app_id_present", True, "warn", "no --app-id given; skipped exact-ID check")
return
hits = grep(self.root, re.compile(re.escape(self.app_id)))
self.add("app_id_present", bool(hits), "error",
"" if hits else f"expected App ID {self.app_id} not found in any init call")
def no_deprecated_addoutcome(self):
hits = grep(self.root, r"\.addOutcome")
self.add("no_deprecated_addoutcome", not hits, "error",
"" if not hits else f"deprecated addOutcome API at {hits[:5]} — use custom events")
def no_committed_secrets(self):
# light check; scan_secrets.py is the fuller tool (Step 8)
rx = re.compile(r"os_v2_(?:app|org)_[a-z0-9]{20,}|-----BEGIN (?:EC )?PRIVATE KEY-----", re.I)
hits = grep(self.root, rx)
self.add("no_committed_secrets", not hits, "error",
"" if not hits else f"secret-shaped string at {hits[:3]} — see scan_secrets.py")
# ---- android ----
def android_init_in_application(self):
init_hits = grep(self.root, r"OneSignal\.initWithContext")
if not init_hits:
self.add("android_init_in_application", False, "error", "no OneSignal.initWithContext found")
return
# the file holding init (or a sibling it calls) should be an Application subclass
app_class = grep(self.root, r":\s*Application\(\)|extends\s+Application")
self.add("android_init_in_application", bool(app_class), "error",
"" if app_class else "init present but no Application subclass found (init likely in Activity — breaks cold start)")
def android_manifest_registers_app(self):
manifests = [fp for fp in walk_files(self.root) if os.path.basename(fp) == "AndroidManifest.xml"]
hit = any(re.search(r"<application[^>]*android:name=", read(fp)) for fp in manifests)
self.add("android_manifest_registers_app", hit, "error",
"" if hit else "no <application android:name=...> — Application subclass not registered")
def android_manifest_no_node_replace(self):
node_replace = re.compile(r"""tools:node\s*=\s*['"]replace['"]""")
bad = []
for fp in walk_files(self.root):
if os.path.basename(fp) != "AndroidManifest.xml":
continue
text = re.sub(r"<!--.*?-->", "", read(fp), flags=re.S)
for m in re.finditer(r"<application\b([^>]*)>", text, re.I | re.S):
if node_replace.search(m.group(1)):
bad.append(os.path.relpath(fp, self.root))
break
self.add(
"android_manifest_no_node_replace",
not bad,
"error",
"" if not bad else (
f'tools:node="replace" on <application> in {bad[:3]}: '
"drops all OneSignal manifest components (for example "
"PermissionsActivity). Remove it. Override a single "
"attribute with tools:replace instead."
),
)
def android_no_stray_google_services(self):
gs = [fp for fp in walk_files(self.root) if os.path.basename(fp) == "google-services.json"]
firebase = grep(self.root, r"com\.google\.firebase")
# only a problem if google-services.json exists AND the app doesn't otherwise use Firebase
bad = bool(gs) and not firebase
self.add("android_no_stray_google_services", not bad, "warn",
"" if not bad else "google-services.json present but no Firebase client usage — OneSignal does NOT require it")
def android_requestpermission_not_callback(self):
# OneSignal.Notifications.requestPermission is `suspend fun(Boolean): Boolean`
# with NO callback overload. A trailing-lambda call is a fabrication that
# does not compile — must be called from a coroutine. (A build would catch
# this, but the Kotlin-stdlib floor error can mask it; check it directly.)
rx = re.compile(r"requestPermission\s*\([^)]*\)\s*\{")
hits = []
for fp in walk_files(self.root):
for i, line in enumerate(read(fp).splitlines(), 1):
stripped = line.lstrip()
if stripped.startswith("//") or stripped.startswith("*"):
continue # a cautionary comment quoting the bad form is not code
if rx.search(line):
hits.append((os.path.relpath(fp, self.root), i))
self.add("android_requestpermission_not_callback", not hits, "error",
"" if not hits else f"requestPermission called with a callback lambda at {hits[:3]} "
"— it is a suspend fun; call it from a coroutine (fabricated overload won't compile)")
def android_coroutines_on_classpath(self):
# com.onesignal:OneSignal exposes kotlinx-coroutines-core/-android only in
# its java-runtime variant (implementation), NOT java-api — verified against
# com.onesignal:core Gradle module metadata (5.9.1). So kotlinx.coroutines
# symbols are NOT on the app's compile classpath transitively. A file that
# imports them (the verification file calls requestPermission from a
# coroutine) needs the app to declare the dependency, or it fails to compile
# with "unresolved reference: coroutines" — same class as BuildConfig.DEBUG.
coro_imports = grep(self.root, re.compile(r"import\s+kotlinx\.coroutines"))
if not coro_imports:
self.add("android_coroutines_on_classpath", True, "warn",
"kotlinx.coroutines not imported; no coroutines dependency required")
return
# Evidence the app actually has coroutines on the compile classpath, in ANY
# of the ways it can legitimately be declared/obtained — don't fail correct
# code (this is the same false-positive class the SPM scoping fixed):
# - a literal coordinate, in build.gradle(.kts) OR a version-catalog .toml
# - a version-catalog alias reference (implementation(libs.kotlinx.coroutines))
# - the app's OWN code already imports coroutines outside the managed
# verification file — if it compiles with them today they are available,
# which also covers coroutines pulled in transitively via an `api` dep
# (e.g. androidx.lifecycle:lifecycle-*-ktx).
declared = (grep(self.root, re.compile(r"org\.jetbrains\.kotlinx:kotlinx-coroutines"))
or grep(self.root, re.compile(r"\blibs\.[\w.]*coroutines", re.I)))
app_uses = [h for h in coro_imports if "verif" not in os.path.basename(h[0]).lower()]
ok = bool(declared) or bool(app_uses)
self.add("android_coroutines_on_classpath", ok, "error",
"" if ok else "the verification file imports kotlinx.coroutines but the app declares no "
"coroutines dependency (coordinate or version-catalog alias) and uses none elsewhere — the "
"OneSignal SDK exposes it as a runtime (implementation) dep, not api, so it is NOT on the "
"compile classpath. Add `implementation(\"org.jetbrains.kotlinx:kotlinx-coroutines-android:"
"1.7.3\")` (or a version-catalog equivalent) to the app module.")
def android_buildconfig_feature_enabled(self):
# BuildConfig.DEBUG only resolves if the app module enables the buildConfig
# feature. AGP 8+ defaults it OFF, so a correct guard can still fail to
# compile with a cryptic "unresolved reference: BuildConfig" — a bad first
# experience for the customer. Only relevant if BuildConfig.DEBUG is used.
if not grep(self.root, re.compile(r"BuildConfig\.DEBUG")):
self.add("android_buildconfig_feature_enabled", True, "warn",
"BuildConfig.DEBUG not used; buildConfig feature not required")
return
enabled = bool(grep(self.root, re.compile(r"buildConfig\s*=\s*true")))
for dp, dn, fn in os.walk(self.root):
dn[:] = [d for d in dn if d not in EXCLUDE]
if "gradle.properties" in fn and re.search(
r"android\.defaults\.buildfeatures\.buildconfig\s*=\s*true",
read(os.path.join(dp, "gradle.properties"))):
enabled = True
self.add("android_buildconfig_feature_enabled", enabled, "error",
"" if enabled else "verification file uses BuildConfig.DEBUG but the app module does not enable "
"the buildConfig feature (AGP 8+ defaults it off) — add `android { buildFeatures { buildConfig = "
"true } }` or BuildConfig won't resolve and the build fails")
def android_verification_debug_guarded(self):
vfiles = [fp for fp in walk_files(self.root) if "verification" in os.path.basename(fp).lower()]
if not vfiles:
self.add("android_verification_debug_guarded", False, "warn", "no verification helper found (debug-only proof step)")
return
guarded = any(re.search(r"BuildConfig\.DEBUG", read(fp)) for fp in vfiles)
self.add("android_verification_debug_guarded", guarded, "error",
"" if guarded else "verification file not guarded by BuildConfig.DEBUG — would ship to production")
# ---- ios ----
def ios_init_in_launch(self):
init_hits = grep(self.root, re.compile(r"OneSignal\.initialize\s*\("))
if not init_hits:
self.add("ios_init_in_launch", False, "error", "no OneSignal.initialize(...) found")
return
# the init must run at app launch, not from a SwiftUI View body
launch = False
for fp in walk_files(self.root):
if fp.endswith(".swift"):
txt = read(fp)
if "OneSignal.initialize" in txt and re.search(r"didFinishLaunchingWithOptions|@main|:\s*App\b", txt):
launch = True
self.add("ios_init_in_launch", launch, "warn",
"" if launch else "OneSignal.initialize is not in an AppDelegate/@main App launch context "
"(if it's in a View, cold-start push/deep links break)")
def ios_verification_debug_guarded(self):
vfiles = [fp for fp in walk_files(self.root) if "verification" in os.path.basename(fp).lower() and fp.endswith(".swift")]
if not vfiles:
self.add("ios_verification_debug_guarded", False, "warn", "no Swift verification helper found (debug-only proof step)")
return
guarded = any("#if DEBUG" in read(fp) for fp in vfiles)
self.add("ios_verification_debug_guarded", guarded, "error",
"" if guarded else "verification file not guarded by #if DEBUG — would ship to release")
def ios_verification_uses_push_observer(self):
# FINDINGS: agents wired verification to a NotificationCenter event OneSignal
# never posts (compiles, functionally dead). The real mechanism is
# OSPushSubscriptionObserver.onPushSubscriptionDidChange. Flag the dead form.
vfiles = [fp for fp in walk_files(self.root) if "verification" in os.path.basename(fp).lower() and fp.endswith(".swift")]
if not vfiles:
self.add("ios_verification_uses_push_observer", True, "warn", "no Swift verification file to check")
return
blob = "\n".join(read(fp) for fp in vfiles)
uses_real = "OSPushSubscriptionObserver" in blob or "onPushSubscriptionDidChange" in blob
uses_notifcenter = re.search(r"NotificationCenter|NSNotificationCenter", blob) is not None
ok = uses_real and not uses_notifcenter
detail = ""
if not ok:
detail = ("verification does not use OSPushSubscriptionObserver.onPushSubscriptionDidChange"
+ (" and relies on NotificationCenter (a OneSignal registration event it never posts — dead)" if uses_notifcenter else ""))
self.add("ios_verification_uses_push_observer", ok, "error", detail)
# ---- expo / react-native (shared package: react-native-onesignal) ----
def rn_package_present(self):
pkgs = [fp for fp in walk_files(self.root) if os.path.basename(fp) == "package.json"]
present = any("react-native-onesignal" in read(fp) for fp in pkgs)
self.add("rn_package_present", present, "error",
"" if present else "react-native-onesignal not found in any package.json")
def rn_init_present(self):
hits = grep(self.root, re.compile(r"OneSignal\.initialize\s*\("))
self.add("rn_init_present", bool(hits), "error",
"" if hits else "no OneSignal.initialize(appId) call found")
def rn_verification_dev_guarded(self):
vfiles = [fp for fp in walk_files(self.root)
if "verif" in os.path.basename(fp).lower() and os.path.splitext(fp)[1] in (".ts", ".tsx", ".js", ".jsx")]
if not vfiles:
self.add("rn_verification_dev_guarded", False, "warn", "no JS/TS verification helper found (debug-only proof step)")
return
guarded = any("__DEV__" in read(fp) for fp in vfiles)
self.add("rn_verification_dev_guarded", guarded, "error",
"" if guarded else "verification file not guarded by __DEV__ — would ship to production")
def _expo_app_config(self):
return [fp for fp in walk_files(self.root)
if os.path.basename(fp) in ("app.json", "app.config.js", "app.config.ts")]
def expo_plugin_registered(self):
cfgs = self._expo_app_config()
present = any("onesignal-expo-plugin" in read(fp) for fp in cfgs)
self.add("expo_plugin_registered", present, "error",
"" if present else "onesignal-expo-plugin not registered in app.json/app.config plugins")
def expo_plugin_first(self):
# The config plugin must be first in the plugins array (upstream + matrix).
# Only checkable deterministically for JSON app.json; skip for app.config.*.
appjson = [fp for fp in self._expo_app_config() if os.path.basename(fp) == "app.json"]
if not appjson:
self.add("expo_plugin_first", True, "warn", "app.config.* (not JSON) — plugin-order not statically checkable")
return
import json as _json
for fp in appjson:
try:
plugins = (_json.loads(read(fp)).get("expo") or {}).get("plugins") or []
except Exception:
continue
names = [(p[0] if isinstance(p, list) and p else p) for p in plugins]
if "onesignal-expo-plugin" in names:
first = names[0] == "onesignal-expo-plugin"
self.add("expo_plugin_first", first, "warn",
"" if first else "onesignal-expo-plugin is not the FIRST entry in expo.plugins")
return
self.add("expo_plugin_first", False, "warn", "onesignal-expo-plugin not found in app.json expo.plugins")
# ---- flutter (onesignal_flutter) ----
def _flutter_verification_files(self):
return [fp for fp in walk_files(self.root)
if "verif" in os.path.basename(fp).lower() and fp.endswith(".dart")]
def flutter_init_present(self):
init_hits = grep(self.root, re.compile(r"OneSignal\.initialize\s*\("))
if not init_hits:
self.add("flutter_init_present", False, "error", "no OneSignal.initialize(appId) call found")
return
# init belongs in main() before runApp(), not in a widget build()
in_main = False
for fp in walk_files(self.root):
if fp.endswith(".dart"):
txt = read(fp)
if "OneSignal.initialize" in txt and re.search(r"void\s+main\s*\(|runApp\s*\(", txt):
in_main = True
self.add("flutter_init_present", in_main, "warn",
"" if in_main else "OneSignal.initialize is not in main()/near runApp() "
"(init once at app entry before runApp)")
def flutter_verification_debug_guarded(self):
vfiles = self._flutter_verification_files()
if not vfiles:
self.add("flutter_verification_debug_guarded", False, "warn", "no Dart verification helper found (debug-only proof step)")
return
guarded = any("kDebugMode" in read(fp) for fp in vfiles)
self.add("flutter_verification_debug_guarded", guarded, "error",
"" if guarded else "verification file not guarded by kDebugMode — would ship to release")
def flutter_verification_uses_push_observer(self):
# Mirror the iOS/RN intent: the verification must key off the real push
# subscription surface (pushSubscription.addObserver / .id, validated
# against onesignal_flutter source), not a notification-received listener
# (addForegroundWillDisplayListener fires on delivery, not registration).
vfiles = self._flutter_verification_files()
if not vfiles:
self.add("flutter_verification_uses_push_observer", True, "warn", "no Dart verification file to check")
return
blob = "\n".join(read(fp) for fp in vfiles)
uses_real = "pushSubscription.addObserver" in blob or "pushSubscription.id" in blob
self.add("flutter_verification_uses_push_observer", uses_real, "error",
"" if uses_real else "verification does not read OneSignal.User.pushSubscription "
"(.addObserver/.id) — a notification-received listener is not proof of registration")
# ---- cordova (onesignal-cordova-plugin) ----
def _js_verification_files(self):
return [fp for fp in walk_files(self.root)
if "verif" in os.path.basename(fp).lower()
and os.path.splitext(fp)[1] in (".ts", ".tsx", ".js", ".jsx")]
def cordova_package_present(self):
present = bool(grep(self.root, re.compile(r"onesignal-cordova-plugin")))
self.add("cordova_package_present", present, "error",
"" if present else "onesignal-cordova-plugin not found in package.json/config.xml/plugin.xml")
def cordova_init_present(self):
init_hits = grep(self.root, re.compile(r"OneSignal\.initialize\s*\("))
if not init_hits:
self.add("cordova_init_present", False, "error", "no OneSignal.initialize(appId) call found")
return
# init must run after 'deviceready' (the plugin bridge isn't ready before it)
in_ready = bool(grep(self.root, re.compile(r"deviceready")))
self.add("cordova_init_present", in_ready, "warn",
"" if in_ready else "no 'deviceready' handler found near init — Cordova init must run after deviceready")
def cordova_verification_uses_push_observer(self):
vfiles = self._js_verification_files()
if not vfiles:
self.add("cordova_verification_uses_push_observer", True, "warn", "no JS/TS verification file to check")
return
blob = "\n".join(read(fp) for fp in vfiles)
# validated surface: getIdAsync() (.id getter is deprecated) + pushSubscription
# addEventListener('change', ...). A notification-received listener is not proof.
uses_real = "getIdAsync" in blob or "pushSubscription" in blob
self.add("cordova_verification_uses_push_observer", uses_real, "error",
"" if uses_real else "verification does not read pushSubscription (getIdAsync/addEventListener 'change') "
"— a notification-received listener is not proof of registration")
# ---- capacitor / ionic (@onesignal/capacitor-plugin) ----
def capacitor_package_present(self):
present = bool(grep(self.root, re.compile(r"@onesignal/capacitor-plugin")))
self.add("capacitor_package_present", present, "error",
"" if present else "@onesignal/capacitor-plugin not found in package.json")
def capacitor_init_present(self):
hits = grep(self.root, re.compile(r"OneSignal\.initialize\s*\("))
self.add("capacitor_init_present", bool(hits), "error",
"" if hits else "no OneSignal.initialize(appId) call found")
def capacitor_handle_notifications_flag(self):
# matrix + cross-platform.md: set ios.handleApplicationNotifications=false so
# OneSignal and Capacitor don't both claim the iOS notification callbacks.
cfgs = [fp for fp in walk_files(self.root)
if os.path.basename(fp) in ("capacitor.config.ts", "capacitor.config.js", "capacitor.config.json")]
if not cfgs:
self.add("capacitor_handle_notifications_flag", False, "warn", "no capacitor.config.* found")
return
blob = "\n".join(read(fp) for fp in cfgs)
ok = re.search(r"handleApplicationNotifications\s*[:=]\s*false", blob) is not None
self.add("capacitor_handle_notifications_flag", ok, "warn",
"" if ok else "capacitor.config missing ios.handleApplicationNotifications=false "
"(OneSignal and Capacitor will both claim iOS notification callbacks)")
def capacitor_verification_uses_push_observer(self):
vfiles = self._js_verification_files()
if not vfiles:
self.add("capacitor_verification_uses_push_observer", True, "warn", "no JS/TS verification file to check")
return
blob = "\n".join(read(fp) for fp in vfiles)
uses_real = "getIdAsync" in blob or "pushSubscription" in blob
self.add("capacitor_verification_uses_push_observer", uses_real, "error",
"" if uses_real else "verification does not read pushSubscription (getIdAsync/addEventListener 'change') "
"— a notification-received listener is not proof of registration")
# ---- web ----
def web_worker_is_importscripts(self):
workers = [fp for fp in walk_files(self.root) if os.path.basename(fp) == "OneSignalSDKWorker.js"]
if not workers:
self.add("web_worker_is_importscripts", False, "error", "OneSignalSDKWorker.js not found")
return
for fp in workers:
txt = read(fp)
if "importScripts" in txt and len(txt) < 2000:
self.add("web_worker_is_importscripts", True, "error"); return
if "<html" in txt.lower() or len(txt) > 50000:
self.add("web_worker_is_importscripts", False, "error",
f"{os.path.relpath(fp, self.root)} looks like a downloaded HTML/blob, not the importScripts one-liner")
return
self.add("web_worker_is_importscripts", False, "warn", "worker present but not the expected importScripts shape")
def web_init_present(self):
hits = grep(self.root, re.compile(r"OneSignalDeferred|OneSignal\.init\b|new OneSignal|react-onesignal|onesignal-vue|onesignal-ngx"))
self.add("web_init_present", bool(hits), "error",
"" if hits else "no OneSignal init found (OneSignalDeferred/OneSignal.init or a framework wrapper)")
def web_page_sdk_v16(self):
# the page SDK must load from the fixed CDN v16 major; a pinned patch or a
# non-CDN path is a drift/staleness hazard.
loaded = grep(self.root, re.compile(r"cdn\.onesignal\.com/sdks/web/v16/OneSignalSDK\.page\.js"))
wrapper = grep(self.root, re.compile(r"react-onesignal|onesignal-vue|onesignal-ngx"))
ok = bool(loaded) or bool(wrapper) # a framework wrapper loads the SDK itself
self.add("web_page_sdk_v16", ok, "warn",
"" if ok else "no v16 CDN page script (or framework wrapper) found")
def run(self):
universal = [self.no_version_range, self.managed_marker_present, self.no_placeholder_app_id,
self.app_id_present, self.no_deprecated_addoutcome, self.no_committed_secrets]
by_platform = {
"android": [self.android_init_in_application, self.android_manifest_registers_app,
self.android_manifest_no_node_replace,
self.android_no_stray_google_services, self.android_verification_debug_guarded,
self.android_requestpermission_not_callback, self.android_buildconfig_feature_enabled,
self.android_coroutines_on_classpath],
# Unity ships via .unitypackage/UPM (a GUI step, no text manifest we can
# structurally assert), so it runs the universal checks only.
"unity": [],
"ios": [self.ios_init_in_launch, self.ios_verification_debug_guarded,
self.ios_verification_uses_push_observer],
"expo": [self.expo_plugin_registered, self.expo_plugin_first,
self.rn_package_present, self.rn_init_present, self.rn_verification_dev_guarded],
"react-native": [self.rn_package_present, self.rn_init_present, self.rn_verification_dev_guarded],
"web": [self.web_worker_is_importscripts, self.web_init_present, self.web_page_sdk_v16],
"flutter": [self.flutter_init_present, self.flutter_verification_debug_guarded,
self.flutter_verification_uses_push_observer],
"cordova": [self.cordova_package_present, self.cordova_init_present,
self.cordova_verification_uses_push_observer],
"capacitor": [self.capacitor_package_present, self.capacitor_init_present,
self.capacitor_handle_notifications_flag,
self.capacitor_verification_uses_push_observer],
}
for c in universal + by_platform.get(self.platform, []):
c()
return self.results
def main():
ap = argparse.ArgumentParser(description="Deterministic structural verification of a OneSignal integration.")
ap.add_argument("project_dir")
ap.add_argument("--platform", required=True,
choices=["android", "ios", "web", "expo", "react-native", "flutter", "cordova", "capacitor", "unity"])
ap.add_argument("--app-id")
args = ap.parse_args()
if not os.path.isdir(args.project_dir):
sys.stderr.write(f"ERROR: not a directory: {args.project_dir}\n"); sys.exit(2)
results = Checks(os.path.abspath(args.project_dir), args.platform, args.app_id).run()
errors = [r for r in results if not r["passed"] and r["severity"] == "error"]
warns = [r for r in results if not r["passed"] and r["severity"] == "warn"]
out = {
"platform": args.platform,
"checks": results,
"summary": {"passed": sum(1 for r in results if r["passed"]),
"total": len(results), "errors": len(errors), "warnings": len(warns)},
"verdict": "pass" if not errors else "fail",
}
print(json.dumps(out, indent=2))
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()
SHA-256: 1df90a5cecfa01fe2b9b95267e8b88a62e77f444fbdca98fe5baf74735d99a24