This document details the configuration, data schema, styling, and synchronization mechanisms powering the NullStore web catalog.
- Stack: Pure Vanilla HTML5, CSS3, and modern ECMAScript (zero runtime frameworks or bundled bloat).
- Hosting: GitHub Pages (
https://nullcpy.github.io/) — canonical origin. Three outage mirrors are git-connected straight to this repo: Vercel (nullstore.vercel.app, configured byvercel.json), Cloudflare Workers static assets (nullstore.nullcpy.workers.dev, configured bywrangler.jsonc), and Cloudflare Pages (nullcpy.pages.dev, configured only in its dashboard — see §6).nullcpy.github.ioremains the canonical origin that every deployment declares in itsog:urlmeta tag (the store ships norel=canonicallink), and each mirror isnoindexd so none competes with it on search. - Public entry point: the domains hub at
https://fahim-ahmed05.github.io/nullstore— a page on the personal site, not in this repo — whichREADME.mdlinks as "Download Website". It lists all four domains so a visitor picks whichever one is reachable, and it is the thing to share during an outage. Note the URL has no trailing slash:/nullstoreresolves,/nullstore/returns 404. - Data Source: Static
data.jsonfile serialized under Schema v2. - Decoupled Design: The build pipeline in
nullcpy/rvbpushes catalog updates over GitHub API. The website operates entirely as an independent client.
The catalog uses Schema v2. All legacy baggage (such as artificial composite keys variantKey, buildKey, and container patches) has been purged in favor of clean, distinct first-class fields.
{
"version": 2,
"updated_at": "2026-09-08T03:30:00.000Z",
"patchSets": [ ... ],
"changelogSets": [ ... ],
"patchSourceSets": [ ... ],
"apps": [ ... ]
}Applied-patch names, changelog URLs and patch-source slugs repeat heavily across
builds, so identical lists are stored once in a top-level table and each build
references the entry by integer index (patchSetRef / changelogRef /
patchSourceRef) instead of inlining its own copy.
[
["Unlock Premium", "Hide Ads", "Remove Tracking"],
["Theme ENABLER", "Custom Branding"]
]Dedup is keyed on the ordered list, so builds with genuinely different lists still get distinct entries — only byte-identical repeats collapse (no data loss). An empty list is omitted entirely (the build simply carries no ref). The client resolves each ref via
getBuildAppliedPatches()/getBuildChangelogs()/getBuildPatchSources().
Each application in the catalog contains:
{
"appKey": "youtube",
"appName": "YouTube",
"totalDownloads": 125430,
"latestPublishedAt": "2026-09-08T03:30:00.000Z",
"brands": [ ... ]
}Represents a patching engine or identity (e.g. Morphe, ReVanced, ReVanced Extended):
{
"brandKey": "morphe",
"brandName": "Morphe",
"latestVersion": "19.16.39",
"latestPublishedAt": "2026-09-08T03:30:00.000Z",
"totalDownloads": 89200,
"variants": [ ... ],
"builds": [ ... ]
}Models a distinct functional or packaging stream:
variant(string | null): Feature or UI variant (e.g."Android TV","Nord"). Standard/default builds usenull.subVariant(string | null): Packaging variant (e.g."clone","alt"). Standard builds usenull.packageName(string): Target Android package name (e.g."com.google.android.youtube").apkFilter(string): Regular expression used for asset filtering (e.g. in Obtainium).latestStable/latestBeta(string | null): Channel pointer storing only the referenced build'sbuildid (ornullwhen the channel has no build). The client resolvesversion/publishedAt/isArchiveFallbackby looking the id up in the brand'sbuildsarray (matchingvariant+subVariant+releaseType+build), so these fields are no longer duplicated per pointer. This is the only shape the client reads; a pointer written as an inline object will not resolve.
{
"variant": "Android TV",
"subVariant": "clone",
"packageName": "com.amazon.amazonvideo.livingroom",
"apkFilter": "^primevideo-morphe-androidtv-clone-v.*\\.apk$",
"latestStable": "380576727",
"latestBeta": null
}Represents an individual build artifact release:
{
"build": "380576727",
"releaseType": "stable",
"isArchive": false,
"version": "19.16.39",
"versions": ["19.16.39"],
"variant": null,
"subVariant": null,
"publishedAt": "2026-09-08T02:00:00.000Z",
"releaseUrl": "https://github.com/nullcpy/rvb/releases/tag/380576727",
"patchSourceRef": 0,
"changelogRef": 0,
"patchSetRef": 0,
"assets": [
{
"name": "youtube-morphe-v19.16.39-arm64-v8a.apk",
"size": 134217728,
"download_count": 4200,
"browser_download_url": "https://github.com/nullcpy/rvb/releases/download/380576727/youtube-morphe-v19.16.39-arm64-v8a.apk",
"arch": "arm64",
"version": "19.16.39",
"patchSetRef": 0
}
]
}Notes
patchSetRef/changelogRef/patchSourceRefare integer indices into the top-levelpatchSets/changelogSets/patchSourceSetstables (see above). Each build's applied-patch names, changelog URLs and patch-source slugs live once in the shared table; an empty list is omitted (no ref). Rendered in the Applied Patches modal, never on collapsed cards.- A numbered build groups every arch under one card. Because rvb publishes each arch independently, one build can carry several app versions (e.g.
arm64at the newest,armat a fallback).versionis the primary (highest) value used forlatestVersionand channel pointers;versionslists every distinct version the build published, newest first, and is shown on the build card (at most two joined by/, the remainder folded into a+ Noverflow, e.g.v6.12.18 / v6.12.15 + 3).assets[].versionrecords the version of that specific file, so download rows show their own.assets[].patchSetRefis a per-asset index into the same sharedpatchSetstable. It lets the Applied Patches modal expose per-arch tabs (mirroring the Stable/Beta channel tabs) whenever a build's arches resolve to different patch sets; arches sharing one list collapse to the same index and the modal shows a single button. Resolved viagetArchAppliedPatches(); the build-levelpatchSetRefremains as the default for older clients.releaseIdis omitted when it equalsbuild(true for all numbered releases; both are the tag). It is kept only when it differs — i.e. rolling archive entries, wherebuildis a version butreleaseIdisstable/beta. The client falls back tobuildwhen it is absent.assets[].archuses the compact keysarm64 | arm | all | x86 | other(seegroupAssetsByArchitecture), whileCONFIG.knownArchslists the raw filename tokens used for auto-detection.assets[].fileTypeis not stored — it is always derived client-side from the filename extension viagetFileType()(.apk→APK,.zip→Module).
All website settings, categories, and dynamic notices are configured directly in CONFIG at the top of script.js.
const CONFIG = {
owner: "nullcpy",
repo: "rvb",
knownArchs: [
"arm64-v8a", "arm64", "aarch64",
"armeabi-v7a", "arm-v7a", "arm32", "arm",
"x86_64", "x86", "universal", "all"
],
appCategories: {
"Adobe": ["adobe"],
"Android TV": [
"primevideo", "plutotv", "moviebox", "disneyplus", "disney",
"hbomax", "tubi", "vix", "at4klauncher", "projectivylauncher",
"peacock", "netflix", "nuvio"
],
"Browser": ["browser", "edge"],
"Google": ["youtube", "google", "gboard"],
"Launcher": ["launcher", "at4klauncher", "projectivylauncher"],
"Meta": ["threads", "instagram", "messenger", "facebook", "!plusmessenger"],
"Social": [
"threads", "instagram", "messenger", "facebook", "twitter",
"tiktok", "telegram", "reddit", "pinterest", "tumblr"
],
"VPN": ["cloudflarewarp", "vpnify", "vpn"],
"YouTube": ["youtube"]
},
appNotices: [ ... ]
};- Category Names: Keys in
CONFIG.appCategoriesdefine the exact chip labels displayed on the site in clean Title Case (e.g."Android TV","Google","Meta","VPN"). - Matching Rules:
- Matches against normalized app name, app key, and names with
+converted toplus. - Prefix with
!to create an exclusion filter (e.g."!plusmessenger"prevents Plus Messenger from being caught under the"messenger"rule). - No random or duplicate word chips: only the categories defined here are rendered.
- Matches against normalized app name, app key, and names with
Notices display contextual alerts inside expanded app cards (e.g. MicroG requirements or login advisories).
Notices are styled 100% dynamically via CSS variables—you never need to edit CSS when adding or tweaking notices.
appNotices: [
{
triggers: ["youtube", "google"],
color: "accent", // Uses theme accent green with glowing gradient
title: "Login Issue",
text: "Signing into Google account on APK (not Module) requires MicroG.",
links: [
{ label: "Morphe", url: "https://github.com/MorpheApp/MicroG-RE/releases/latest" },
{ label: "ReVanced", url: "https://github.com/ReVanced/GmsCore/releases/latest" }
]
},
{
triggers: ["twitter"],
color: "warning", // Uses warning yellow with subtle warning gradient
title: "Login Issue",
text: "Since October 2025, Twitter has started checking device integrity during login.",
links: [
{ label: "Workarounds", url: "https://t.me/pikopatches/1/59772" }
]
},
// Example with custom hex color:
{
triggers: ["spotify"],
color: "#1db954", // Automatically derives border, text color, and 12% glow background!
title: "Playback Notice",
text: "Use recommended version.",
links: []
}
]color:- Presets:
"accent","info","warning","danger","error","success". - Custom Color: Any hex (
"#3b82f6"), rgb, or CSS color string.
- Presets:
border(optional): Specific border override (e.g."1px solid red"). Defaults tocolor.bg(optional): Specific background override. If omitted with a hexcolor, a subtle12%alpha transparent glow gradient is generated automatically.
NullStore provides native integration with Obtainium:
-
Label Standard: Uses square brackets for subvariants to prevent nested double parentheses:
$$\text{Format: } \text{AppName} \text{ (Brand - Variant [SubVariant])}$$ Example:Prime Video (Morphe - Android TV [Clone]) -
Asset Filtering: Automatically derives regex matching the variant, subvariant, and channel (
includePrereleasesfor beta). -
One-Click URL: Encodes configuration into
obtainium://app/{...}link protocol for instant setup.
Live search operates dynamically in memory without static corpus strings or bloated tokens:
- Exact match:
0(instant first result) - Prefix match:
1 - App Name Word match:
2(exact word),3(word prefix) - Substring match on App Name/Key:
4 - Brand Name match:
5 - Category Name / Keyword match:
5 - Variant & Sub-Variant match:
6 - Package Name match:
7
Unmatched apps return Infinity and are filtered out instantly.
- Sole writer of
data.json:- What GitHub Pages publishes is decided by the
Stage site for publishingstep indeploy-pages.yml, which copies the same five site files intodist/and uploads that. Nothing else in the repo is reachable onnullcpy.github.io—CONFIG.md,README.md,LICENSE,_config.yml,vercel.jsonandwrangler.jsoncall 404. Note that_config.yml's ownexclude:list has no effect: Jekyll never runs here, so editing it changes nothing.upload-pages-artifacthas no exclude input either — the staged directory is the only lever. .github/scripts/rebuild_catalog.pyregenerates the entire catalog in this repo and is the only component that writesdata.json. Itsfinalize()step emits the deduped schema: a top-levelpatchSetstable with per-build integerpatchSetRef, channel pointers stored as bare build-id strings,releaseIdomitted when equal tobuild, and assetfileTypeomitted (derived client-side).- It runs via
rebuild-catalog.ymlonrepository_dispatch(from rvb releases), the ~6-hourly schedule safety-net, or manualworkflow_dispatch, then self-dispatches a Pages deploy.
- What GitHub Pages publishes is decided by the
- rvb is an input source only:
nullcpy/rvbdoes not write or patchdata.json.rebuild_catalog.pyreads it through the GitHub API — each release'sbuild.jsonmanifest plus the releases/assets listings — and derives every field locally.
- Outage mirrors (Vercel + Cloudflare Workers + Cloudflare Pages) — all git-connected to this repo:
-
Vercel project
nullstore→https://nullstore.vercel.app. It must live on a Vercel account whose linked GitHub identity isnullcpy: Vercel supports one GitHub account per Vercel user and requires repository ownership to import a personal-account repo, which is why an unrelated account cannot connect this one. -
vercel.jsoncopies only the five published site files (index.html,style.css,script.js,data.json,social-preview.svg) intodist/, so repo docs and workflow files are never served. If a sixth file ever becomes part of the page, add it to that copy list in bothvercel.jsonandwrangler.jsonc— the two build commands must stay identical. -
It also sends
X-Robots-Tag: noindexon every response, and cachesdata.jsonasmax-age=0, s-maxage=300, stale-while-revalidate=600(edge 5 minutes, browser always revalidates) so no client holds a stale ~1 MB catalog. -
Site-file pushes deploy on their own through the Git integration — no CI step is involved, and
deploy-pages.ymldeliberately carries no Vercel work. -
Analytics is build-time injected, never committed.
index.htmlships only an<!-- ANALYTICS-BEACON -->placeholder;.github/scripts/inject-analytics.mjsreplaces it with the Cloudflare beacon from theCF_BEACON_TOKENenvironment variable — provided as a repo secret/variable indeploy-pages.yml, as a Vercel project env var, and as a Cloudflare project build environment variable, keeping all three deployments byte-identical. Without the variable (forks, local builds) the placeholder is removed and no beacon is deployed, so third-party sites cannot report traffic into your Cloudflare property. If you ever suspect a token leaked through an old commit or a fork, rotate it in the Cloudflare dashboard and update the three env sources — the repo itself never needs to change. -
Catalog rebuilds need the hooks. The rebuild commit carries
[skip ci], a token every mirror honours (Vercel, Cloudflare Pages, and Cloudflare Workers Builds — Workers gained it in May 2026), so without help each mirror freezes at whatever catalog it was first built from.rebuild-catalog.ymltherefore POSTsVERCEL_DEPLOY_HOOK,CLOUDFLARE_WORKERS_DEPLOY_HOOKandCLOUDFLARE_PAGES_DEPLOY_HOOKimmediately after it pushesdata.json, gated onCATALOG_PUSHED. They are tried independently — one unreachable mirror must not skip the others — and all are best-effort, so adding a mirror means creating its hook and appending the secret name to that one loop. -
Cloudflare specifics. The project is a Workers project serving static assets (no
main, so nothing ever runs as a Worker and no runtime quota is consumed) and its URL isnullstore.nullcpy.workers.dev, not*.pages.dev.wrangler.jsoncexists mainly to stop auto-detection: seeing the vestigial_config.yml, wrangler guesses a Jekyll site and runsnpx bundle exec jekyll buildinto_site, which fails — and in a non-interactive build the "modify these settings?" prompt silently answers no. Custom response headers live in_headersat the repo root, which the build copies intodist/because Cloudflare only reads that file from the assets-directory root (Vercel's equivalent lives insidevercel.json). -
Cloudflare Pages →
https://nullcpy.pages.dev. Unlike every other mirror here, a Pages project has no committed config file — its build settings exist only in the dashboard, so they must be re-entered by hand if the project is ever recreated. Required values, chosen to be byte-equivalent in effect towrangler.jsonc:- Framework preset:
None - Build command:
node .github/scripts/inject-analytics.mjs && mkdir -p dist && cp index.html style.css script.js data.json social-preview.svg _headers dist/ - Build output directory:
dist - Environment variable (Secret, Production):
CF_BEACON_TOKEN
Left unset, Pages publishes the raw checkout:
CONFIG.md,README.md,.gitignoreand.github/**become reachable, and the<!-- ANALYTICS-BEACON -->comment ships verbatim so the mirror records no traffic. Pages reads_headersfrom the output root, which is why the build copies it intodist/. - Framework preset:
-
Recovery is cheap and never blocks the canonical site: a lost nudge is re-run with a single
curl -X POSTof the hook URL, and the next site-file push re-syncs every mirror anyway because those deploys ship the whole repo,data.jsonincluded.
-