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:
commit
2fa8f2435f
121 changed files with 17802 additions and 0 deletions
82
www/docs.html
Normal file
82
www/docs.html
Normal 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
163
www/docs.js
Normal 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
326
www/index.html
Normal 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></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 -> /data/local/tmp/appproxy_cap.json
|
||||
<span class="t-dim">[*]</span> Setting HTTP proxy -> 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&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 && .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
192
www/script.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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
381
www/styles.css
Normal 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
6
www/vendor/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue