Initial commit: Null DRM Official

Capture, decrypt, and restream toolkit with compiled-in app modules
(RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www.
BBC module.yaml is published (clear streams); other module values stay local.
This commit is contained in:
404errordeveloper 2026-10-06 00:25:35 +02:00
commit 2fa8f2435f
121 changed files with 17802 additions and 0 deletions

82
www/docs.html Normal file
View file

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Docs — NULLDRM</title>
<meta name="description" content="NULLDRM documentation, mirrored from the Forgejo repository." />
<meta name="color-scheme" content="light" />
<link rel="stylesheet" href="styles.css" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%234c6ef5'/%3E%3Ctext x='16' y='22' text-anchor='middle' font-size='16' font-weight='bold' font-family='system-ui' fill='%23fff'%3EN%3C/text%3E%3C/svg%3E" />
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="site-header">
<div class="shell header-row">
<a class="brand" href="index.html">
<span class="mark" aria-hidden="true">N</span>
NULLDRM
</a>
<nav class="site-nav" aria-label="Main">
<a href="index.html#how">How it works</a>
<a href="index.html#start">Quick start</a>
<a href="docs.html" class="active">Docs</a>
<a href="index.html#source">Source</a>
<a href="https://t.me/+GZwQ7d_HD2w2ZTM8" rel="noopener" target="_blank">Telegram</a>
</nav>
</div>
</header>
<main id="main">
<section>
<div class="shell">
<div class="docs-layout">
<nav class="docs-nav" aria-label="Documents">
<h3>Documents</h3>
<ul id="docs-list">
<li><span class="status-msg">Loading…</span></li>
</ul>
</nav>
<div class="docs-main">
<div class="docs-toolbar">
<strong id="docs-title">README.md</strong>
<a id="docs-source" href="https://git.nulldrm.com/nulldrm/Null-DRM-Official/src/branch/main/docs/README.md" rel="noopener">View on Forgejo</a>
</div>
<div id="docs-status" class="status-msg">Loading document…</div>
<article id="docs-content" class="docs-content" hidden></article>
</div>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div class="shell footer-row">
<span>Mirrored from <code>docs/</code> on Forgejo.</span>
<nav aria-label="Footer">
<a href="index.html">Home</a>
<a href="https://git.nulldrm.com/nulldrm/Null-DRM-Official/src/branch/main/docs" rel="noopener">Source</a>
<a href="https://t.me/+GZwQ7d_HD2w2ZTM8" rel="noopener" target="_blank">Telegram</a>
</nav>
</div>
</footer>
<div id="tg-modal" class="tg-modal" hidden>
<div class="tg-backdrop" data-tg-dismiss></div>
<div class="tg-dialog" role="dialog" aria-modal="true" aria-labelledby="tg-title">
<button type="button" class="tg-close" data-tg-dismiss aria-label="Close">✕</button>
<h2 id="tg-title">Join the Telegram group</h2>
<p>Modules, swarm tips and open-source updates. Shown once — it is always in the menu.</p>
<div class="tg-actions">
<a class="btn btn-primary" id="tg-join" href="https://t.me/+GZwQ7d_HD2w2ZTM8" rel="noopener" target="_blank">Join Telegram</a>
<button type="button" class="btn" data-tg-dismiss>Not now</button>
</div>
</div>
</div>
<script src="vendor/marked.min.js"></script>
<script src="docs.js"></script>
<script src="script.js"></script>
</body>
</html>

163
www/docs.js Normal file
View file

@ -0,0 +1,163 @@
(() => {
const OWNER = "nulldrm";
const REPO = "Null-DRM-Official";
const BRANCH = "main";
const API = "/git-api/api/v1/repos/" + OWNER + "/" + REPO;
const FORGEJO = "https://git.nulldrm.com/" + OWNER + "/" + REPO;
const DEFAULT_DOC = "README.md";
const FALLBACK = [
"README.md",
"quickstart.md",
"architecture.md",
"modules.md",
"capture.md",
"streamd.md",
"providers/rte.md",
"providers/tg4.md",
"providers/bbc.md",
];
// Order the nav the way the docs are meant to be read.
const ORDER = FALLBACK;
const listEl = document.getElementById("docs-list");
const contentEl = document.getElementById("docs-content");
const statusEl = document.getElementById("docs-status");
const titleEl = document.getElementById("docs-title");
const sourceEl = document.getElementById("docs-source");
function currentFile() {
const params = new URLSearchParams(window.location.search);
const f = (params.get("f") || DEFAULT_DOC).trim();
// Allow one level of subdirectory (providers/tg4.md) but nothing escaping docs/.
if (!f || f.includes("..") || f.includes("\\")) return DEFAULT_DOC;
if (!/^[\w.-]+(?:\/[\w.-]+)?\.md$/i.test(f)) return DEFAULT_DOC;
return f;
}
// Keep "/" readable in the path; only the segments need escaping.
function encodePath(name) {
return name.split("/").map(encodeURIComponent).join("/");
}
function forgejoSrc(name) {
return FORGEJO + "/src/branch/" + BRANCH + "/docs/" + encodePath(name);
}
function forgejoRaw(name) {
return "/git-api/" + OWNER + "/" + REPO + "/raw/branch/" + BRANCH + "/docs/" + encodePath(name);
}
function setActiveNav(name) {
if (!listEl) return;
listEl.querySelectorAll("a").forEach((a) => {
a.classList.toggle("active", a.getAttribute("data-file") === name);
});
}
function renderNav(files) {
if (!listEl) return;
listEl.innerHTML = files.map((name) => {
const href = "docs.html?f=" + encodeURIComponent(name);
return "<li><a href=\"" + href + "\" data-file=\"" + name.replace(/"/g, "") + "\">" + name + "</a></li>";
}).join("");
}
function rewriteDocLinks(root) {
root.querySelectorAll("a[href]").forEach((a) => {
const href = a.getAttribute("href") || "";
if (/^https?:\/\//i.test(href) || href.startsWith("#") || href.startsWith("mailto:")) return;
const clean = href.replace(/^\.\//, "");
if (/^[\w.-]+(?:\/[\w.-]+)?\.md(?:#.*)?$/i.test(clean)) {
const [file, hash] = clean.split("#");
a.setAttribute("href", "docs.html?f=" + encodeURIComponent(file) + (hash ? "#" + hash : ""));
}
});
}
async function dirDocs(path) {
const res = await fetch(API + "/contents/" + path + "?ref=" + encodeURIComponent(BRANCH));
if (!res.ok) throw new Error("HTTP " + res.status);
const entries = await res.json();
return Array.isArray(entries) ? entries : [];
}
async function listDocs() {
try {
const entries = await dirDocs("docs");
const files = entries
.filter((e) => e && e.type === "file" && /\.md$/i.test(e.name || ""))
.map((e) => e.name);
// Pull in one level of subdirectories, e.g. docs/providers/.
const dirs = entries.filter((e) => e && e.type === "dir" && e.name);
for (const d of dirs) {
try {
const sub = await dirDocs("docs/" + d.name);
sub
.filter((e) => e && e.type === "file" && /\.md$/i.test(e.name || ""))
.forEach((e) => files.push(d.name + "/" + e.name));
} catch {
/* a subdirectory we cannot list is not fatal */
}
}
files.sort((a, b) => {
const ia = ORDER.indexOf(a);
const ib = ORDER.indexOf(b);
if (ia !== -1 || ib !== -1) {
return (ia === -1 ? ORDER.length : ia) - (ib === -1 ? ORDER.length : ib);
}
return a.localeCompare(b);
});
return files.length ? files : FALLBACK;
} catch {
return FALLBACK;
}
}
async function loadDoc(name) {
if (titleEl) titleEl.textContent = name;
if (sourceEl) {
sourceEl.href = forgejoSrc(name);
sourceEl.textContent = "View on Forgejo";
}
setActiveNav(name);
if (statusEl) {
statusEl.hidden = false;
statusEl.textContent = "Loading " + name + "…";
}
if (contentEl) contentEl.hidden = true;
try {
const res = await fetch(forgejoRaw(name));
if (!res.ok) throw new Error("HTTP " + res.status);
const md = await res.text();
if (typeof marked === "undefined" || !marked.parse) {
throw new Error("marked missing");
}
marked.setOptions({ gfm: true, breaks: false });
contentEl.innerHTML = marked.parse(md);
rewriteDocLinks(contentEl);
statusEl.hidden = true;
contentEl.hidden = false;
document.title = name + " — NULLDRM Docs";
} catch {
if (statusEl) {
statusEl.innerHTML =
"Could not load <code>" + name + "</code>. " +
"Open it on <a href=\"" + forgejoSrc(name) + "\" rel=\"noopener\">Forgejo</a>.";
statusEl.hidden = false;
}
if (contentEl) contentEl.hidden = true;
}
}
async function boot() {
const files = await listDocs();
renderNav(files);
let file = currentFile();
if (files.indexOf(file) === -1) file = files[0] || DEFAULT_DOC;
await loadDoc(file);
}
boot();
})();

326
www/index.html Normal file
View file

@ -0,0 +1,326 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NULLDRM — capture · decrypt · restream, self-hosted</title>
<meta name="description" content="NULLDRM captures Widevine live sessions from Android apps and restreams them. One Go binary, modules for each app, free forever." />
<meta name="color-scheme" content="light" />
<link rel="stylesheet" href="styles.css" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%234c6ef5'/%3E%3Ctext x='16' y='22' text-anchor='middle' font-size='16' font-weight='bold' font-family='system-ui' fill='%23fff'%3EN%3C/text%3E%3C/svg%3E" />
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="site-header">
<div class="shell header-row">
<a class="brand" href="index.html">
<span class="mark" aria-hidden="true">N</span>
NULLDRM
</a>
<nav class="site-nav" aria-label="Main">
<a href="#how">How it works</a>
<a href="#ways">Ways to run it</a>
<a href="#start">Quick start</a>
<a href="docs.html">Docs</a>
<a href="#source">Source</a>
<a href="https://t.me/+GZwQ7d_HD2w2ZTM8" rel="noopener" target="_blank">Telegram</a>
</nav>
</div>
</header>
<main id="main">
<section class="hero">
<div class="shell">
<span class="pill"><span class="dot" aria-hidden="true"></span> Free forever · open source</span>
<h1>Capture, decrypt and restream your own subscriptions.</h1>
<p class="tagline">
NULLDRM logs the Widevine handshake from an Android app, recovers the content keys,
and keeps a live restream healthy. One binary, self-hosted, no license keys.
</p>
<div class="actions">
<a class="btn btn-primary" href="#start">Quick start</a>
<a class="btn" href="docs.html">Read the docs</a>
<a class="btn" href="https://git.nulldrm.com/nulldrm/Null-DRM-Official" rel="noopener">Source</a>
</div>
</div>
</section>
<section id="how">
<div class="shell">
<div class="section-head">
<span class="eyebrow">How it works</span>
<h2>A capture, start to finish</h2>
<p>
An HTTPS proxy runs on the phone. NULLDRM drives the real app to a channel,
reads the licence exchange as it happens, and resolves the keys with your own CDM.
</p>
</div>
<div class="terminal">
<div class="term-bar">
<span class="lamp" aria-hidden="true"></span>
<span class="lamp" aria-hidden="true"></span>
<span class="lamp" aria-hidden="true"></span>
<span class="name">drm capture — rteone</span>
</div>
<pre aria-label="Example output of a capture run"><span class="t-prompt">PS C:\DRM-Decryption&gt;</span> ./bin/drm capture --app rte --channel rteone --auto-play
<span class="t-dim">[*]</span> Stopping any old appproxy...
<span class="t-dim">[*]</span> Pushing appproxy...
<span class="t-dim">20:30:45</span> CA cert: /data/local/tmp/appproxy-ca.crt
<span class="t-dim">20:30:45</span> capture → /data/local/tmp/appproxy_cap.json
<span class="t-dim">20:30:45</span> DNS → [1.1.1.1:53 1.0.0.1:53 8.8.8.8:53]
<span class="t-dim">20:30:45</span> listening on [::]:8080
<span class="t-ok">[+]</span> appproxy pid=12004; capture -&gt; /data/local/tmp/appproxy_cap.json
<span class="t-dim">[*]</span> Setting HTTP proxy -&gt; 127.0.0.1:8080 (was "null")
<span class="t-dim">[*]</span> Re-injecting system CA (Magisk)...
<span class="t-ok">[+]</span> System CA present in conscrypt
<span class="t-dim">[*]</span> Launching rte (<span class="t-redact">package from module.yaml</span>)…
<span class="t-dim">[*]</span> Auto-play RTE One…
<span class="t-dim">[*]</span> tap_ui "Live"/"Live tab, 2 out of 5" @ 324,2253
<span class="t-dim">[*]</span> tap Play @ 540,720
<span class="t-ok">[+]</span> player is PLAYING
<span class="t-dim">[*]</span> Waiting for license + PSSH + manifest…
<span class="t-dim">watch: .cache\appproxy-default.log</span>
<span class="t-ok">[+]</span> Capture:
pid: <span class="t-redact">••••••••••••</span>
mpd: <span class="t-url">https://dai.google.com/linear/dash/pa/event/<span class="t-redact">••••</span>/stream/<span class="t-redact">••••</span></span>
license: <span class="t-url">https://widevine.entitlement.<span class="t-redact">••••</span>/wv/web/ModularDrm?schema=1.0&amp;form=json</span>
pssh: AAAASnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAACoiIG<span class="t-redact">••••</span>…
<span class="t-ok">[+]</span> key: <span class="t-redact">••••••••••••••••••••••••••••••••</span>:<span class="t-redact">••••••••••••••••••••••••••••••••</span>
<span class="t-ok">[+]</span> session: outputs\rte\20261005-203112\session.json
<span class="t-dim">[*]</span> Closing player…
<span class="t-dim">[*]</span> HTTP proxy cleared</pre>
</div>
<p class="term-legend">
Redacted above: the package id, licence account, programme id, PSSH and keys.
Those belong to your device and your subscription — NULLDRM keeps them in a local
file that is never committed.
</p>
<div class="grid grid-3" style="margin-top:28px">
<article class="card">
<h3>Nothing is cracked</h3>
<p>
Keys come from a Widevine device file you supply. NULLDRM automates the request
flow a normal client already performs — it does not break the DRM.
</p>
</article>
<article class="card">
<h3>Catalogue or phone</h3>
<p>
Some apps publish a playback catalogue, so a channel resolves with no phone at all.
Everything else goes through the on-device proxy.
</p>
</article>
<article class="card">
<h3>Stale keys heal</h3>
<p>
Live keys rotate. The agent notices a dead channel, re-runs the capture on a
phone and posts fresh credentials back to the restreamer.
</p>
</article>
</div>
</div>
</section>
<section id="ways">
<div class="shell">
<div class="section-head">
<span class="eyebrow">Ways to run it</span>
<h2>Start small, grow if you want to</h2>
<p>Same binary in all three. You decide how much automation and how much cooperation.</p>
</div>
<div class="grid grid-3">
<article class="card">
<div class="step" aria-hidden="true">1</div>
<h3>On your desk</h3>
<p>
Run a capture when you need fresh material, decrypt locally, play it back.
No server, nothing always-on.
</p>
<ul>
<li>One-shot captures</li>
<li>No VPS required</li>
</ul>
</article>
<article class="card">
<div class="step" aria-hidden="true">2</div>
<h3>Server plus a phone</h3>
<p>
Host the restreamer so channels stay up. Leave a phone plugged into the agent
and it refreshes keys on its own when something dies.
</p>
<ul>
<li>Dashboard and API on :8083</li>
<li>Agent watches health on a loop</li>
</ul>
</article>
<article class="card">
<div class="step" aria-hidden="true">3</div>
<h3>Join a swarm</h3>
<p>
A host publishes the channel list. Contributors point agents at it and cover
only the channels they actually subscribe to.
</p>
<ul>
<li>Nobody needs every platform</li>
<li>Any matching agent can heal a feed</li>
</ul>
</article>
</div>
<p class="note">
That is the point of a swarm: <strong>distributed ownership</strong>. One person
should not need every subscription. Many agents, many logins, one shared restream.
</p>
</div>
</section>
<section id="start">
<div class="shell">
<div class="section-head">
<span class="eyebrow">Quick start</span>
<h2>Four commands</h2>
<p>Go 1.25+ and Python 3.10+. A phone is only needed for the capture step.</p>
</div>
<pre class="code"><span class="c"># 1. build the one binary</span>
git clone https://git.nulldrm.com/nulldrm/Null-DRM-Official
go -C apps/cli build -o ../../bin/drm .
<span class="c"># 2. the CDM helper</span>
python -m venv .venv &amp;&amp; .venv/bin/pip install -r apps/wvkey/requirements.txt
<span class="c"># 3. see which app modules are compiled in</span>
./bin/drm modules
<span class="c"># 4. capture a channel</span>
./bin/drm capture --app rte --channel rteone --auto-play --wvd data/device.wvd</pre>
<p class="note">
A fresh clone ships no account ids, keys or hostnames — module code is public,
its values are not. <a href="docs.html?f=quickstart.md">The quickstart</a> walks
through filling in your own, and
<a href="docs.html?f=capture.md">the capture guide</a> shows how to discover them
for an app nobody has written a module for yet.
</p>
</div>
</section>
<section id="inside">
<div class="shell">
<div class="section-head">
<span class="eyebrow">What is inside</span>
<h2>One binary, a handful of parts</h2>
<p>Everything ships as <code>drm</code>. Each subcommand is one of these.</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Part</th><th>What it does</th></tr>
</thead>
<tbody>
<tr>
<td><code>drm capture</code></td>
<td>Drives the phone through the on-device proxy and writes a session: manifest, PSSH, keys.</td>
</tr>
<tr>
<td><code>drm catalog</code></td>
<td>Resolves a channel from a public playback catalogue, no phone involved.</td>
</tr>
<tr>
<td><code>drm serve</code></td>
<td>Control plane: REST API, SQLite, dashboard, and the restream supervisor.</td>
</tr>
<tr>
<td><code>drm agent</code></td>
<td>Polls the control plane, claims a free phone and refreshes dead channels.</td>
</tr>
<tr>
<td><code>drm proxy</code></td>
<td>Builds and installs the on-device HTTPS proxy, and records unknown apps.</td>
</tr>
<tr>
<td><code>drm modules</code></td>
<td>Lists the app modules this build contains and where each one reads its values.</td>
</tr>
</tbody>
</table>
</div>
<p class="note">
An app module is a small Go package plus a local values file. Adding one means a new
package and a single import line — no fork, no plugin runtime.
See <a href="docs.html?f=modules.md">writing a module</a>.
</p>
</div>
</section>
<section id="source">
<div class="shell">
<div class="section-head">
<span class="eyebrow">Source</span>
<h2>git.nulldrm.com</h2>
<p>
Open source, free forever: no paid tiers, no licence keys, no feature gates.
The official source is Forgejo at <code>nulldrm/Null-DRM-Official</code>.
</p>
</div>
<div class="stats" id="git-stats" aria-live="polite">
<div class="stat"><span class="stat-value" id="stat-stars">—</span><span class="stat-label">Stars</span></div>
<div class="stat"><span class="stat-value" id="stat-forks">—</span><span class="stat-label">Forks</span></div>
<div class="stat"><span class="stat-value" id="stat-issues">—</span><span class="stat-label">Issues</span></div>
<div class="stat"><span class="stat-value" id="stat-updated">—</span><span class="stat-label">Updated</span></div>
</div>
<div class="actions">
<a class="btn btn-primary" href="https://git.nulldrm.com/nulldrm/Null-DRM-Official" rel="noopener">Open Forgejo</a>
<a class="btn" href="https://git.nulldrm.com/nulldrm/Null-DRM-Official/releases" rel="noopener">Releases</a>
<a class="btn" href="https://t.me/+GZwQ7d_HD2w2ZTM8" rel="noopener" target="_blank">Telegram</a>
</div>
<h3 style="margin-top:32px">Latest releases</h3>
<div id="releases-status" class="status-msg">Loading releases…</div>
<ul class="releases" id="releases-list" hidden></ul>
</div>
</section>
</main>
<footer class="site-footer">
<div class="shell footer-row">
<span>nulldrm.com — self-hosted, free forever.</span>
<nav aria-label="Footer">
<a href="docs.html">Docs</a>
<a href="https://git.nulldrm.com/nulldrm/Null-DRM-Official" rel="noopener">Forgejo</a>
<a href="https://t.me/+GZwQ7d_HD2w2ZTM8" rel="noopener" target="_blank">Telegram</a>
</nav>
</div>
<div class="shell" style="margin-top:14px">
<p>Use only on services you are authorised to access, with content you have the right to decrypt.</p>
</div>
</footer>
<div id="tg-modal" class="tg-modal" hidden>
<div class="tg-backdrop" data-tg-dismiss></div>
<div class="tg-dialog" role="dialog" aria-modal="true" aria-labelledby="tg-title">
<button type="button" class="tg-close" data-tg-dismiss aria-label="Close">✕</button>
<h2 id="tg-title">Join the Telegram group</h2>
<p>Modules, swarm tips and open-source updates. Shown once — it is always in the menu.</p>
<div class="tg-actions">
<a class="btn btn-primary" id="tg-join" href="https://t.me/+GZwQ7d_HD2w2ZTM8" rel="noopener" target="_blank">Join Telegram</a>
<button type="button" class="btn" data-tg-dismiss>Not now</button>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

192
www/script.js Normal file
View file

@ -0,0 +1,192 @@
(() => {
const OWNER = "nulldrm";
const REPO = "Null-DRM-Official";
const API = "/git-api/api/v1/repos/" + OWNER + "/" + REPO;
const FORGEJO = "https://git.nulldrm.com/" + OWNER + "/" + REPO;
const TG_URL = "https://t.me/+GZwQ7d_HD2w2ZTM8";
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function shortDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function loadRepo() {
if (!document.getElementById("git-stats")) return;
try {
const res = await fetch(API);
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
setText("stat-stars", String(data.stars_count ?? data.stars ?? 0));
setText("stat-forks", String(data.forks_count ?? data.forks ?? 0));
setText("stat-issues", String(data.open_issues_count ?? data.open_issues ?? 0));
setText("stat-updated", shortDate(data.updated_at || data.pushed_at));
} catch {
["stat-stars", "stat-forks", "stat-issues", "stat-updated"].forEach((id) => setText(id, "—"));
}
}
async function loadReleases() {
const status = document.getElementById("releases-status");
const list = document.getElementById("releases-list");
if (!status || !list) return;
try {
const res = await fetch(API + "/releases?limit=5");
if (!res.ok) throw new Error("HTTP " + res.status);
const releases = await res.json();
if (!Array.isArray(releases) || releases.length === 0) {
status.textContent = "No releases published yet — clone the repo or watch it on Forgejo.";
status.hidden = false;
list.hidden = true;
return;
}
list.innerHTML = releases.map((r) => {
const title = escapeHtml(r.name || r.tag_name || "Release");
const tag = escapeHtml(r.tag_name || "");
const when = shortDate(r.published_at || r.created_at);
const url = escapeHtml(r.html_url || (FORGEJO + "/releases/tag/" + encodeURIComponent(r.tag_name || "")));
const body = (r.body || "").trim();
const preview = body
? '<p class="release-body">' + escapeHtml(body.slice(0, 180)) + (body.length > 180 ? "…" : "") + "</p>"
: "";
const assets = Array.isArray(r.assets) ? r.assets : [];
const assetLinks = assets.slice(0, 4).map((a) => {
const href = escapeHtml(a.browser_download_url || a.html_url || "#");
const name = escapeHtml(a.name || "asset");
return '<a href="' + href + '">' + name + "</a>";
}).join(" · ");
return (
"<li>" +
'<p class="release-title"><a href="' + url + '">' + title + "</a></p>" +
'<p class="release-meta">' + tag + (tag ? " · " : "") + when +
(assetLinks ? " · " + assetLinks : "") + "</p>" +
preview +
"</li>"
);
}).join("");
status.hidden = true;
list.hidden = false;
} catch {
status.innerHTML =
'Could not load releases. Open <a href="' + FORGEJO + '/releases" rel="noopener">Forgejo releases</a> directly.';
status.hidden = false;
list.hidden = true;
}
}
// Telegram invite: shown once per browser.
function initTelegramInvite() {
const KEY = "nulldrm_tg_seen";
const modal = document.getElementById("tg-modal");
if (!modal) return;
let seen = false;
try {
seen = localStorage.getItem(KEY) === "1";
} catch {
seen = true; // private mode: do not nag
}
const markSeen = () => {
try {
localStorage.setItem(KEY, "1");
} catch {
/* private mode */
}
};
// Belt and braces: the hidden attribute plus an inline display, so the
// overlay cannot survive a stylesheet that sets display on .tg-modal.
const close = () => {
markSeen();
modal.hidden = true;
modal.style.display = "none";
document.body.style.overflow = "";
};
const open = () => {
modal.hidden = false;
modal.style.display = "grid";
document.body.style.overflow = "hidden";
const join = document.getElementById("tg-join");
if (join) join.focus();
};
// Wire every dismiss control directly rather than relying on delegation.
modal.querySelectorAll("[data-tg-dismiss]").forEach((el) => {
el.addEventListener("click", (e) => {
e.preventDefault();
close();
});
});
// Clicking anywhere outside the dialog also closes it.
modal.addEventListener("click", (e) => {
const t = e.target;
if (t instanceof Element && !t.closest(".tg-dialog")) close();
});
const join = document.getElementById("tg-join");
if (join) join.addEventListener("click", close);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !modal.hidden) close();
});
if (seen) {
modal.hidden = true;
modal.style.display = "none";
} else {
open();
}
}
// Highlight the section currently in view.
function initNavHighlight() {
const links = Array.from(document.querySelectorAll('.site-nav a[href^="#"]'));
if (!links.length || !("IntersectionObserver" in window)) return;
const byId = new Map(links.map((a) => [a.getAttribute("href").slice(1), a]));
const sections = Array.from(byId.keys())
.map((id) => document.getElementById(id))
.filter(Boolean);
if (!sections.length) return;
const obs = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const link = byId.get(entry.target.id);
if (link) link.classList.toggle("active", entry.isIntersecting);
});
},
{ rootMargin: "-45% 0px -50% 0px" }
);
sections.forEach((s) => obs.observe(s));
}
loadRepo();
loadReleases();
initTelegramInvite();
initNavHighlight();
// Any Telegram link left without an href falls back to the canonical invite.
document.querySelectorAll('a[href^="https://t.me/"]').forEach((a) => {
if (!a.getAttribute("href")) a.setAttribute("href", TG_URL);
});
})();

