Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Vendored
+52
@@ -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 };
|
||||
Reference in New Issue
Block a user