Initial commit

This commit is contained in:
hibna
2026-04-25 23:58:58 +03:00
commit 9d06660901
13 changed files with 528 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
name: Build presences
on:
push:
branches: [main]
paths:
- "presences/**"
- "scripts/**"
- "package.json"
- "package-lock.json"
- ".gitea/workflows/build.yml"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm install
- name: Build all presences
run: npm run build
- name: Generate index.json
run: npm run index
- name: Commit and push artifacts
env:
BUILD_USER: gitea-actions
BUILD_EMAIL: actions@gits.hibna.com.tr
run: |
git config user.name "$BUILD_USER"
git config user.email "$BUILD_EMAIL"
git add presences/*/dist/presence.js index.json
if git diff --cached --quiet; then
echo "No build artifacts changed."
exit 0
fi
git commit -m "ci: rebuild presence artifacts"
git push origin HEAD:main
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
+108
View File
@@ -0,0 +1,108 @@
# Source Presences
Public registry of Source Presence extensions, served from
[`gits.hibna.com.tr/hibna/source-presences`](https://gits.hibna.com.tr/hibna/source-presences).
The Source Presence browser extension reads:
- `index.json` — list of presences and their current versions.
- `presences/<id>/metadata.json` — presence manifest.
- `presences/<id>/dist/presence.js` — built CJS module (produced by `npm run build`).
- `presences/<id>/<icon>` — optional 128×128 PNG.
Both `index.json` and each presence's `dist/presence.js` are committed by the
Gitea Actions workflow at [`.gitea/workflows/build.yml`](.gitea/workflows/build.yml)
on every push to `main`.
## Add a new presence
```bash
mkdir -p presences/<id>/src
cat > presences/<id>/metadata.json <<'JSON'
{
"id": "<id>",
"name": "<Display Name>",
"description": "<short description>",
"version": "1.0.0",
"author": "<your name>",
"match": ["https://example.com/*"],
"tickInterval": 1000,
"sdkVersion": "^0.1.0"
}
JSON
```
Write your TypeScript entry at `presences/<id>/src/presence.ts`:
```ts
import { ActivityType, definePresence } from "@source/presence-sdk";
export default definePresence({
match: ["https://example.com/*"],
tick(ctx) {
ctx.setActivity({
type: ActivityType.WATCHING,
name: "Example",
details: document.title,
});
},
});
```
Push to `main`. Gitea Actions builds the presence, regenerates `index.json`,
and commits the artifacts back. The extension picks up the new version on its
next 6-hour refresh, or immediately when users hit "Refresh" / "Update all".
## Local build
```bash
npm install
npm run build # builds every presence under presences/*
npm run index # regenerates index.json
npm run release # both
```
## Presence API
`PresenceDefinition` shape (also defined in [`types/presence-sdk.d.ts`](types/presence-sdk.d.ts)):
| Field | Required | Description |
|----------------|----------|--------------------------------------------------------------------|
| `match` | yes | Array of [match patterns](https://developer.chrome.com/docs/extensions/reference/match_patterns). |
| `tickInterval` | no | Override default 1000ms tick. |
| `onLoad(ctx)` | no | Runs once when a matching tab loads the presence. |
| `tick(ctx)` | no | Runs every `tickInterval`; call `ctx.setActivity(...)` to publish. |
| `onUnload(ctx)`| no | Runs when the presence is torn down (tab nav / disable). |
`PresenceContext`:
- `ctx.url` / `ctx.tabActive` — read-only state.
- `ctx.setActivity(activity)` / `ctx.clear()` — publish or clear.
- `ctx.storage` — namespaced async storage (`get` / `set` / `delete` / `clear`).
- `ctx.log` / `warn` / `error` — surfaced in the extension's background console.
## Activity payload
```ts
{
type: ActivityType.PLAYING | LISTENING | WATCHING,
name: string,
details?: string,
state?: string,
url?: string,
largeImage?: string,
largeText?: string,
smallImage?: string,
smallText?: string,
startedAt?: number, // unix ms — Source renders an elapsed timer
endsAt?: number, // unix ms — Source renders a remaining timer
}
```
## Notes
- Presences run in the **content script's isolated world** with full DOM read
access. They cannot access page JS globals.
- The extension debounces identical payloads; calling `setActivity` with the
same data on every tick is fine.
- Keep presence builds small (< 100 KB). They're shipped with every install.
+18
View File
@@ -0,0 +1,18 @@
{
"name": "source-presences",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "node scripts/build.mjs",
"index": "node scripts/generate-index.mjs",
"release": "npm run build && npm run index"
},
"devDependencies": {
"esbuild": "^0.24.2",
"typescript": "^5.7.3"
},
"dependencies": {
"@source/presence-sdk": "0.1.0"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "spotify",
"name": "Spotify Web",
"description": "Reflects what you're listening to on open.spotify.com.",
"version": "1.0.0",
"author": "hibna",
"match": ["https://open.spotify.com/*"],
"tickInterval": 1500,
"sdkVersion": "^0.1.0"
}
+51
View File
@@ -0,0 +1,51 @@
import { ActivityType, definePresence } from "@source/presence-sdk";
function readNowPlayingTitle(): string | null {
return (
document
.querySelector<HTMLElement>('[data-testid="now-playing-widget"] [data-testid="context-item-info-title"]')
?.textContent?.trim() ?? null
);
}
function readArtist(): string | null {
const artistNodes = document.querySelectorAll<HTMLElement>(
'[data-testid="now-playing-widget"] [data-testid="context-item-info-artist"]',
);
if (artistNodes.length === 0) return null;
return Array.from(artistNodes).map((n) => n.textContent?.trim()).filter(Boolean).join(", ") || null;
}
function readPlayState(): "playing" | "paused" {
const button = document.querySelector<HTMLButtonElement>(
'[data-testid="control-button-playpause"]',
);
const label = button?.getAttribute("aria-label")?.toLowerCase() ?? "";
if (label.includes("pause")) return "playing";
return "paused";
}
export default definePresence({
match: ["https://open.spotify.com/*"],
tickInterval: 1500,
tick(ctx) {
const title = readNowPlayingTitle();
if (!title) {
ctx.setActivity({
type: ActivityType.LISTENING,
name: "Spotify",
details: "Browsing",
});
return;
}
const artist = readArtist();
const state = readPlayState();
ctx.setActivity({
type: ActivityType.LISTENING,
name: "Spotify",
details: title,
state: artist ? `${state === "playing" ? "Playing" : "Paused"} · ${artist}` : state === "playing" ? "Playing" : "Paused",
url: location.href,
});
},
});
+13
View File
@@ -0,0 +1,13 @@
{
"id": "youtube",
"name": "YouTube",
"description": "Shows the video you're watching on YouTube as a Source rich presence.",
"version": "1.0.0",
"author": "hibna",
"match": [
"https://www.youtube.com/*",
"https://music.youtube.com/*"
],
"tickInterval": 1000,
"sdkVersion": "^0.1.0"
}
+69
View File
@@ -0,0 +1,69 @@
import { ActivityType, definePresence } from "@source/presence-sdk";
function findVideo(): HTMLVideoElement | null {
return document.querySelector<HTMLVideoElement>("video.html5-main-video, video");
}
function readTitle(): string | null {
const titleNode =
document.querySelector<HTMLElement>("h1.ytd-watch-metadata yt-formatted-string") ??
document.querySelector<HTMLElement>("h1.title yt-formatted-string");
const text = titleNode?.textContent?.trim();
if (text) return text;
const fallback = document.title.replace(/\s*-\s*YouTube\s*$/i, "").trim();
return fallback.length > 0 ? fallback : null;
}
function readChannel(): string | null {
const node =
document.querySelector<HTMLElement>("ytd-channel-name #text a") ??
document.querySelector<HTMLElement>("#owner #channel-name a");
return node?.textContent?.trim() ?? null;
}
export default definePresence({
match: ["https://www.youtube.com/*", "https://music.youtube.com/*"],
tickInterval: 1000,
tick(ctx) {
const video = findVideo();
const isMusic = location.hostname === "music.youtube.com";
if (location.pathname === "/" || location.pathname === "/feed/subscriptions") {
ctx.setActivity({
type: ActivityType.WATCHING,
name: isMusic ? "YouTube Music" : "YouTube",
details: "Browsing",
});
return;
}
if (!video) {
ctx.setActivity({
type: ActivityType.WATCHING,
name: isMusic ? "YouTube Music" : "YouTube",
details: document.title.slice(0, 128),
});
return;
}
const title = readTitle();
if (!title) {
ctx.clear();
return;
}
const channel = readChannel();
const paused = video.paused;
const liveBadge = document.querySelector(".ytp-live") !== null;
ctx.setActivity({
type: isMusic ? ActivityType.LISTENING : ActivityType.WATCHING,
name: isMusic ? "YouTube Music" : "YouTube",
details: title,
state: channel ? `${paused ? "Paused" : liveBadge ? "Live" : "Playing"} · ${channel}` : paused ? "Paused" : liveBadge ? "Live" : "Playing",
url: location.href,
startedAt: paused || liveBadge ? null : Date.now() - Math.floor(video.currentTime * 1000),
endsAt: paused || liveBadge || !Number.isFinite(video.duration) ? null : Date.now() + Math.floor((video.duration - video.currentTime) * 1000),
});
},
});
+72
View File
@@ -0,0 +1,72 @@
import { readdir, readFile, mkdir, writeFile, stat } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import * as esbuild from "esbuild";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const PRESENCES_DIR = path.join(ROOT, "presences");
const SDK_RUNTIME = path.join(ROOT, "scripts", "sdk-runtime.mjs");
async function listPresences() {
const entries = await readdir(PRESENCES_DIR, { withFileTypes: true });
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
}
async function readMetadata(presenceDir) {
const metaPath = path.join(presenceDir, "metadata.json");
const json = JSON.parse(await readFile(metaPath, "utf8"));
return json;
}
async function findEntry(presenceDir) {
const candidates = ["src/presence.ts", "src/index.ts", "presence.ts", "index.ts"];
for (const candidate of candidates) {
const full = path.join(presenceDir, candidate);
try {
await stat(full);
return full;
} catch {
// try next
}
}
throw new Error(`No entry script found in ${presenceDir} (tried ${candidates.join(", ")})`);
}
async function buildPresence(slug) {
const presenceDir = path.join(PRESENCES_DIR, slug);
const meta = await readMetadata(presenceDir);
if (meta.id !== slug) {
throw new Error(`metadata.id "${meta.id}" does not match folder name "${slug}"`);
}
const entry = await findEntry(presenceDir);
const distDir = path.join(presenceDir, "dist");
await mkdir(distDir, { recursive: true });
await esbuild.build({
entryPoints: [entry],
outfile: path.join(distDir, "presence.js"),
bundle: true,
platform: "browser",
format: "cjs",
target: ["chrome120", "firefox115"],
minify: true,
legalComments: "none",
alias: {
"@source/presence-sdk": SDK_RUNTIME,
},
});
console.log(`${slug} v${meta.version}`);
}
async function main() {
const list = await listPresences();
if (list.length === 0) {
console.log("No presences found.");
return;
}
for (const slug of list) {
await buildPresence(slug);
}
}
await main();
+54
View File
@@ -0,0 +1,54 @@
import { readdir, readFile, writeFile, stat } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const PRESENCES_DIR = path.join(ROOT, "presences");
async function listPresences() {
const entries = await readdir(PRESENCES_DIR, { withFileTypes: true });
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
}
async function readMetadata(presenceDir) {
return JSON.parse(await readFile(path.join(presenceDir, "metadata.json"), "utf8"));
}
async function ensureDistExists(presenceDir, slug) {
const distFile = path.join(presenceDir, "dist", "presence.js");
try {
await stat(distFile);
} catch {
throw new Error(`dist/presence.js missing for ${slug} — run "npm run build" first`);
}
}
async function main() {
const slugs = await listPresences();
const entries = [];
for (const slug of slugs) {
const dir = path.join(PRESENCES_DIR, slug);
const meta = await readMetadata(dir);
await ensureDistExists(dir, slug);
entries.push({
id: meta.id,
name: meta.name,
description: meta.description,
version: meta.version,
author: meta.author,
match: meta.match,
...(meta.icon ? { icon: meta.icon } : {}),
...(typeof meta.tickInterval === "number" ? { tickInterval: meta.tickInterval } : {}),
});
}
entries.sort((a, b) => a.id.localeCompare(b.id));
const index = {
generatedAt: new Date().toISOString(),
presences: entries,
};
await writeFile(path.join(ROOT, "index.json"), JSON.stringify(index, null, 2) + "\n");
console.log(`Wrote index.json (${entries.length} presence${entries.length === 1 ? "" : "s"})`);
}
await main();
+14
View File
@@ -0,0 +1,14 @@
// Runtime stand-in for `@source/presence-sdk` when bundling presences.
// definePresence is just identity; the extension's content-script runtime
// passes a PresenceContext to lifecycle methods, so the package itself only
// needs to export constants and a pass-through helper.
export const ActivityType = Object.freeze({
PLAYING: 0,
LISTENING: 2,
WATCHING: 3,
});
export function definePresence(def) {
return def;
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": [],
"skipLibCheck": true,
"paths": {
"@source/presence-sdk": ["./types/presence-sdk.d.ts"]
}
},
"include": ["presences/**/*.ts", "types/**/*.d.ts"]
}
+52
View File
@@ -0,0 +1,52 @@
// Local copy of the @source/presence-sdk type contract used by presences in this repo.
// Keep in sync with src/presence-sdk/index.ts in the extension.
export declare const ActivityType: {
readonly PLAYING: 0;
readonly LISTENING: 2;
readonly WATCHING: 3;
};
export type ActivityTypeValue = (typeof ActivityType)[keyof typeof ActivityType];
export interface PresenceActivityInput {
type: ActivityTypeValue;
name: string;
details?: string | null;
state?: string | null;
url?: string | null;
largeImage?: string | null;
largeText?: string | null;
smallImage?: string | null;
smallText?: string | null;
startedAt?: number | null;
endsAt?: number | null;
}
export interface PresenceStorage {
get<T = unknown>(key: string): Promise<T | undefined>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<void>;
clear(): Promise<void>;
}
export interface PresenceContext {
readonly url: string;
readonly tabActive: boolean;
readonly storage: PresenceStorage;
setActivity(activity: PresenceActivityInput | null): void;
clear(): void;
log(...args: unknown[]): void;
warn(...args: unknown[]): void;
error(...args: unknown[]): void;
}
export interface PresenceDefinition {
match: string[];
tickInterval?: number;
onLoad?: (ctx: PresenceContext) => void | Promise<void>;
tick?: (ctx: PresenceContext) => void | Promise<void>;
onUnload?: (ctx: PresenceContext) => void | Promise<void>;
}
export declare function definePresence(def: PresenceDefinition): PresenceDefinition;
export type { PresenceDefinition as Presence };