381
www/styles.css Normal file
View file

@ -0,0 +1,381 @@
/* NULLDRM - flat surfaces, soft depth, one accent. Light only. */
:root {
color-scheme: light;
--accent: #4c6ef5;
--accent-ink: #ffffff;
--accent-soft: #eef1fe;
--accent-line: #c9d4fb;
/* white with a slight grey tint */
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f0f2f5;
--ink: #15181d;
--ink-2: #434a53;
--muted: #6b7280;
--line: #e4e7ec;
--line-strong: #d4d9e0;
--ok: #1a7f52;
--warn: #9a6a00;
/* the terminal card stays dark on purpose - it is a terminal */
--term-bg: #15181f;
--term-ink: #d9dee8;
--term-dim: #7b8595;
--term-ok: #6fd79b;
--term-url: #8fb6ff;
--term-redact: #5c6678;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 1px 2px rgba(16, 20, 30, 0.05), 0 6px 20px -8px rgba(16, 20, 30, 0.12);
--shadow-sm: 0 1px 2px rgba(16, 20, 30, 0.06);
--maxw: 1080px;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
/* The hidden attribute must win over any display rule below. */
[hidden] { display: none !important; }
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.65 var(--sans);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { line-height: 1.2; letter-spacing: -0.015em; margin: 0; }
h1 { font-size: clamp(2rem, 5vw, 3rem); font-weight: 680; }
h2 { font-size: clamp(1.3rem, 3vw, 1.6rem); font-weight: 650; }
h3 { font-size: 1.02rem; font-weight: 640; }
p { margin: 0; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre, kbd { font-family: var(--mono); }
code {
font-size: 0.88em;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 5px;
padding: 0.1em 0.38em;
}
.skip {
position: absolute;
left: -9999px;
}
.skip:focus {
left: 12px; top: 12px; z-index: 100;
background: var(--surface); color: var(--ink);
padding: 10px 14px; border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.shell { max-width: var(--maxw); margin: 0 auto; padding: 0 20px; }
/* ---------- header ---------- */
.site-header {
position: sticky; top: 0; z-index: 20;
background: rgba(246, 247, 249, 0.85);
backdrop-filter: saturate(140%) blur(10px);
border-bottom: 1px solid var(--line);
}
.header-row {
display: flex; align-items: center; gap: 20px;
min-height: 60px;
}
.brand {
display: inline-flex; align-items: center; gap: 10px;
color: var(--ink); font-weight: 660; letter-spacing: -0.02em;
}
.brand:hover { text-decoration: none; }
.mark {
width: 26px; height: 26px; border-radius: 7px;
background: var(--accent); color: var(--accent-ink);
display: grid; place-items: center;
font: 700 14px/1 var(--sans);
}
.site-nav { margin-left: auto; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.site-nav a {
color: var(--ink-2); padding: 7px 11px; border-radius: 7px;
font-size: 0.925rem;
}
.site-nav a:hover { background: var(--surface-2); color: var(--ink); text-decoration: none; }
.site-nav a.active { color: var(--accent); background: var(--accent-soft); }
/* ---------- sections ---------- */
section { padding: 56px 0; border-top: 1px solid var(--line); }
section:first-of-type { border-top: 0; }
.section-head { max-width: 62ch; margin-bottom: 28px; }
.section-head p { color: var(--muted); margin-top: 8px; }
.eyebrow {
display: block; font: 600 0.76rem/1 var(--sans);
letter-spacing: 0.09em; text-transform: uppercase;
color: var(--accent); margin-bottom: 10px;
}
/* ---------- hero ---------- */
.hero { padding: 72px 0 60px; }
.hero h1 { max-width: 20ch; }
.hero .tagline {
font-size: clamp(1.05rem, 2.4vw, 1.3rem);
color: var(--ink-2); margin-top: 16px; max-width: 48ch;
}
.pill {
display: inline-flex; align-items: center; gap: 8px;
font-size: 0.82rem; font-weight: 550; color: var(--ink-2);
background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 5px 13px; margin-bottom: 22px;
box-shadow: var(--shadow-sm);
}
.pill .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ok);
}
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
.btn {
display: inline-flex; align-items: center; gap: 8px;
font: 550 0.94rem/1 var(--sans);
padding: 11px 17px; border-radius: var(--radius-sm);
border: 1px solid var(--line-strong);
background: var(--surface); color: var(--ink);
box-shadow: var(--shadow-sm);
cursor: pointer;
}
.btn:hover { text-decoration: none; border-color: var(--muted); }
.btn-primary {
background: var(--accent); border-color: transparent; color: var(--accent-ink);
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0));
}
.btn-primary:hover { filter: brightness(1.06); }
/* ---------- cards ---------- */
.grid { display: grid; gap: 16px; }
.grid-3 { grid-template-columns: repeat(auto-fit, minmax(255px, 1fr)); }
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 22px;
box-shadow: var(--shadow);
}
.card h3 { margin-bottom: 8px; }
.card p { color: var(--ink-2); font-size: 0.945rem; }
.card ul { margin: 14px 0 0; padding-left: 18px; color: var(--muted); font-size: 0.9rem; }
.card li + li { margin-top: 5px; }
.step {
display: grid; place-items: center;
width: 25px; height: 25px; border-radius: 7px;
background: var(--accent-soft); color: var(--accent);
border: 1px solid var(--accent-line);
font: 650 0.82rem/1 var(--mono);
margin-bottom: 13px;
}
.note {
margin-top: 20px; padding: 15px 18px;
background: var(--accent-soft);
border: 1px solid var(--accent-line);
border-radius: var(--radius-sm);
color: var(--ink-2); font-size: 0.93rem;
}
/* ---------- terminal ---------- */
.terminal {
background: var(--term-bg);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.term-bar {
display: flex; align-items: center; gap: 9px;
padding: 11px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.03);
}
.term-bar .lamp { width: 10px; height: 10px; border-radius: 50%; background: #39404e; }
.term-bar .name {
margin-left: 6px; color: var(--term-dim);
font: 500 0.8rem/1 var(--mono);
}
.terminal pre {
margin: 0; padding: 18px;
overflow-x: auto;
color: var(--term-ink);
font-size: 0.815rem; line-height: 1.75;
tab-size: 2;
}
.terminal .t-dim { color: var(--term-dim); }
.terminal .t-ok { color: var(--term-ok); }
.terminal .t-url { color: var(--term-url); }
.terminal .t-redact { color: var(--term-redact); }
.terminal .t-prompt { color: var(--term-ok); }
.term-legend {
margin-top: 14px; color: var(--muted); font-size: 0.88rem;
max-width: 68ch;
}
/* ---------- code blocks ---------- */
pre.code {
margin: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 18px;
overflow-x: auto;
font-size: 0.85rem; line-height: 1.75;
box-shadow: var(--shadow-sm);
color: var(--ink);
}
pre.code .c { color: var(--muted); }
/* ---------- table ---------- */
.table-wrap {
border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden; background: var(--surface); box-shadow: var(--shadow-sm);
}
table { width: 100%; border-collapse: collapse; font-size: 0.93rem; }
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { background: var(--surface-2); font-weight: 600; font-size: 0.8rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
tbody tr:last-child td { border-bottom: 0; }
td code { white-space: nowrap; }
td p { color: var(--ink-2); }
/* ---------- source / stats ---------- */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 20px; }
.stat {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius-sm); padding: 15px 16px; box-shadow: var(--shadow-sm);
}
.stat-value { display: block; font: 650 1.4rem/1.1 var(--sans); letter-spacing: -0.02em; }
.stat-label { display: block; margin-top: 5px; color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.06em; }
.releases { list-style: none; margin: 16px 0 0; padding: 0; }
.releases li { padding: 14px 0; border-top: 1px solid var(--line); }
.release-title { font-weight: 600; }
.release-meta { color: var(--muted); font-size: 0.85rem; margin-top: 3px; }
.release-body { color: var(--ink-2); font-size: 0.9rem; margin-top: 7px; }
.status-msg { color: var(--muted); font-size: 0.92rem; }
/* ---------- footer ---------- */
.site-footer {
border-top: 1px solid var(--line);
padding: 32px 0 48px;
color: var(--muted); font-size: 0.9rem;
}
.footer-row { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; }
.footer-row nav { display: flex; gap: 18px; margin-left: auto; flex-wrap: wrap; }
/* ---------- docs page ---------- */
.docs-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 28px; align-items: start; }
.docs-nav { position: sticky; top: 80px; }
.docs-nav h3 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); margin-bottom: 10px; }
.docs-nav ul { list-style: none; margin: 0; padding: 0; }
.docs-nav li + li { margin-top: 2px; }
.docs-nav a {
display: block; padding: 7px 10px; border-radius: 7px;
color: var(--ink-2); font-size: 0.9rem;
}
.docs-nav a:hover { background: var(--surface-2); color: var(--ink); text-decoration: none; }
.docs-nav a.active { background: var(--accent-soft); color: var(--accent); font-weight: 550; }
/* min-width:0 so wide code blocks scroll instead of stretching the grid. */
.docs-main { min-width: 0; }
.docs-toolbar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding-bottom: 12px; margin-bottom: 20px; border-bottom: 1px solid var(--line);
}
.docs-toolbar strong { font-family: var(--mono); font-size: 0.9rem; font-weight: 600; }
.docs-toolbar a { margin-left: auto; font-size: 0.88rem; }
.docs-content { max-width: 74ch; }
.docs-content > *:first-child { margin-top: 0; }
.docs-content h1 { font-size: 1.85rem; margin: 36px 0 14px; }
.docs-content h2 { font-size: 1.32rem; margin: 34px 0 12px; padding-top: 14px; border-top: 1px solid var(--line); }
.docs-content h3 { font-size: 1.04rem; margin: 24px 0 8px; }
.docs-content p, .docs-content ul, .docs-content ol { margin: 0 0 14px; }
.docs-content ul, .docs-content ol { padding-left: 22px; }
.docs-content li + li { margin-top: 4px; }
.docs-content pre {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 15px; overflow-x: auto; font-size: 0.84rem; line-height: 1.7; margin: 0 0 16px;
}
.docs-content pre code { background: none; border: 0; padding: 0; font-size: inherit; }
.docs-content table { margin: 0 0 18px; border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; }
.docs-content blockquote {
margin: 0 0 16px; padding: 2px 0 2px 16px;
border-left: 3px solid var(--accent-line); color: var(--ink-2);
}
.docs-content hr { border: 0; border-top: 1px solid var(--line); margin: 28px 0; }
.docs-content img { max-width: 100%; }
.docs-content input[type="checkbox"] { margin-right: 6px; }
/* ---------- telegram invite ---------- */
.tg-modal[hidden] { display: none !important; }
.tg-modal { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; }
.tg-backdrop { position: absolute; inset: 0; background: rgba(21, 24, 29, 0.4); backdrop-filter: blur(3px); }
.tg-dialog {
position: relative; width: 100%; max-width: 400px;
background: var(--surface); border: 1px solid var(--line-strong);
border-radius: var(--radius); padding: 26px;
box-shadow: 0 20px 60px -14px rgba(16, 20, 30, 0.3);
}
.tg-dialog h2 { font-size: 1.2rem; margin-bottom: 8px; }
.tg-dialog p { color: var(--ink-2); font-size: 0.93rem; }
.tg-actions { display: flex; gap: 10px; margin-top: 22px; flex-wrap: wrap; }
.tg-close {
position: absolute; top: 12px; right: 12px;
width: 30px; height: 30px; border-radius: 7px;
border: 1px solid var(--line); background: var(--surface);
color: var(--muted); font-size: 1rem; line-height: 1; cursor: pointer;
}
.tg-close:hover { color: var(--ink); background: var(--surface-2); }
/* ---------- responsive ---------- */
@media (max-width: 820px) {
.docs-layout { grid-template-columns: 1fr; }
.docs-nav { position: static; }
.docs-nav ul { display: flex; flex-wrap: wrap; gap: 4px; }
}
@media (max-width: 640px) {
.shell { padding: 0 16px; }
section { padding: 44px 0; }
.hero { padding: 48px 0 40px; }
.header-row { min-height: 54px; gap: 10px; }
.site-nav { gap: 0; }
.site-nav a { padding: 6px 8px; font-size: 0.875rem; }
.card { padding: 18px; }
.terminal pre { font-size: 0.74rem; padding: 14px; }
.footer-row nav { margin-left: 0; }
}

6
www/vendor/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long