From 9d06660901f0206d050a8bb030d571879fc9d914 Mon Sep 17 00:00:00 2001 From: hibna Date: Sat, 25 Apr 2026 23:58:58 +0300 Subject: [PATCH] Initial commit --- .gitea/workflows/build.yml | 47 +++++++++++++ .gitignore | 3 + README.md | 108 ++++++++++++++++++++++++++++++ package.json | 18 +++++ presences/spotify/metadata.json | 10 +++ presences/spotify/src/presence.ts | 51 ++++++++++++++ presences/youtube/metadata.json | 13 ++++ presences/youtube/src/presence.ts | 69 +++++++++++++++++++ scripts/build.mjs | 72 ++++++++++++++++++++ scripts/generate-index.mjs | 54 +++++++++++++++ scripts/sdk-runtime.mjs | 14 ++++ tsconfig.json | 17 +++++ types/presence-sdk.d.ts | 52 ++++++++++++++ 13 files changed, 528 insertions(+) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 presences/spotify/metadata.json create mode 100644 presences/spotify/src/presence.ts create mode 100644 presences/youtube/metadata.json create mode 100644 presences/youtube/src/presence.ts create mode 100644 scripts/build.mjs create mode 100644 scripts/generate-index.mjs create mode 100644 scripts/sdk-runtime.mjs create mode 100644 tsconfig.json create mode 100644 types/presence-sdk.d.ts diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..4a4b18c --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..60dda0b --- /dev/null +++ b/README.md @@ -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//metadata.json` — presence manifest. +- `presences//dist/presence.js` — built CJS module (produced by `npm run build`). +- `presences//` — 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//src +cat > presences//metadata.json <<'JSON' +{ + "id": "", + "name": "", + "description": "", + "version": "1.0.0", + "author": "", + "match": ["https://example.com/*"], + "tickInterval": 1000, + "sdkVersion": "^0.1.0" +} +JSON +``` + +Write your TypeScript entry at `presences//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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..bf8adc3 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/presences/spotify/metadata.json b/presences/spotify/metadata.json new file mode 100644 index 0000000..25d6b7d --- /dev/null +++ b/presences/spotify/metadata.json @@ -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" +} diff --git a/presences/spotify/src/presence.ts b/presences/spotify/src/presence.ts new file mode 100644 index 0000000..7433470 --- /dev/null +++ b/presences/spotify/src/presence.ts @@ -0,0 +1,51 @@ +import { ActivityType, definePresence } from "@source/presence-sdk"; + +function readNowPlayingTitle(): string | null { + return ( + document + .querySelector('[data-testid="now-playing-widget"] [data-testid="context-item-info-title"]') + ?.textContent?.trim() ?? null + ); +} + +function readArtist(): string | null { + const artistNodes = document.querySelectorAll( + '[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( + '[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, + }); + }, +}); diff --git a/presences/youtube/metadata.json b/presences/youtube/metadata.json new file mode 100644 index 0000000..de5c4c7 --- /dev/null +++ b/presences/youtube/metadata.json @@ -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" +} diff --git a/presences/youtube/src/presence.ts b/presences/youtube/src/presence.ts new file mode 100644 index 0000000..5cbfc59 --- /dev/null +++ b/presences/youtube/src/presence.ts @@ -0,0 +1,69 @@ +import { ActivityType, definePresence } from "@source/presence-sdk"; + +function findVideo(): HTMLVideoElement | null { + return document.querySelector("video.html5-main-video, video"); +} + +function readTitle(): string | null { + const titleNode = + document.querySelector("h1.ytd-watch-metadata yt-formatted-string") ?? + document.querySelector("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("ytd-channel-name #text a") ?? + document.querySelector("#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), + }); + }, +}); diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..58373d8 --- /dev/null +++ b/scripts/build.mjs @@ -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(); diff --git a/scripts/generate-index.mjs b/scripts/generate-index.mjs new file mode 100644 index 0000000..77475e2 --- /dev/null +++ b/scripts/generate-index.mjs @@ -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(); diff --git a/scripts/sdk-runtime.mjs b/scripts/sdk-runtime.mjs new file mode 100644 index 0000000..ea8c2bf --- /dev/null +++ b/scripts/sdk-runtime.mjs @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c46cba0 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/types/presence-sdk.d.ts b/types/presence-sdk.d.ts new file mode 100644 index 0000000..27c5b11 --- /dev/null +++ b/types/presence-sdk.d.ts @@ -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(key: string): Promise; + set(key: string, value: unknown): Promise; + delete(key: string): Promise; + clear(): Promise; +} + +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; + tick?: (ctx: PresenceContext) => void | Promise; + onUnload?: (ctx: PresenceContext) => void | Promise; +} + +export declare function definePresence(def: PresenceDefinition): PresenceDefinition; +export type { PresenceDefinition as Presence };