Drip: arcade games and visual effects with sound, played over Claude's window. For Claude Code on a Mac. Free: every game and twenty drips (confetti…

Drip plays games and drips over Claude's window, with sound. Arcade games, endless and harder as they go, your best score to beat: Missiles comes with it, and the gallery has Blocks, Bricks, Snake, Solitaire, Sudoku and more. Twenty drips: visual effects like confetti, fireworks, hearts and a matrix rain. Ask Claude and it makes you new ones.
/drip
/drip blocks
/drip confetti
/drip 3 hearts stars
Every game and every drip is free: no account, no subscription, no purchase.
Drip is a mod for Claude Code on a Mac: the claude command in your terminal, the Code tab of the Claude desktop app, or Claude Code in VS Code. It does not work in Claude's chat, in Cowork, on the phone, on Windows or on Linux.
That's not a gap we're filling later; it's what the product is. Games and drips play in a transparent window that Drip opens over Claude's own window and keeps lined up with it as you move it, so a game sits right over your conversation and confetti falls over the answer. Only Claude Code lets a mod do that: it runs mods on your machine, gives them a side panel, a slash command and a button by the prompt, and lets them start that overlay. Chat and Cowork run skills, not mods, and have nothing to draw over. The overlay itself is a small macOS program. So it's Claude Code, on a Mac, and we'd rather say so than pretend otherwise.
xcode-select --install). Drip builds its small overlay program with them, to play games and drips over Claude's window or the whole screen. Without them, nothing plays: it tells you once what to install.In Claude Code, run:
/plugin marketplace add timmetim/drip
/plugin install drip@drip
Upgrading from 1.x? Before 2.0.0 Drip had another name and came from another marketplace. Uninstall that one (/plugin), then install as above. The first time 2.0.0 starts it brings over your saved drips, your stars, your best scores and your own games and drips, by itself; set your /config rows again.
To let Drip open its page in the desktop app's Browser pane without asking you each time, allow this call in ~/.claude/settings.json:
"permissions": {
"allow": ["mcp__Claude_Browser__preview_start"]
}
If you don't, and the app refuses the call, Drip asks Claude to open the page for you.
| Command | What it does |
|---|---|
/drip | Opens the Drip pane: every game and drip |
/drip <game> | Starts a game: /drip blocks, /drip missiles |
/drip <recipe> | Plays a drip, or one of your saved drips by name |
/drip list | Lists the games and the drips |
/drip save <name> <recipe> | Saves a recipe under a name |
/drip delete <name> | Deletes a saved drip |
/drip stop | Stops every effect that keeps playing |
/drip page | Opens the Drips page: your saved drips and an editor |
/drip runs straight away, even while Claude is working.
/drip (or /drip list) opens the Drip pane in the side panel: a search, a filter by type and a table, fifteen rows a page. Each row is an emoji, the name, one line, the type, ☆ and a button: Play for what's on your Mac, Install for what the gallery has that isn't yet (one press fetches it, and the row turns to Play). Click a name for its page: a screenshot, its line, and Play. The ☆ puts it in the tray above the prompt, opened by the drip button. One game plays at a time; drips play over anything, as many as you like.
Each game plays over Claude's window and takes the keyboard and mouse while it's on. Esc quits, M mutes. Each is endless and gets harder as it goes; your top score is kept and drawn in the next round, to beat.
Ask Claude: "make me a Snake game", "make a snow drip". Drip installs two skills that show Claude how, with a template and a checker. Your games go in ~/.drip/games/<name>/ and your drips in ~/.drip/drips/<name>/, and they show up on the card straight away.
To share one, send the folder; whoever gets it puts it in their own ~/.drip/games/ or ~/.drip/drips/. A game or drip from someone else is code that runs on your Mac, so only add ones from people you trust. Drip loads each one with the network blocked.
A drip button sits by the prompt: in the terminal, in the hint line under it; in the desktop app, in the prompt's footer. Press it and a row opens just above the prompt: fireworks, confetti, hearts, stars, and powow, then your saved drips, then ■ Stop while something keeps playing, and ✕ to close the row. A press plays the effect the way /drip does, where your Where effects play setting says. To hide the button, turn off Drip button under Drip in /config.
A recipe is a few words, in any order:
fireworks, hearts, confetti, stars, rain, bubbles, sparkle, spiral, emoji, wave, thumbsup, peace, cat, powow, and the backgrounds matrix, aurora, nebula, plasma, fireflies, starfield. Name several and they play one after another.0.4).-1: keep playing. The effect stays until you play the same recipe again, run /drip stop, or press Stop in the Drips page. Nothing plays for longer than 30 minutes./drip alone plays fireworks.
Open /config and find Where effects play under Drip:
Effects never take the pointer or the keyboard: you can keep working through them. The first effect over Claude's window or the screen takes a few seconds longer, while Drip builds its small overlay program with Swift. It's built once and kept in ~/Library/Caches/drip-overlay. Drip needs no extra permission for this: it reads only where Claude's window is and how big, never what's in it, so macOS doesn't ask for Screen Recording.
Before version 1.1, claude-window meant the page in the Browser pane. If you chose it then, effects now play over Claude's window itself. Version 1.1 had a third choice, browser-pane, that played effects in the Drips page; it's gone since 1.2, and a stored browser-pane plays over Claude's window.
Previews from the Drips page play where every effect plays, and a preview that keeps playing stops by itself after 12 seconds.
Every effect has its own sound: rockets whistle up and boom in fireworks, a popper goes off in confetti, the cat meows, and the backgrounds play something soft and slow for as long as they stay. Every sound is made as it plays; none is a recording. To turn them off, open /config and turn off Sounds under Drip. The faint matrix that plays while Claude's tools run is always silent.
/drip page opens the page. Your drips lists each saved drip with its recipe and a Play button. Search by name or recipe, or click + Add to make one. Click a drip to edit it:
Leaving the editor stops whatever it played.
Claude has two tools: play_drip plays a recipe (or stops them), and list_drips lists the effects and your saved drips. Ask Claude to celebrate and it may.
Two more rows in /config, both off until you turn them on:
Everything stays on your machine. Your saved drips are kept in Claude Code's store for Drip, with your best scores. Your own games and drips are in ~/.drip/. The overlay program is kept in ~/Library/Caches/drip-overlay.
127.0.0.1, on a random port. Every request must carry a random key made for that session. The server refuses any other host name or origin, and the page can't be framed by another site.native/overlay.swift). It shows one effect, ignores the pointer, closes when the effect ends or after its time limit (never more than 30 minutes), and quits if Claude Code goes away. To find Claude's window it reads only each window's owner, layer and position, never a title or what a window shows.Its source is this folder of timmetim/drip. With CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, claude plugin test plugins/drip runs its tests and claude plugin validate plugins/drip checks it.
Each drip is one file in hooks/drips/effects/ that builds its whole document, listed once in hooks/drips/effects/index.ts. A new drip is a new file there and a line in that list.
hooks/register.tsx 13 lines1// Drip: games and drips (visual effects) over Claude's window or the
2// whole screen, free. The games' half goes first: it takes /drip <game> and
3// the pane and passes everything else to the drips' half.
4import type { Register } from 'claude-code'
5
6import { arcade } from './arcade/hooks.tsx'
7import { drips } from './drips/hooks.tsx'
8
9export const register: Register = (on, options) => {
10 arcade(on, options)
11 drips(on, options)
12}
13hooks/arcade/hooks.tsx 640 lines1// Games, wired to the engine: the one file of this module that holds
2// `$` and the mod's state (the engine follows `$` and atoms only within the
3// file whose hook received them), handing the rest closures. /drip lists the
4// games and starts one in the shared native overlay (../overlay), over
5// Claude's window or the whole screen as the /config row `display` says; the
6// overlay takes the pointer and keys while a game is on. Each game posts its
7// score as it goes, and the top one is kept for the next round to beat.
8// /drip (or /drip list) opens the Drip pane: every game and drip, here or in the
9// gallery, each with who made it and Play (Install for the gallery's). Every
10// game and drip is free.
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, On, PluginOptions } from 'claude-code'
13
14import { ancestorsOf, eventsOf, overlayHost, prepareOverlay, type OverlayDeps, type Placement, type Prepared } from '../overlay/host.ts'
15import { selectTarget, type Target, type TerminalEnv } from '../overlay/target.ts'
16import { BUILTIN_EFFECT_LIST, ONE_SHOT_EFFECTS } from '../drips/effects.ts'
17import { dripsDir, gamesDir, loadDrips, loadGames, skippedText, type FolderDeps, type FolderGame } from '../content/folders.ts'
18import { GALLERY_API, get as galleryGet, listing as galleryListing, type Entry } from '../content/gallery.ts'
19import { COVER_FILES, DRIP_AUTHOR, NAME } from '../content/rules.js'
20import { share as galleryShare } from '../content/share.ts'
21import { readCustoms } from '../drips/library.ts'
22import { GAMES, gameDoc, gameOf } from './games/index.ts'
23import type { Game } from './games/game.ts'
24import { LOGO_ALT, LOGO_RATIO, logoSvg } from './brand.ts'
25import { BUILT_IN, TYPES, emojiOf, type Kind } from './catalog.ts'
26import { FAVOURITES_KEY } from './favourites.ts'
27import { scoresOf, type Store } from './scores.ts'
28import { settingsOf, type Settings } from './settings.ts'
29
30/** A drip the pane asked to play: the drips' half (../drips) takes it. */
31const queued = atom({ plugin: 'drip', key: 'queued' } as const, null as { recipe: string; at: number } | null)
32/** The pane's search, type filter and page (from 0). */
33const query = atom({ plugin: 'drip', key: 'query' } as const, '')
34const filter = atom({ plugin: 'drip', key: 'filter' } as const, 'all' as Kind | 'all')
35const page = atom({ plugin: 'drip', key: 'page' } as const, 0)
36/** Bumped when a screenshot the pane waits for has loaded: the page redraws with it. */
37const shotTick = atom({ plugin: 'drip', key: 'shotTick' } as const, 0)
38/** The item the pane shows on its own page ("game:snake"); none, the table. */
39const detail = atom({ plugin: 'drip', key: 'detail' } as const, null as string | null)
40/** The starred items ("game:missiles"), the tray's buttons: kept in the store across sessions (../drips loads them). */
41const favourites = atom({ plugin: 'drip', key: 'favourites' } as const, [] as string[])
42/** The tray's row is open (the drip button by the prompt). */
43const bandOpen = atom({ plugin: 'drip', key: 'bandOpen' } as const, false)
44
45/** The pane's logo: it follows the app's own light or dark, as the app's buttons do. */
46const LOGO = logoSvg('auto')
47
48/** The Drip pane: /drip (or /drip list) opens it. */
49export const PANE = 'drip'
50/** What /drip says in the conversation once the pane is open. */
51export const OPENED = 'Drip is open in the side panel.'
52/** How long what the gallery has is kept before it's asked again, in ms. */
53const GALLERY_MS = 5 * 60_000
54/** The gallery's listing as last read, for the pane's draws. */
55let galleryKept: { at: number; index: Awaited<ReturnType<typeof galleryListing>> } | null = null
56
57/** A drip's name as the card shows it: "matrix" → "Matrix". */
58const titleOf = (name: string) => name.charAt(0).toUpperCase() + name.slice(1)
59
60/** Every built-in game's names, which the person's own games may not take. */
61const TAKEN = new Set(GAMES.flatMap(g => [g.name, ...(g.aliases ?? [])]))
62
63let settings: Settings
64/** This load's pieces: the overlay's binary, built on first use, and the window Claude runs in. */
65let native: Promise<Prepared> | null = null
66/** The title of the game on screen, if one is: only one plays at a time. */
67let gameOpen: string | null = null
68let target: Promise<Target | null> | null = null
69/** Said once per load: Claude's window not found. */
70let isMissedSaid = false
71
72const short = (err: unknown) => String(err instanceof Error ? err.message : err).replace(/^(Hooks)?Error: /, '').slice(0, 300)
73
74function store($: EngineInterface): Store {
75 return { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) }
76}
77
78function folderDeps($: EngineInterface): FolderDeps {
79 return { list: p => $.fs.list(p), read: async p => String(await $.fs.read(p)), bytes: async p => (await $.fs.read(p, { as: 'bytes' })).base64, exists: p => $.fs.exists(p) }
80}
81
82/** Every game from a folder: the plugin's own (shipped, a snapshot of the
83 * gallery), then the person's (~/.drip/games); read again each time. */
84async function folderGames($: EngineInterface): Promise<{ games: FolderGame[]; skipped: string }> {
85 const d = folderDeps($)
86 const shipped = await loadGames(d, `${$.plugin.root}/games`, TAKEN, () => !settings.sounds, false)
87 const home = await $.env.get('HOME')
88 if (!home) return { games: shipped.games, skipped: '' }
89 const yours = await loadGames(d, gamesDir(home), new Set([...TAKEN, ...shipped.games.map(g => g.name)]), () => !settings.sounds, true)
90 return { games: [...shipped.games, ...yours.games], skipped: skippedText('~/.drip/games', yours.skipped) }
91}
92
93/** Every drip from a folder, for the card: the plugin's own, then the person's. */
94async function folderDrips($: EngineInterface): Promise<{ name: string; title: string; icon: string | null; isYours: boolean; author: string }[]> {
95 const d = folderDeps($)
96 const builtIn = new Set(Object.keys(ONE_SHOT_EFFECTS))
97 const shipped = (await loadDrips(d, `${$.plugin.root}/drips`, builtIn, false)).drips
98 const home = await $.env.get('HOME')
99 const yours = home ? (await loadDrips(d, dripsDir(home), new Set([...builtIn, ...shipped.map(x => x.name)]), true)).drips : []
100 return [...shipped, ...yours].map(x => ({ name: x.name, title: x.title, icon: x.icon, isYours: x.isYours, author: x.author }))
101}
102
103/** /drip share <name>: one of the person's own, to the gallery as a pull request. */
104async function shareToGallery($: EngineInterface, name: string): Promise<string> {
105 const home = await $.env.get('HOME')
106 if (!home) return 'No home folder to share from.'
107 return galleryShare(
108 {
109 run: async (argv, opts) => {
110 const r = await $.process.run(argv, opts).catch(() => null)
111 return r ? { exitCode: r.exitCode, stdout: String(r.stdout ?? ''), stderr: String(r.stderr ?? '') } : null
112 },
113 exists: p => $.fs.exists(p),
114 read: async p => String(await $.fs.read(p)),
115 bytes: async p => (await $.fs.read(p, { as: 'bytes' })).base64,
116 now: () => $.clock.now(),
117 },
118 home,
119 name,
120 )
121}
122
123/** /drip get <name>: the gallery's game or drip, into the person's folder. */
124async function getFromGallery($: EngineInterface, name: string): Promise<string> {
125 const home = await $.env.get('HOME')
126 if (!home) return 'No home folder to keep it in.'
127 // Getting one already fetched fetches it again: the gallery's newest.
128 const have = new Set([...TAKEN, ...Object.keys(ONE_SHOT_EFFECTS)])
129 const said = await galleryGet(
130 {
131 fetch: url => $.http.fetch(url),
132 write: (path, text) => $.fs.write(path, text),
133 writeBytes: async (path, base64) => (await $.process.run(['/usr/bin/base64', '-D', '-o', path], { stdin: base64 }).catch(() => null))?.exitCode === 0,
134 },
135 home,
136 name,
137 have,
138 )
139 rowsKept = null // here now: the pane reads it afresh
140 return said
141}
142
143/** A game by any of its names: built in, else the person's own. */
144async function findGame($: EngineInterface, name: string): Promise<Game | null> {
145 return gameOf(name) ?? (await folderGames($)).games.find(g => g.name === name.trim().toLowerCase()) ?? null
146}
147
148function overlayDeps($: EngineInterface): OverlayDeps {
149 return {
150 run: (argv, opts) => $.process.run(argv, opts).catch(() => null),
151 spawn: (argv, input) => eventsOf($.process.spawn({ argv, input })),
152 read: async path => String(await $.fs.read(path)),
153 exists: path => $.fs.exists(path),
154 home: () => $.env.get('HOME'),
155 root: $.plugin.root,
156 }
157}
158
159/** The window Claude runs in: the desktop app's, or the terminal's that hosts this session. */
160async function findTarget($: EngineInterface, binary: string): Promise<Target | null> {
161 const env: TerminalEnv = {
162 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
163 __CFBundleIdentifier: await $.env.get('__CFBundleIdentifier'),
164 TERM: await $.env.get('TERM'),
165 }
166 const isDesktop = (await $.session.surfaces()).includes('desktop')
167 return selectTarget({ isDesktop, env, ancestors: await ancestorsOf(overlayDeps($), binary) })
168}
169
170function placement($: EngineInterface, binary: string): Placement {
171 if (settings.display === 'fullscreen') return { kind: 'fullscreen' }
172 return {
173 kind: 'window',
174 target: () => (target ??= findTarget($, binary)),
175 missed: () => {
176 if (!isMissedSaid) $.ui.toast("Games could not find Claude's window: games play over the screen under the pointer.")
177 isMissedSaid = true
178 },
179 }
180}
181
182/** The list /drip answers with: each game, its other names and your best. */
183async function listing($: EngineInterface): Promise<string> {
184 const scores = scoresOf(store($))
185 const lines = ['Games. /drip <game> starts one:']
186 const yours = await folderGames($)
187 for (const g of [...GAMES, ...yours.games]) {
188 const best = await scores.best(g.name)
189 const mine = 'isYours' in g && g.isYours ? ' · yours' : ''
190 lines.push(` ${g.name.padEnd(9)} ${g.title}${best > 0 ? ` · best ${best}` : ''}${mine}`)
191 }
192 if (yours.skipped) lines.push('', yours.skipped)
193 return lines.join('\n')
194}
195
196/** /drip's own words, never a name to look for in the gallery. */
197const WORDS = ['list', 'stop', 'save', 'delete', 'page', 'get', 'share']
198
199/** Whether `name` plays as a drip here: built in, a folder of one, or a saved recipe. */
200async function isDripHere($: EngineInterface, name: string): Promise<boolean> {
201 if (name in ONE_SHOT_EFFECTS || BUILTIN_EFFECT_LIST.some(d => d.name === name)) return true
202 if ((await folderDrips($)).some(d => d.name === name)) return true
203 return name in (await readCustoms(store($)))
204}
205
206/** What the gallery has, for a name not on this Mac and the pane; null offline. */
207async function gallery($: EngineInterface) {
208 const now = await $.clock.now()
209 if (galleryKept && now - galleryKept.at < GALLERY_MS) return galleryKept.index
210 const index = await galleryListing({ fetch: url => $.http.fetch(url) })
211 galleryKept = { at: now, index }
212 return index
213}
214
215/** Who made it, as the pane says it: Drip's own are "Drip built". */
216export const byOf = (author: string) => (author === DRIP_AUTHOR ? 'Drip built' : `@${author}`)
217
218/** One row of the pane's table. */
219export type Row = {
220 kind: Kind
221 name: string
222 title: string
223 about: string
224 emoji: string
225 by: string | null
226 best: number
227 isYours: boolean
228 isGallery: boolean
229}
230
231/** Every game and drip for the pane: here first (games, then drips), then the gallery's not fetched yet. */
232async function paneRows($: EngineInterface): Promise<Row[]> {
233 const scores = scoresOf(store($))
234 const rows: Row[] = []
235 for (const g of [...GAMES, ...(await folderGames($)).games]) {
236 const f = 'isYours' in g ? (g as FolderGame) : null
237 rows.push({
238 kind: 'game',
239 name: g.name,
240 title: g.title,
241 about: f?.about ?? BUILT_IN[g.name]?.about ?? '',
242 emoji: emojiOf('game', g.name, f?.emoji),
243 by: byOf(f ? f.author : DRIP_AUTHOR),
244 best: await scores.best(g.name),
245 isYours: f?.isYours === true,
246 isGallery: false,
247 })
248 }
249 const own = await folderDrips($)
250 const saved = await readCustoms(store($))
251 const drip = (d: { name: string; title: string; about?: string; emoji?: string; by: string | null; isYours: boolean }): Row => ({
252 kind: 'drip',
253 name: d.name,
254 title: d.title,
255 about: d.about ?? BUILT_IN[d.name]?.about ?? '',
256 emoji: emojiOf('drip', d.name, d.emoji),
257 by: d.by,
258 best: 0,
259 isYours: d.isYours,
260 isGallery: false,
261 })
262 rows.push(
263 ...BUILTIN_EFFECT_LIST.map(d => drip({ name: d.name, title: titleOf(d.name), by: byOf(DRIP_AUTHOR), isYours: false })),
264 ...own.map(d => drip({ ...d, by: byOf(d.author) })),
265 // A saved recipe is the person's, made of built-in drips: its recipe is its line.
266 ...Object.entries(saved)
267 .filter(([n]) => !own.some(d => d.name === n))
268 .map(([name, recipe]) => drip({ name, title: titleOf(name), about: recipe, by: null, isYours: true })),
269 )
270 const index = await gallery($)
271 const here = new Set(rows.map(r => r.name))
272 const fromGallery = (kind: Kind, e: Entry): Row => ({
273 kind,
274 name: e.name,
275 title: e.title,
276 about: e.about ?? '',
277 emoji: emojiOf(kind, e.name, e.emoji),
278 by: e.author ? byOf(e.author) : null,
279 best: 0,
280 isYours: false,
281 isGallery: true,
282 })
283 for (const e of index?.games ?? []) if (!here.has(e.name)) rows.push(fromGallery('game', e))
284 for (const e of index?.drips ?? []) if (!here.has(e.name)) rows.push(fromGallery('drip', e))
285 return rows
286}
287
288/** Rows to a page of the pane's table. */
289export const PAGE_ROWS = 15
290
291/** The rows a search (every word, in the name, line, type, maker or "yours") and a filter leave, in order. */
292export function shown(rows: readonly Row[], query: string, filter: Kind | 'all'): Row[] {
293 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
294 return rows.filter(r => {
295 if (filter !== 'all' && r.kind !== filter) return false
296 const text = `${r.title} ${r.name} ${r.about} ${TYPES[r.kind]} ${r.by ?? ''} ${r.isYours ? 'yours' : ''}`.toLowerCase()
297 return words.every(w => text.includes(w))
298 })
299}
300
301/** A favourite's key, as the tray and the store keep it. */
302export const favKey = (kind: Kind, name: string) => `${kind}:${name}`
303
304/** The largest picture the pane can draw: an Svg's source is at most 131072 characters. */
305const SVG_MAX = 131_072
306
307/** The pane's rows as last read, kept until a /drip command or a fetch changes what's here: a press redraws at once. */
308let rowsKept: Row[] | null = null
309
310/** The pane's rows: kept ones, else read afresh. */
311async function rowsOf($: EngineInterface): Promise<Row[]> {
312 rowsKept ??= await paneRows($)
313 return rowsKept
314}
315
316/** Each page's screenshot as the pane draws it, by "kind:name": loading while it is read, null for none. */
317const shots = new Map<string, string | null | 'loading'>()
318
319/** The gallery's covers fetched this load, base64 by "kind:name": one fetch each. */
320const galleryCovers = new Map<string, string | null>()
321
322/** An item's screenshot as base64 JPEG or PNG ({ type, base64 }), from wherever it lives; null without one. */
323async function coverOf($: EngineInterface, r: Row): Promise<{ type: string; base64: string } | null> {
324 const read64 = async (path: string) => ((await $.fs.exists(path).catch(() => false)) ? (await $.fs.read(path, { as: 'bytes' })).base64 : null)
325 const home = await $.env.get('HOME')
326 if (!r.isGallery) {
327 // A folder's (fetched or the person's), else a built-in one's in the plugin's covers/.
328 for (const file of COVER_FILES) {
329 const b = home ? await read64(`${home}/.drip/${r.kind}s/${r.name}/${file}`) : null
330 if (b) return { type: file.endsWith('.png') ? 'png' : 'jpeg', base64: b }
331 }
332 const b = await read64(`${$.plugin.root}/covers/${r.kind}-${r.name}.jpg`)
333 return b ? { type: 'jpeg', base64: b } : null
334 }
335 const key = favKey(r.kind, r.name)
336 if (!galleryCovers.has(key)) {
337 const index = await gallery($)
338 const entry = (r.kind === 'game' ? index?.games : index?.drips)?.find(e => e.name === r.name)
339 const file = COVER_FILES.find(f => entry?.files.includes(f))
340 const res = file ? await $.http.fetch(`${GALLERY_API}/${r.kind}s/${r.name}/${file}?ref=main`).catch(() => null) : null
341 let base64: string | null = null
342 try {
343 const v = res?.ok ? (JSON.parse(res.text) as { encoding?: string; content?: string }) : null
344 base64 = v?.encoding === 'base64' && typeof v.content === 'string' ? v.content.replace(/\s+/g, '') : null
345 } catch {
346 base64 = null
347 }
348 galleryCovers.set(key, base64 && file ? `${file.endsWith('.png') ? 'png' : 'jpeg'}:${base64}` : null)
349 }
350 const kept = galleryCovers.get(key)
351 return kept ? { type: kept.slice(0, kept.indexOf(':')), base64: kept.slice(kept.indexOf(':') + 1) } : null
352}
353
354/** A screenshot as an Svg the pane can draw (480×300, filled); null when there is none or it is too big. */
355export function screenshotSvg(cover: { type: string; base64: string } | null): string | null {
356 if (!cover) return null
357 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="480" height="300" viewBox="0 0 480 300"><image width="480" height="300" preserveAspectRatio="xMidYMid slice" href="data:image/${cover.type};base64,${cover.base64}"/></svg>`
358 return svg.length <= SVG_MAX ? svg : null
359}
360
361/** The pane's search: the rows it leaves, from the first page. */
362async function search($: EngineInterface, text: string): Promise<void> {
363 await update($, query, () => text)
364 await update($, page, () => 0)
365}
366
367/** Stars or unstars an item: the tray above the prompt shows the starred ones. */
368async function toggleFavourite($: EngineInterface, key: string): Promise<void> {
369 const next = await update($, favourites, list => (list.includes(key) ? list.filter(k => k !== key) : [...list, key]))
370 await $.store.set(FAVOURITES_KEY, next)
371}
372
373/** Play or Install on the pane (or the tray): plays it, or installs the gallery's
374 * (its row then says Play). */
375async function press($: EngineInterface, row: Row): Promise<void> {
376 if (row.isGallery) {
377 const got = await getFromGallery($, row.name).catch(err => `/drip: ${short(err)}`)
378 await update($, shotTick, n => n + 1)
379 $.ui.toast(got.startsWith('Got ') ? `${row.title} is installed.` : got)
380 return
381 }
382 const said = row.kind === 'drip'
383 ? await queueDrip($, row.name).then(() => 'Got')
384 : await play($, (await findGame($, row.name))!).catch(err => `/drip: ${short(err)}`)
385 if (!said.startsWith('Got') && !said.includes('Esc quits')) $.ui.toast(said)
386}
387
388/** A drip for the drips' half (../drips) to play. */
389async function queueDrip($: EngineInterface, name: string): Promise<void> {
390 const at = await $.clock.now()
391 await update($, queued, () => ({ recipe: name, at }))
392}
393
394/** Fetches the gallery's `name` (/drip <name>), then plays it:
395 * says what happened when it couldn't. */
396async function getThenPlay($: EngineInterface, kind: Kind, name: string): Promise<string> {
397 const got = await getFromGallery($, name)
398 if (!got.startsWith('Got ')) return got
399 if (kind === 'drip') {
400 await queueDrip($, name)
401 return got
402 }
403 const game = await findGame($, name)
404 return game ? play($, game) : got
405}
406
407/** Starts `game` in the overlay; resolves once it is on screen. The score it
408 * posts is kept when it beats the top one. */
409async function play($: EngineInterface, game: Game): Promise<string> {
410 // One game at a time (drips may stack; games don't).
411 if (gameOpen) return `${gameOpen} is already open. Quit it with Esc first.`
412 gameOpen = game.title
413 try {
414 return await playOne($, game)
415 } catch (err) {
416 gameOpen = null
417 throw err
418 }
419}
420
421async function playOne($: EngineInterface, game: Game): Promise<string> {
422 native ??= prepareOverlay(overlayDeps($))
423 const prepared = await native
424 if (!('binary' in prepared)) {
425 gameOpen = null
426 return prepared.missing
427 }
428 const scores = scoresOf(store($))
429 const best = await scores.best(game.name)
430 const { show } = overlayHost(overlayDeps($), prepared.binary, placement($, prepared.binary))
431 const shown = await show(
432 s => gameDoc(game, s.w, s.h, best, settings.sounds),
433 async ev => void (ev.t === 'score' && (await scores.keep(game.name, ev.score))),
434 )
435 void shown.closed.catch(() => undefined).finally(() => (gameOpen = null))
436 return `${game.title}${best > 0 ? `: your best is ${best}` : ''}. Esc quits, M mutes.`
437}
438
439export function arcade(on: On, options: PluginOptions) {
440 settings = settingsOf(options)
441 native = null
442 gameOpen = null
443 target = null
444 isMissedSaid = false
445 galleryKept = null
446 galleryCovers.clear()
447 shots.clear()
448 rowsKept = null
449
450 // The Drip pane (/drip, /drip list): a search, a filter by type, and a
451 // table, fifteen rows a page: emoji, name, line, type, ☆ (the tray) and Play
452 // (Install for the gallery's).
453 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
454 const { Box, Text, Button, ...els } = $.ui.resolve(e)
455 const Input = 'Input' in els ? els.Input : null
456 // Every surface but the terminal draws SVG: the logo; the terminal gets the word.
457 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : null
458 await read($, shotTick) // an install redraws the rows
459 const all = await rowsOf($)
460 const q = await read($, query)
461 const f = await read($, filter)
462 const rows = shown(all, q, f)
463 const pages = Math.max(1, Math.ceil(rows.length / PAGE_ROWS))
464 const at = Math.min(await read($, page), pages - 1)
465 const stars = await read($, favourites)
466 // One item's page: ← back to its type, its screenshot, its line and its button.
467 const opened = await read($, detail)
468 const item = opened ? all.find(r => favKey(r.kind, r.name) === opened) : undefined
469 if (item) {
470 const key = favKey(item.kind, item.name)
471 // The page draws now; its screenshot follows once read (a gallery one is fetched).
472 await read($, shotTick)
473 if (Svg && !shots.has(key)) {
474 shots.set(key, 'loading')
475 void coverOf($, item)
476 .then(c => shots.set(key, screenshotSvg(c)))
477 .catch(() => shots.set(key, null))
478 .then(() => update($, shotTick, n => n + 1))
479 }
480 const shot = shots.get(key) ?? null
481 const isStar = stars.includes(key)
482 return (
483 <Box flexDirection="column" gap={1} paddingX={1}>
484 <Box key="top" flexDirection="row">
485 <Button key="back" label={`←\u00a0\u00a0${TYPES[item.kind]}s`} plain onPress={() => void update($, detail, () => null)} />
486 </Box>
487 {shot === 'loading' ? (
488 <Text key="shot" dimColor>
489 Loading screenshot…
490 </Text>
491 ) : shot && Svg ? (
492 <Svg key="shot" source={shot} alt={`${item.title}, playing`} width={480} height={300} />
493 ) : (
494 <Text key="shot">{item.emoji}</Text>
495 )}
496 <Box key="name" flexDirection="row" alignItems="center" gap={1}>
497 <Text>{item.emoji}</Text>
498 <Text bold>{item.title}</Text>
499 {item.by ? <Text dimColor>{item.by}</Text> : null}
500 {item.isYours ? <Text dimColor>yours</Text> : null}
501 </Box>
502 {item.about ? <Text key="about">{item.about}</Text> : null}
503 <Text key="type" dimColor>
504 {TYPES[item.kind]}
505 {item.best > 0 ? ` · Best ${item.best.toLocaleString('en-GB')}` : ''}
506 </Text>
507 <Box key="act" flexDirection="row" alignItems="center" gap={1}>
508 <Button key={`${item.isGallery ? 'get' : 'play'}:${key}`} label={item.isGallery ? 'Install' : 'Play'} variant="primary" onPress={() => void press($, item)} />
509 <Button key={`fav:${key}`} label={isStar ? '★' : '☆'} plain dimColor={!isStar} onPress={() => void toggleFavourite($, key)} />
510 </Box>
511 </Box>
512 )
513 }
514 const kinds = (Object.keys(TYPES) as Kind[]).filter(k => all.some(r => r.kind === k))
515 const turn = (by: number) => () => void update($, page, p => Math.max(0, Math.min(pages - 1, Math.min(p, pages - 1) + by)))
516 return (
517 <Box flexDirection="column" gap={1} paddingX={1}>
518 <Box key="logo" flexDirection="row" alignItems="center" gap={1}>
519 {Svg ? (
520 <Svg key="logo" source={LOGO} alt={LOGO_ALT} height={28} width={Math.round(28 * LOGO_RATIO)} />
521 ) : (
522 <Text bold>drip</Text>
523 )}
524 </Box>
525 {Input ? (
526 <Input
527 key="search"
528 placeholder="Search games and drips"
529 value={q}
530 submitLabel="search"
531 onInput={value => void search($, value)}
532 onSubmit={value => void search($, value)}
533 />
534 ) : null}
535 <Box flexDirection="row" gap={1}>
536 {(['all', ...kinds] as const).map(k => (
537 <Button
538 key={`filter:${k}`}
539 label={k === 'all' ? `All ${all.length}` : `${TYPES[k]}s ${all.filter(r => r.kind === k).length}`}
540 plain
541 dimColor={f !== k}
542 onPress={() => void update($, filter, () => k).then(() => update($, page, () => 0))}
543 />
544 ))}
545 </Box>
546 <Box flexDirection="column" gap={1}>
547 {rows.length === 0 ? <Text dimColor>Nothing matches “{q}”.</Text> : null}
548 {rows.slice(at * PAGE_ROWS, (at + 1) * PAGE_ROWS).map(r => {
549 const key = favKey(r.kind, r.name)
550 const isStar = stars.includes(key)
551 return (
552 <Box key={key} flexDirection="row" alignItems="center" gap={2}>
553 {/* The name button keeps its own room: nothing beside it is laid over it. */}
554 <Box flexShrink={0}>
555 <Button key={`open:${key}`} label={`${r.emoji}\u00a0\u00a0\u00a0${r.title}`} plain onPress={() => void update($, detail, () => key)} />
556 </Box>
557 {r.isYours ? <Text dimColor>yours</Text> : null}
558 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
559 <Text dimColor wrap="truncate">{r.about || r.by || ''}</Text>
560 </Box>
561 <Box flexShrink={0}>
562 <Text dimColor>{TYPES[r.kind]}</Text>
563 </Box>
564 <Button key={`fav:${key}`} label={isStar ? '★' : '☆'} plain dimColor={!isStar} onPress={() => void toggleFavourite($, key)} />
565 <Button
566 key={`${r.isGallery ? 'get' : 'play'}:${key}`}
567 label={r.isGallery ? 'Install' : 'Play'}
568 variant="secondary"
569 onPress={() => void press($, r)}
570 />
571 </Box>
572 )
573 })}
574 </Box>
575 {pages > 1 ? (
576 <Box flexDirection="row" alignItems="center" gap={1}>
577 <Button key="prev" label="‹" plain dimColor={at === 0} onPress={turn(-1)} />
578 <Text dimColor>
579 {at + 1} of {pages}
580 </Text>
581 <Button key="next" label="›" plain dimColor={at === pages - 1} onPress={turn(1)} />
582 </Box>
583 ) : null}
584 </Box>
585 )
586 })
587
588 // The tray above the prompt: the drips' half (../drips) draws the starred
589 // drips and its own buttons; the starred games go in front, played here.
590 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
591 const band = await next(e)
592 if (e.props.hasSurvey || !(await read($, bandOpen))) return band
593 const games = (await read($, favourites)).filter(k => k.startsWith('game:')).map(k => k.slice(5))
594 if (games.length === 0) return band
595 const { Box, Button } = $.ui.resolve(e)
596 const all = await rowsOf($)
597 const tray = games.map(n => all.find(r => r.kind === 'game' && r.name === n)).filter((r): r is Row => r !== undefined)
598 return (
599 <Box flexDirection="row" flexWrap="wrap" gap={1}>
600 {tray.map(r => (
601 <Button key={`tray:game:${r.name}`} label={`${r.emoji} ${r.title}`} onPress={() => void press($, r)} />
602 ))}
603 {band}
604 </Box>
605 )
606 })
607
608 // /drip <game> starts a game; /drip alone lists the games, then the drips
609 // (the drips' half, ../drips); anything else is the drips' to play.
610 on('command.run', { command: 'drip' }, async ($, e, next) => {
611 rowsKept = null // a /drip command may change what's here: the pane reads it afresh
612 const name = e.args.trim()
613 const [verb, ...rest] = name.split(/\s+/)
614 if (verb?.toLowerCase() === 'get') return { text: rest.length === 1 ? await getFromGallery($, rest[0]!) : 'Usage: /drip get <name>' }
615 if (verb?.toLowerCase() === 'share') return { text: rest.length === 1 ? await shareToGallery($, rest[0]!) : 'Usage: /drip share <name>' }
616 if (!name || name.toLowerCase() === 'list') {
617 // The pane; where no surface places one (a bare terminal), the list in words.
618 const { isPlaced } = await $.ui.open({ id: PANE, title: 'Drip', focus: true })
619 if (isPlaced) return { text: OPENED }
620 const drips = await next({ ...e, args: 'list' })
621 return { text: `${await listing($)}\n\n${drips.text ?? ''}`.trim() }
622 }
623 const game = await findGame($, name)
624 if (!game) {
625 // Not here: the gallery's, fetched and played.
626 const want = name.toLowerCase()
627 if (!NAME.test(want) || WORDS.includes(want) || (await isDripHere($, want))) return next(e)
628 const index = await gallery($)
629 const kind = index?.games.some(g => g.name === want) ? 'game' : index?.drips.some(d => d.name === want) ? 'drip' : null
630 if (!kind) return next(e)
631 return { text: await getThenPlay($, kind, want).catch(err => `/drip: ${short(err)}`) }
632 }
633 try {
634 return { text: await play($, game) }
635 } catch (err) {
636 return { text: `/drip: ${short(err)}` }
637 }
638 })
639}
640hooks/drips/hooks.tsx 474 lines1// Drips, wired to the engine: the one file of this module that holds `$` and
2// the mod's state (the engine follows `$` and atoms only within the file
3// whose hook received them), handing the rest closures. It runs the Drips
4// page's helper (helper/server.mjs), detached so it outlives the mod's
5// reloads, and the native overlay (native/overlay.swift), one process per
6// effect, over Claude's window or the whole screen. Where effects play is the
7// /config row `display`. The Drips button sits in the hint line under the
8// prompt; pressed, a row of quick plays opens above the prompt.
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, On, PluginOptions, RenderChildren, Timer } from 'claude-code'
11
12import type { HelperInfo } from '../../types'
13import { BUILTIN_EFFECT_LIST, BUILTIN_EFFECTS, ONE_SHOT_EFFECTS, setYourDrips, yourDrips } from './effects.ts'
14import { dripsDir, loadDrips } from '../content/folders.ts'
15import { moveOnce } from '../content/moved.ts'
16import { createFun } from './fun.ts'
17import { emojiOf } from '../arcade/catalog.ts'
18import { FAVOURITES_KEY, favouritesOf } from '../arcade/favourites.ts'
19import { deleteCustom, dripId, listCustoms, readCustoms, saveCustom, type Store } from './library.ts'
20import { HELPER_FILES, linkTo, versionOf, type Link, type PageMessage } from './link.ts'
21import { createManager, customEffects, type Host, type Manager } from './manager.ts'
22import { ancestorsOf, eventsOf, prepareOverlay, type Placement, type Prepared } from '../overlay/host.ts'
23import { selectTarget, type Target, type TerminalEnv } from '../overlay/target.ts'
24import { nativeHost, type NativeDeps } from './native.ts'
25import { findNode, NODE_MISSING } from './node.ts'
26import { settingsOf, type Settings } from './settings.ts'
27import { showPage, shownText, type PageDeps } from './window.ts'
28
29const helperInfo = atom({ plugin: 'drip', key: 'helper' } as const, null as HelperInfo | null)
30const pageTab = atom({ plugin: 'drip', key: 'pageTab' } as const, null as string | null)
31const nodePath = atom({ plugin: 'drip', key: 'node' } as const, null as string | null)
32const bandOpen = atom({ plugin: 'drip', key: 'bandOpen' } as const, false)
33/** The starred items, the tray's buttons (the pane stars them; kept in the store). */
34const favourites = atom({ plugin: 'drip', key: 'favourites' } as const, [] as string[])
35/** A drip the card asked to play (../arcade queues it): taken within a beat. */
36const queued = atom({ plugin: 'drip', key: 'queued' } as const, null as { recipe: string; at: number } | null)
37/** How often the drips' half looks for a drip the card queued, in ms. */
38export const QUEUE_MS = 150
39
40
41const TOOL_PREFIX = 'mcp__drip__'
42const NOT_A_RECIPE = ['save', 'delete', 'list']
43
44/** This load's pieces: made at the session's start (a reload makes them again). */
45type Mod = {
46 manager: Manager
47 /** The native overlay's binary, built on first use. */
48 native: Promise<Prepared> | null
49 /** The window Claude runs in, found on first use. */
50 target: Promise<Target | null> | null
51 fun: ReturnType<typeof createFun>
52}
53let settings: Settings
54let mod: Mod | null = null
55/** The helper this load talks to, and the timer that collects the page's messages. */
56let helper: Link | null = null
57let collecting: Timer | null = null
58let starting: Promise<Link> | null = null
59/** Each is said once per load: the overlay unavailable, Claude's window not found. */
60let isUnavailableSaid = false
61let isMissedSaid = false
62
63/** The overlay cannot run here: said once by a toast, never again by report. */
64class Unavailable extends Error {}
65
66const short = (err: unknown) => String(err instanceof Error ? err.message : err).replace(/^(Hooks)?Error: /, '').slice(0, 300)
67
68/** What Drip before 2.0.0 kept, brought over once (../content/moved.ts). */
69async function moveFromBefore($: EngineInterface): Promise<void> {
70 const home = await $.env.get('HOME')
71 if (!home) return
72 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${home}/.claude`
73 const moved = await moveOnce(
74 {
75 list: p => $.fs.list(p),
76 read: async p => String(await $.fs.read(p)),
77 exists: p => $.fs.exists(p),
78 run: async argv => (await $.process.run(argv).catch(() => null))?.exitCode ?? null,
79 },
80 store($),
81 { home, config },
82 )
83 if (moved && moved.folders.length) $.ui.toast(`Drip brought over your ${moved.folders.length === 1 ? 'game or drip' : `${moved.folders.length} games and drips`} into ~/.drip`)
84}
85
86function store($: EngineInterface): Store {
87 return { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) }
88}
89
90function pageDeps($: EngineInterface): PageDeps {
91 return {
92 isDesktop: async () => (await $.session.surfaces()).includes('desktop'),
93 mcp: (server, tool, args) => $.mcp.call(server, tool, args),
94 openExternal: async url => (await $.process.run(['open', url]).catch(() => null))?.exitCode === 0,
95 // A prompt submitted from inside a command would wait on the command
96 // itself: it goes once the command has returned.
97 ask: text =>
98 void $.clock.after(0, () => {
99 $.prompt.submit({ text }).catch(() => $.ui.toast('The Browser pane is not available: allow mcp__Claude_Browser__preview_start'))
100 }),
101 link: () => startHelper($),
102 tab: { get: () => read($, pageTab), set: async fn => void (await update($, pageTab, fn)) },
103 }
104}
105
106/** `quiet`: the overlay plays no sound whatever the setting (the matrix while
107 * tools run, which would otherwise sound through every long run). */
108function nativeDeps($: EngineInterface, quiet = false): NativeDeps {
109 return {
110 run: (argv, opts) => $.process.run(argv, opts).catch(() => null),
111 spawn: (argv, input) => eventsOf($.process.spawn({ argv, input })),
112 read: async path => String(await $.fs.read(path)),
113 exists: path => $.fs.exists(path),
114 home: () => $.env.get('HOME'),
115 root: $.plugin.root,
116 sounds: () => !quiet && settings.sounds,
117 }
118}
119
120function build($: EngineInterface): Mod {
121 const report = (err: unknown) => void (err instanceof Unavailable || $.ui.toast(`Drips: ${short(err)}`))
122 const manager = createManager({
123 store: store($),
124 customs: () => readCustoms(store($)),
125 after: (ms, fn) => void $.clock.after(ms, fn),
126 report,
127 changed: () => $.ui.invalidate('ui.render'), // the row's Stop comes and goes
128 })
129 const m: Mod = { manager, native: null, target: null, fun: null as never }
130 m.fun = createFun(
131 {
132 play: async recipe => void (await manager.execute(() => hostOf($, m), recipe)),
133 ensure: async effect => manager.ensure(await hostOf($, m, true), effect),
134 release: effect => manager.release(effect),
135 after: (ms, fn) => $.clock.after(ms, fn),
136 report,
137 },
138 settings,
139 )
140 return m
141}
142
143const modOf = ($: EngineInterface) => (mod ??= build($))
144
145/** The window Claude runs in: the desktop app's, or the terminal's that
146 * hosts this session (its ancestors, as the overlay sees them, and the
147 * environment). */
148async function findTarget($: EngineInterface, binary: string): Promise<Target | null> {
149 const env: TerminalEnv = {
150 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
151 __CFBundleIdentifier: await $.env.get('__CFBundleIdentifier'),
152 TERM: await $.env.get('TERM'),
153 }
154 const isDesktop = (await $.session.surfaces()).includes('desktop')
155 return selectTarget({ isDesktop, env, ancestors: await ancestorsOf(nativeDeps($), binary) })
156}
157
158function placement($: EngineInterface, m: Mod, binary: string, display: 'claude-window' | 'fullscreen'): Placement {
159 if (display === 'fullscreen') return { kind: 'fullscreen' }
160 return {
161 kind: 'window',
162 target: () => (m.target ??= findTarget($, binary)),
163 missed: () => {
164 if (!isMissedSaid) $.ui.toast("Drips could not find Claude's window: effects play over the screen under the pointer.")
165 isMissedSaid = true
166 },
167 }
168}
169
170/** Where effects play now: the setting's host, the native overlay. Where the
171 * overlay cannot run here (not a Mac, no Command Line Tools), nothing plays:
172 * a toast says why, once, and the play answers with the same reason. */
173async function hostOf($: EngineInterface, m: Mod, quiet = false): Promise<Host> {
174 const display = settings.display
175 m.native ??= prepareOverlay(nativeDeps($))
176 const prepared = await m.native
177 if ('binary' in prepared) return nativeHost(nativeDeps($, quiet), prepared.binary, placement($, m, prepared.binary, display))
178 if (!isUnavailableSaid) $.ui.toast(prepared.missing)
179 isUnavailableSaid = true
180 throw new Unavailable(prepared.missing)
181}
182
183/** The engine's own line with the Drips button after it; pressed, it opens or closes the row above the prompt. */
184async function withButton($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], line: RenderChildren) {
185 const { Box, Button } = $.ui.resolve(e)
186 const isOpen = await read($, bandOpen)
187 return (
188 <Box flexDirection="row" gap={2}>
189 {line}
190 <Button key="drip" label="💧 drip" plain dimColor={!isOpen} onPress={() => update($, bandOpen, v => !v)} />
191 </Box>
192 )
193}
194
195// ── The Drips page's helper ─────────────────────────────────────────────────
196
197const linkOf = ($: EngineInterface, info: HelperInfo) => linkTo((url, init) => $.http.fetch(url, init), info.port, info.token)
198
199async function helperVersion($: EngineInterface): Promise<string> {
200 const texts = []
201 for (const f of HELPER_FILES) texts.push(String(await $.fs.read(`${$.plugin.root}/helper/${f}`).catch(() => '')))
202 return versionOf(texts)
203}
204
205/** The page's saved recipes and effects, sent to the helper. */
206async function pushLibrary($: EngineInterface): Promise<void> {
207 $.ui.invalidate('ui.render') // the row above the prompt lists them too
208 await helper?.library({ drips: await listCustoms(store($)), effects: BUILTIN_EFFECTS, display: settings.display })
209}
210
211/** What the page asked: a preview played, everything stopped, a recipe saved or deleted. */
212async function onPage($: EngineInterface, m: PageMessage): Promise<void> {
213 const { manager } = modOf($)
214 if (m.t === 'stop') return manager.stopAll()
215 if (m.t === 'play') {
216 await manager.execute(() => hostOf($, modOf($)), m.recipe, { preview: true })
217 return
218 }
219 if (m.t === 'save') {
220 if (!dripId(m.name) || customEffects(m.recipe).length === 0) return $.ui.toast('Drips: a drip needs a name and at least one effect.')
221 if (m.original && dripId(m.original) !== dripId(m.name)) await deleteCustom(store($), m.original)
222 await saveCustom(store($), m.name, m.recipe)
223 } else await deleteCustom(store($), m.name)
224 await pushLibrary($)
225}
226
227/** Talks to a running helper: the page's messages collected every second,
228 * which keeps it alive; one that stopped answering is let go. */
229async function attach($: EngineInterface, link: Link): Promise<Link> {
230 helper = link
231 collecting?.cancel()
232 collecting = $.clock.every(1000, () => {
233 link
234 .inbox()
235 .then(async messages => {
236 for (const m of messages) await onPage($, m).catch(err => void (err instanceof Unavailable || $.ui.toast(`Drips: ${short(err)}`)))
237 })
238 .catch(async () => {
239 if (helper !== link) return
240 collecting?.cancel()
241 helper = null
242 await update($, helperInfo, () => null)
243 })
244 })
245 await pushLibrary($)
246 return link
247}
248
249/** The helper kept from before this load: found again when its code is this
250 * copy's; told to leave when it is not. */
251async function reattach($: EngineInterface, version: string): Promise<Link | null> {
252 const kept = await read($, helperInfo)
253 if (!kept) return null
254 const link = linkOf($, kept)
255 const running = await link.state().then(s => s.version, () => null)
256 if (running === version) return attach($, link)
257 if (running !== null) await link.quit().catch(() => undefined)
258 collecting?.cancel()
259 helper = null
260 await update($, helperInfo, () => null)
261 return null
262}
263
264const randomHex = (n: number) => Array.from(crypto.getRandomValues(new Uint8Array(n)), b => b.toString(16).padStart(2, '0')).join('')
265
266/** The page's helper: the one running, or a new one started detached on a
267 * port and with a token chosen here; up when it answers. */
268function startHelper($: EngineInterface): Promise<Link> {
269 if (helper) return Promise.resolve(helper)
270 starting ??= (async () => {
271 const version = await helperVersion($)
272 const kept = await reattach($, version)
273 if (kept) return kept
274 const node = await read($, nodePath)
275 if (!node) throw new Error(NODE_MISSING)
276 for (let attempt = 0; attempt < 2; attempt++) {
277 const info: HelperInfo = { port: 20000 + (crypto.getRandomValues(new Uint16Array(1))[0]! % 40000), token: randomHex(24) }
278 const launch = $.process.spawn({
279 argv: [node, `${$.plugin.root}/helper/server.mjs`],
280 env: { DRIPS_DETACH: '1', DRIPS_PORT: String(info.port), DRIPS_TOKEN: info.token, DRIPS_VERSION: version },
281 })
282 for await (const _ of launch); // the launcher starts the helper in its own session and exits
283 const link = linkOf($, info)
284 for (let i = 0; i < 20; i++) {
285 if ((await link.state().then(s => s.version, () => null)) === version) {
286 await update($, helperInfo, () => info)
287 return attach($, link)
288 }
289 await $.clock.sleep(100)
290 }
291 }
292 throw new Error('the Drips page did not start')
293 })().finally(() => {
294 starting = null
295 })
296 return starting
297}
298
299// ── The Drips button and its row ────────────────────────────────────────────
300
301/** A play from the row or the card: the same path as /drip. */
302async function bandPlay($: EngineInterface, recipe: string): Promise<void> {
303 await readYours($).catch(() => undefined)
304 const m = modOf($)
305 await m.manager.execute(() => hostOf($, m), recipe).catch(err => void (err instanceof Unavailable || $.ui.toast(`Drips: ${short(err)}`)))
306}
307
308
309/** Reads the folder drips again (the plugin's shipped ones, then ~/.drip/drips): before each play and listing. */
310async function readYours($: EngineInterface): Promise<void> {
311 const d = {
312 list: (p: string) => $.fs.list(p),
313 read: async (p: string) => String(await $.fs.read(p)),
314 bytes: async (p: string) => (await $.fs.read(p, { as: 'bytes' })).base64,
315 exists: (p: string) => $.fs.exists(p),
316 }
317 const builtIn = new Set(Object.keys(ONE_SHOT_EFFECTS))
318 const shipped = (await loadDrips(d, `${$.plugin.root}/drips`, builtIn, false)).drips
319 const home = await $.env.get('HOME')
320 const yours = home ? (await loadDrips(d, dripsDir(home), new Set([...builtIn, ...shipped.map(x => x.name)]), true)).drips : []
321 setYourDrips([...shipped, ...yours], !settings.sounds)
322}
323
324/** Plays the drip the card queued, if any: the same path as a quick play. */
325async function takeQueued($: EngineInterface): Promise<void> {
326 const q = await read($, queued)
327 if (!q) return
328 await update($, queued, () => null)
329 await bandPlay($, q.recipe)
330}
331
332// ── Hooks ───────────────────────────────────────────────────────────────────
333
334async function registerAll($: EngineInterface): Promise<void> {
335 await $.command.register({
336 name: 'drip',
337 description: 'Drip: /drip list opens the pane of games and drips; /drip snake starts a game; /drip confetti, /drip 3 hearts stars, /drip -1 aurora play drips; get <name> fetches a game or drip from the gallery, share <name> sends one of yours there; stop, save <name> <recipe>, delete <name>; page opens your saved drips and the editor. Every game and drip is free',
338 argumentHint: '<game> | <recipe> | get <name> | share <name> | stop | save <name> <recipe> | delete <name> | page',
339 immediate: true,
340 })
341 await $.tool.register({
342 name: 'play_drip',
343 description:
344 'Play a visual effect over Claude for the person: a recipe such as "confetti", "3 hearts stars", "0.6 fireworks", ' +
345 '"-1 aurora" (persistent: plays until fired again or "stop"), a saved drip\'s name, or "stop". list_drips lists the effects.',
346 inputSchema: { type: 'object', properties: { recipe: { type: 'string', description: 'The drip recipe' } }, required: ['recipe'] },
347 })
348 await $.tool.register({
349 name: 'list_drips',
350 description: "List the visual effects play_drip can play, and the person's saved drips.",
351 inputSchema: { type: 'object', properties: {} },
352 })
353}
354
355export function drips(on: On, options: PluginOptions) {
356 settings = settingsOf(options)
357 mod = null
358 helper = null
359 isUnavailableSaid = false
360 isMissedSaid = false
361
362 on('session.start', async ($, e, next) => {
363 mod = build($)
364 await moveFromBefore($).catch(() => null)
365 const kept = favouritesOf(await $.store.get(FAVOURITES_KEY))
366 await update($, favourites, () => kept)
367 await update($, nodePath, () => null)
368 const node = await findNode({ run: argv => $.process.run(argv).catch(() => null) })
369 await update($, nodePath, () => node)
370 await reattach($, await helperVersion($)).catch(() => null) // a reload keeps the page up
371 await registerAll($)
372 $.clock.every(QUEUE_MS, () => void takeQueued($).catch(err => $.ui.toast(`drip ✗ ${short(err)}`)))
373 return next(e)
374 })
375
376 on('command.run', { command: 'drip' }, async ($, e) => {
377 // What the games' half (../arcade) passes on: `list` (or nothing) lists, anything else is a recipe or a name to play.
378 const line = e.args.trim() || 'list'
379 const sub = line.split(/\s+/)[0]?.toLowerCase() ?? ''
380 if (sub === 'page') {
381 if (!(await read($, nodePath))) return { text: NODE_MISSING }
382 return { text: await showPage(pageDeps($)).then(shownText, err => `The Drip page ✗ ${short(err)}`) }
383 }
384 try {
385 await readYours($).catch(() => undefined)
386 const m = modOf($)
387 const text = await m.manager.execute(() => hostOf($, m), line)
388 if (sub === 'save' || sub === 'delete') await pushLibrary($).catch(() => undefined)
389 return { text }
390 } catch (err) {
391 return { text: `/drip: ${short(err)}` }
392 }
393 })
394
395 on('tool.call', { tool: 'mcp__drip__play_drip' }, async ($, e) => {
396 const recipe = String((e.input as { recipe?: unknown } | undefined)?.recipe ?? '').trim()
397 if (NOT_A_RECIPE.includes(recipe.split(/\s+/)[0]?.toLowerCase() ?? '')) return { result: 'play_drip takes a recipe or "stop"; list_drips lists the effects.' }
398 try {
399 await readYours($).catch(() => undefined)
400 const m = modOf($)
401 return { result: String(await m.manager.execute(() => hostOf($, m), recipe)) }
402 } catch (err) {
403 return { result: `Not played: ${short(err)}` }
404 }
405 })
406
407 on('tool.call', { tool: 'mcp__drip__list_drips' }, async ($, e) => {
408 await readYours($).catch(() => undefined)
409 const own = yourDrips().map(d => ({ name: d.name, kind: d.kind, title: d.title }))
410 return { result: JSON.stringify({ effects: BUILTIN_EFFECT_LIST, yours: own, custom: await listCustoms(store($)), running: modOf($).manager.running() }) }
411 })
412
413 // The Drips button, beside the engine's own line: on the terminal the hint
414 // under the prompt; on the desktop, which draws no hint line of the engine's,
415 // the mode labels in the prompt's footer.
416 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
417 const line = await next(e)
418 if (!settings.button || e.surface === 'desktop') return line
419 return withButton($, e, line)
420 })
421 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
422 const line = await next(e)
423 if (!settings.button || e.surface !== 'desktop') return line
424 return withButton($, e, line)
425 })
426
427 // Its row above the prompt, the tray: the starred drips (the games' half,
428 // ../arcade, puts the starred games in front), Stop while something
429 // persistent plays, ✕.
430 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
431 if (!settings.button || e.props.hasSurvey || !(await read($, bandOpen))) return next(e)
432 const { Box, Button } = $.ui.resolve(e)
433 const close = <Button key="close" label="✕" plain role="dismiss" onPress={() => update($, bandOpen, () => false)} />
434 const saved = new Set((await listCustoms(store($))).map(d => d.name))
435 await readYours($).catch(() => undefined)
436 const own = yourDrips()
437 // A starred drip that plays here: built in, a folder of one, or a saved recipe.
438 const starred = (await read($, favourites))
439 .filter(k => k.startsWith('drip:'))
440 .map(k => k.slice(5))
441 .filter(n => BUILTIN_EFFECTS.includes(n) || saved.has(n) || own.some(d => d.name === n))
442 const isPlaying = modOf($).manager.running().length > 0
443 return (
444 <Box flexDirection="row" flexWrap="wrap" gap={1}>
445 {starred.map(n => {
446 const d = own.find(x => x.name === n)
447 return <Button key={`play:${n}`} label={`${emojiOf('drip', n, d?.emoji)} ${d?.title ?? n}`} onPress={() => bandPlay($, n)} />
448 })}
449 {isPlaying ? [<Button key="stop" label="■ Stop" onPress={() => modOf($).manager.stopAll()} />] : []}
450 {close}
451 </Box>
452 )
453 })
454
455 // The fun hooks: each does nothing unless its /config toggle is on.
456 on('tool.call', async ($, e, next) => {
457 if (!settings.matrixWhileTools || e.agentId || String(e.tool).startsWith(TOOL_PREFIX)) return next(e)
458 const { fun } = modOf($)
459 void fun.toolStart()
460 try {
461 return await next(e)
462 } finally {
463 fun.toolEnd()
464 }
465 }).catch(($, e, next) => next(e))
466
467 on('turn.complete', async ($, e, next) => {
468 void modOf($)
469 .fun.turnEnd({ durationMs: e.durationMs, reason: e.reason, isMainLoop: !e.agentId })
470 .catch(err => void (err instanceof Unavailable || $.ui.toast(`Drips: ${short(err)}`)))
471 return next(e)
472 })
473}
474hooks/overlay/host.ts 147 lines1// The native overlay (./overlay.swift) every game and drip draws in:
2// over Claude's window (the window Claude is running in,
3// followed as it moves) or over the whole screen. It is compiled with swiftc
4// the first time it is needed and kept in ~/Library/Caches/drip-overlay,
5// one binary per version of its source, so every load uses the same one. Each
6// document is one overlay process. Works on OverlayDeps, closures over `$`
7// that the mod's hooks hand it.
8import {
9 binaryPath,
10 compileArgv,
11 digestOf,
12 lineSplitter,
13 parseAncestors,
14 parseEvent,
15 parseLocated,
16 parseScreen,
17 playRequest,
18 windowRequest,
19 type Layer,
20 type OverlayEvent,
21 type Screen,
22} from './overlay.ts'
23import type { Ancestor, Target } from './target.ts'
24
25export type RunResult = { exitCode: number; stdout: string; stderr: string }
26export type RunOptions = { timeoutMs?: number; stdin?: string }
27
28export type OverlayDeps = {
29 /** A command's result, or null when it could not start. */
30 run(argv: readonly string[], opts?: RunOptions): Promise<RunResult | null>
31 /** Starts `argv` with `input` on stdin: the overlay's events, in order, until it exits. */
32 spawn(argv: readonly string[], input: string): AsyncIterable<OverlayEvent>
33 read(path: string): Promise<string>
34 exists(path: string): Promise<boolean>
35 home(): Promise<string | undefined>
36 /** The mod's folder. */
37 root: string
38}
39
40/** One document for the overlay: its page, how long it may stay, and whether
41 * it takes the pointer and keys (a game) or lets them through. */
42export type Doc = { html: string; closeAfterMs: number; interactive: boolean }
43
44export const SWIFT_MISSING = "Drip's overlay needs Swift: install the Xcode Command Line Tools (xcode-select --install)."
45export const MAC_ONLY = "Drip's overlay needs macOS."
46const BUILD_MS = 5 * 60_000
47const ASK_MS = 10_000
48
49export type Prepared = { binary: string } | { missing: string }
50
51const firstLine = (text: string) => text.trim().split('\n')[0]?.slice(0, 160) ?? ''
52
53/** The overlay's events from a spawned child's output pieces. */
54export async function* eventsOf(chunks: AsyncIterable<{ stream: string; text: string }>): AsyncGenerator<OverlayEvent> {
55 const split = lineSplitter()
56 for await (const chunk of chunks) {
57 if (chunk.stream !== 'stdout') continue
58 for (const line of split(chunk.text)) {
59 const ev = parseEvent(line)
60 if (ev) yield ev
61 }
62 }
63}
64
65/** The overlay binary for this copy of the mod: the one built before, or built now. */
66export async function prepareOverlay(d: OverlayDeps): Promise<Prepared> {
67 if ((await d.run(['uname', '-s']))?.stdout.trim() !== 'Darwin') return { missing: MAC_ONLY }
68 const home = await d.home()
69 if (!home) return { missing: "Drip's overlay needs HOME to be set." }
70 const source = `${d.root}/hooks/overlay/overlay.swift`
71 const binary = binaryPath(home, await digestOf(await d.read(source)))
72 if (await d.exists(binary)) return { binary }
73 if ((await d.run(['xcode-select', '-p']))?.exitCode !== 0) return { missing: SWIFT_MISSING }
74 const found = await d.run(['xcrun', '--find', 'swiftc'])
75 const swiftc = found?.exitCode === 0 ? found.stdout.trim() : ''
76 if (!swiftc) return { missing: SWIFT_MISSING }
77 await d.run(['/bin/mkdir', '-p', binary.slice(0, binary.lastIndexOf('/'))])
78 const building = `${binary}.building`
79 const built = await d.run(compileArgv(source, building), { timeoutMs: BUILD_MS })
80 if (built?.exitCode !== 0) return { missing: `Drip's overlay did not build: ${firstLine(built?.stderr ?? 'swiftc did not run')}` }
81 const moved = await d.run(['/bin/mv', '-f', building, binary])
82 if (moved?.exitCode !== 0) return { missing: `Drip's overlay could not be kept: ${firstLine(moved?.stderr ?? '')}` }
83 return { binary }
84}
85
86/** The apps among the overlay's ancestors: the ones hosting this session. */
87export async function ancestorsOf(d: OverlayDeps, binary: string): Promise<Ancestor[]> {
88 const r = await d.run([binary, 'apps'], { timeoutMs: ASK_MS })
89 return r?.exitCode === 0 ? parseAncestors(r.stdout) : []
90}
91
92/** Where an overlay goes: over the whole screen, or over the window `target`
93 * names (`missed` is told when there is no such window, and the effect plays
94 * over the screen under the pointer instead). */
95export type Placement = { kind: 'fullscreen' } | { kind: 'window'; target(): Promise<Target | null>; missed(): void }
96
97type Spot = { frame: Screen; layer: Layer; follow: number | null }
98/** An overlay on screen: its process, and when it has gone. */
99export type Shown = { pid: number; closed: Promise<void> }
100/** What the overlay says while it is up (a game's score). */
101export type Heard = (ev: OverlayEvent) => Promise<void>
102
103/** Shows documents in overlays where `place` says. */
104export function overlayHost(d: OverlayDeps, binary: string, place: Placement): { show(doc: (s: Screen) => Doc, heard?: Heard): Promise<Shown> } {
105 async function screen(): Promise<Screen> {
106 const r = await d.run([binary, 'screen'], { timeoutMs: ASK_MS })
107 const s = r?.exitCode === 0 ? parseScreen(r.stdout) : null
108 if (!s) throw new Error("Drip's overlay could not find the screen.")
109 return s
110 }
111
112 async function spot(): Promise<Spot> {
113 if (place.kind === 'fullscreen') return { frame: await screen(), layer: 'screen', follow: null }
114 const target = await place.target()
115 if (target) {
116 const r = await d.run([binary, 'window'], { timeoutMs: ASK_MS, stdin: windowRequest(target) })
117 const located = r?.exitCode === 0 ? parseLocated(r.stdout) : null
118 if (located?.found) return { frame: located.frame, layer: 'window', follow: located.id }
119 if (located) {
120 place.missed()
121 return { frame: located.frame, layer: 'window', follow: null }
122 }
123 }
124 place.missed()
125 return { frame: await screen(), layer: 'window', follow: null }
126 }
127
128 /** Opens one overlay; resolves once it is on screen. */
129 async function show(doc: (s: Screen) => Doc, heard?: Heard): Promise<Shown> {
130 const s = await spot()
131 const { html, closeAfterMs, interactive } = doc(s.frame)
132 const events = d.spawn([binary, 'play'], playRequest(html, s.frame, closeAfterMs, s.layer, s.follow, interactive))[Symbol.asyncIterator]()
133 const first = await events.next()
134 if (first.done || first.value.t !== 'shown') {
135 const said = !first.done && first.value.t === 'error' ? `: ${first.value.message}` : ''
136 throw new Error(`Drip's overlay did not open${said}`)
137 }
138 const closed = (async () => {
139 // Read to the end (the child has exited), hearing what it says on the way.
140 for (let ev = await events.next(); !ev.done; ev = await events.next()) await heard?.(ev.value)
141 })()
142 return { pid: first.value.pid, closed }
143 }
144
145 return { show }
146}
147hooks/overlay/target.ts 85 lines1// The window effects play over when `display` is claude-window: the window
2// Claude is running in. In the desktop app, the Claude app's own window; in a
3// terminal, the window of the terminal app that hosts Claude Code. Pure: the
4// apps among the process's ancestors (the native overlay's `apps`), a few
5// environment variables and the surface decide it.
6
7/** An app among the overlay's ancestors, nearest first. */
8export type Ancestor = { pid: number; bundleId: string; regular: boolean }
9
10/** What the native overlay looks for: the app's windows (by pid, and every
11 * instance of the bundle), the frontmost of them or the largest. */
12export type Target = { surface: 'desktop' | 'terminal'; bundleId: string; pid: number | null; pick: 'largest' | 'frontmost' }
13
14/** The environment variables that name the terminal app. */
15export type TerminalEnv = { TERM_PROGRAM?: string; __CFBundleIdentifier?: string; TERM?: string }
16
17/** The Claude desktop app (/Applications/Claude.app). */
18export const CLAUDE_DESKTOP = 'com.anthropic.claudefordesktop'
19
20/** The terminal apps known by bundle id, with their names. VS Code's family
21 * hosts Claude Code in its integrated terminal (and its extension). */
22export const TERMINALS: Readonly<Record<string, string>> = {
23 'com.mitchellh.ghostty': 'Ghostty',
24 'com.apple.Terminal': 'Terminal',
25 'com.googlecode.iterm2': 'iTerm2',
26 'net.kovidgoyal.kitty': 'kitty',
27 'com.github.wez.wezterm': 'WezTerm',
28 'com.microsoft.VSCode': 'VS Code',
29 'com.microsoft.VSCodeInsiders': 'VS Code Insiders',
30 'com.todesktop.230313mzl4w4u92': 'Cursor',
31 'com.vscodium': 'VSCodium',
32 'com.exafunction.windsurf': 'Windsurf',
33 'dev.warp.Warp-Stable': 'Warp',
34 'org.alacritty': 'Alacritty',
35}
36
37/** TERM_PROGRAM's values for the terminals that set it. `vscode` is VS Code's
38 * whole family: __CFBundleIdentifier tells which. */
39const TERM_PROGRAMS: Readonly<Record<string, string>> = {
40 Apple_Terminal: 'com.apple.Terminal',
41 'iTerm.app': 'com.googlecode.iterm2',
42 ghostty: 'com.mitchellh.ghostty',
43 WezTerm: 'com.github.wez.wezterm',
44 kitty: 'net.kovidgoyal.kitty',
45 vscode: 'com.microsoft.VSCode',
46 WarpTerminal: 'dev.warp.Warp-Stable',
47}
48
49const isTerminal = (id: string | undefined): id is string => !!id && id in TERMINALS
50
51/** The terminal app the environment names: __CFBundleIdentifier when it is a
52 * known terminal (it tells VS Code from Cursor), else TERM_PROGRAM, else
53 * kitty's TERM. */
54export function terminalFromEnv(env: TerminalEnv): string | null {
55 if (isTerminal(env.__CFBundleIdentifier)) return env.__CFBundleIdentifier
56 const named = env.TERM_PROGRAM ? TERM_PROGRAMS[env.TERM_PROGRAM] : undefined
57 if (named) return named
58 return env.TERM === 'xterm-kitty' ? 'net.kovidgoyal.kitty' : null
59}
60
61export type TargetInput = { isDesktop: boolean; env: TerminalEnv; ancestors: readonly Ancestor[] }
62
63/**
64 * The window to play over, or null when nothing names one (the overlay then
65 * plays over the screen under the pointer).
66 *
67 * - Desktop: the Claude app, its largest window (the main window, not a
68 * panel), its pid when it is an ancestor.
69 * - Terminal, the frontmost window of, in order: the nearest known terminal
70 * among the ancestors (exact: its pid); the terminal the environment names
71 * (under tmux or screen the ancestors end at the multiplexer's server); the
72 * nearest regular app among the ancestors (a terminal not listed here).
73 */
74export function selectTarget({ isDesktop, env, ancestors }: TargetInput): Target | null {
75 const pidOf = (bundleId: string) => ancestors.find(a => a.bundleId === bundleId)?.pid ?? null
76 if (isDesktop) return { surface: 'desktop', bundleId: CLAUDE_DESKTOP, pid: pidOf(CLAUDE_DESKTOP), pick: 'largest' }
77 const terminal = (bundleId: string, pid: number | null): Target => ({ surface: 'terminal', bundleId, pid, pick: 'frontmost' })
78 const known = ancestors.find(a => isTerminal(a.bundleId))
79 if (known) return terminal(known.bundleId, known.pid)
80 const named = terminalFromEnv(env)
81 if (named) return terminal(named, null)
82 const app = ancestors.find(a => a.regular)
83 return app ? terminal(app.bundleId, app.pid) : null
84}
85hooks/drips/effects.ts 160 lines1// The drip system's pure half, shared by every surface: the registries built
2// from the drips (one file each, listed in ./effects/index.ts), the recipe
3// grammar, and the documents the overlay loads. Ported from the
4// desktop app Drip began in.
5//
6// Every document closes itself with the drip close signal (./effects/shared.ts)
7// but a looping one, which plays until it is stopped.
8
9import type { LoopDrip, OneShotDrip } from './effects/drips.ts'
10import { DRIPS } from './effects/index.ts'
11import { CLOSE_SIGNAL, soundScript } from './effects/shared.ts'
12import { DRIP_SAFETY_MS, type FolderDrip } from '../content/folders.ts'
13
14// ── Effect registries ───────────────────────────────────────
15
16const loops = DRIPS.filter((d): d is LoopDrip => d.kind === "loop");
17const oneShots = DRIPS.filter((d): d is OneShotDrip => d.kind === "one-shot");
18
19export const LOOP_EFFECTS: Record<string, (w: number, h: number, opacity: number) => string> =
20 Object.fromEntries(loops.map(d => [d.name, d.build]));
21
22function wrapOneShot(builder: (w: number, h: number, opacity: number) => string): (w: number, h: number) => string {
23 return (w, h) => {
24 const html = builder(w, h, 0.8);
25 return html.replace("</body></html>", `<script>setTimeout(function(){${CLOSE_SIGNAL}},8000)</${"script"}></body></html>`);
26 };
27}
28
29/** Every drip played once: the one-shots, then the looping backgrounds
30 * bounded at 8 s. */
31export const ONE_SHOT_EFFECTS: Record<string, (w: number, h: number) => string> = Object.fromEntries([
32 ...oneShots.map(d => [d.name, d.build] as const),
33 ...loops.map(d => [d.name, wrapOneShot(d.build)] as const),
34]);
35
36const hidden = new Set(oneShots.filter(d => d.isHidden).map(d => d.name));
37
38/** Flat name list (compat surface: `.drip list`, agent list_drips). A hidden
39 * drip (timme, an easter egg) is playable by name, never listed. */
40export const BUILTIN_EFFECTS = Object.keys(ONE_SHOT_EFFECTS).filter(k => !hidden.has(k));
41
42// ── Builtin-effect catalog (the drips menus' surface) ───────
43
44export interface BuiltinEffectInfo {
45 name: string;
46 /** 'loop' effects are ambient backgrounds (previewed as a bounded one-shot,
47 * run persistent via `-1 <name>`); 'one-shot' effects play once and end. */
48 kind: "one-shot" | "loop";
49}
50
51/** Every listed effect, one-shots first then the looping
52 * backgrounds: the structured list the drips menus render. */
53export const BUILTIN_EFFECT_LIST: BuiltinEffectInfo[] = [
54 ...BUILTIN_EFFECTS.filter(k => !(k in LOOP_EFFECTS)).map(name => ({ name, kind: "one-shot" as const })),
55 ...Object.keys(LOOP_EFFECTS).map(name => ({ name, kind: "loop" as const })),
56];
57
58// ── Recipe grammar ──────────────────────────────────────────
59//
60// A recipe is whitespace-separated tokens, order-free:
61// `-1` → persistent (looping) run
62// 0 < n < 1 → opacity
63// integer n ≥ 1 → repeat count (capped at 10)
64// anything else → an effect name (custom names expand via `resolveEffect`)
65
66export interface ParsedDripRecipe {
67 /** -1 = persistent; otherwise the repeat count (1..10). */
68 count: number;
69 /** 0..1, or -1 when the recipe named none. */
70 opacity: number;
71 effects: string[];
72}
73
74/**
75 * Parse a raw drip recipe. `resolveEffect` maps a name to its expansion
76 * (a custom drip's effect list) or null for a plain effect name — the
77 * desktop passes its customs cache; the web preview path passes nothing
78 * (menu tiles already carry raw recipes).
79 */
80export function parseDripRecipe(raw: string, resolveEffect?: (name: string) => string[] | null): ParsedDripRecipe {
81 const parts = raw.trim().split(/\s+/).filter(Boolean);
82 let count = 1;
83 let opacity = -1;
84 const effects: string[] = [];
85
86 for (const p of parts) {
87 const n = parseFloat(p);
88 if (p === "-1") { count = -1; }
89 else if (!isNaN(n) && n > 0 && n < 1) { opacity = n; }
90 else if (!isNaN(n) && Number.isInteger(n) && n > 0) { count = Math.min(n, 10); }
91 else {
92 const name = p.toLowerCase();
93 const expanded = resolveEffect?.(name);
94 if (expanded) effects.push(...expanded);
95 else effects.push(name);
96 }
97 }
98
99 if (effects.length === 0) effects.push("fireworks");
100 return { count, opacity, effects };
101}
102
103// ── The person's own drips (../content/folders.ts) ──────────
104
105/** Their drips by name, as the drips' half last read them. */
106let yours: Readonly<Record<string, FolderDrip>> = {}
107/** Sounds off: their drips start muted, as the built-in ones play silent. */
108let isQuiet = false
109
110/** Sets the person's own drips, read again before each play and listing. */
111export function setYourDrips(drips: readonly FolderDrip[], quiet: boolean): void {
112 yours = Object.fromEntries(drips.map(d => [d.name, d]))
113 isQuiet = quiet
114}
115
116/** The person's own drips, as last set. */
117export function yourDrips(): FolderDrip[] {
118 return Object.values(yours)
119}
120
121/** A drip that plays: built in (hidden ones too) or the person's own. */
122export function isKnownEffect(name: string): boolean {
123 return name in ONE_SHOT_EFFECTS || name in yours
124}
125
126// ── Effect-doc assembly ─────────────────────────────────────
127
128/** One-shot document (unknown names fall back to fireworks). */
129export function buildOneShotHtml(effect: string, w: number, h: number): string {
130 if (!ONE_SHOT_EFFECTS[effect] && yours[effect]) return yours[effect]!.build(w, h, 1, isQuiet);
131 const builder = ONE_SHOT_EFFECTS[effect] || ONE_SHOT_EFFECTS.fireworks!;
132 return builder(w, h);
133}
134
135/** Looping document; a one-shot name plays its bounded doc instead. */
136export function buildLoopHtml(effect: string, w: number, h: number, opacity: number): string {
137 const builder = LOOP_EFFECTS[effect];
138 if (builder) return builder(w, h, opacity);
139 if (!ONE_SHOT_EFFECTS[effect] && yours[effect]) return yours[effect]!.build(w, h, opacity, isQuiet);
140 const oneShot = ONE_SHOT_EFFECTS[effect] || ONE_SHOT_EFFECTS.fireworks!;
141 return oneShot(w, h);
142}
143
144/** A document with its effect's sounds when `isOn`: its script goes in just
145 * before </body>. */
146export function withSound(html: string, effect: string, isOn: boolean): string {
147 const sound = DRIPS.find(d => d.name === effect)?.sound;
148 const at = html.lastIndexOf("</body>");
149 if (!isOn || !sound || at < 0) return html;
150 return html.slice(0, at) + soundScript(sound) + html.slice(at);
151}
152
153/** Backstop teardown for a one-shot whose close signal never arrives: the
154 * drip's own, 8 s otherwise. */
155export function oneShotSafetyMs(effect: string): number {
156 if (!ONE_SHOT_EFFECTS[effect] && yours[effect]) return DRIP_SAFETY_MS;
157 const drip = DRIPS.find(d => d.name === effect);
158 return (drip && drip.kind !== "loop" ? drip.safetyMs : undefined) ?? 8000;
159}
160hooks/content/folders.ts 136 lines1// Games and drips from folders (the repository's README): the ones the plugin
2// ships (its games/ and drips/, a snapshot of timmetim/drip) and the
3// person's own (~/.drip/games, ~/.drip/drips), read every time they are
4// asked for, checked against ./rules, and made into the same shapes the
5// built-in ones have. A folder that fails, or is named like one already
6// there, is left out with the reason.
7import type { Game } from '../arcade/games/game.ts'
8import { COVER_FILES, checkDrip, checkGame, type Cover, type Read } from './rules.js'
9import { MARK } from './gallery.ts'
10import { withKit } from './kit.ts'
11
12/** What a folder read needs: the engine's fs, closed over in the hooks file. */
13export type FolderDeps = {
14 list: (path: string) => Promise<{ name: string; kind: string }[]>
15 read: (path: string) => Promise<string>
16 /** A file's bytes, standard base64 (a cover). */
17 bytes: (path: string) => Promise<string>
18 exists: (path: string) => Promise<boolean>
19}
20
21/** A game left out, or a drip: its name and why. */
22export type Skipped = { name: string; why: string }
23
24/** A game from a folder: a built-in game's shape, plus what the card shows. */
25export type FolderGame = Game & { isYours: boolean; about?: string; emoji?: string; icon: string | null; author: string }
26
27/** A drip from a folder. */
28export type FolderDrip = {
29 name: string
30 title: string
31 kind: 'one-shot' | 'loop'
32 icon: string | null
33 isYours: boolean
34 /** Who made it: a GitHub login, or `drip`. */
35 author: string
36 about?: string
37 emoji?: string
38 /** The page with the kit, for this size; `quiet` mutes it. */
39 build: (w: number, h: number, opacity: number, quiet: boolean) => string
40}
41
42/** A game's backstop, as the built-in games': 30 minutes. */
43export const GAME_SAFETY_MS = 30 * 60_000
44/** A one-shot drip's backstop: 15 seconds. */
45export const DRIP_SAFETY_MS = 15_000
46
47export const gamesDir = (home: string) => `${home}/.drip/games`
48export const dripsDir = (home: string) => `${home}/.drip/drips`
49
50/** A folder as read, and whether the gallery put it there (`get`'s mark). */
51type Found = Read & { isGallery: boolean }
52
53async function readFolders(d: FolderDeps, dir: string, file: 'game' | 'drip'): Promise<Found[]> {
54 if (!(await d.exists(dir).catch(() => false))) return []
55 const entries = await d.list(dir).catch(() => [])
56 const out: Found[] = []
57 for (const e of entries.filter(x => x.kind === 'dir').sort((a, b) => a.name.localeCompare(b.name))) {
58 const at = `${dir}/${e.name}`
59 const opt = async (p: string) => ((await d.exists(p).catch(() => false)) ? await d.read(p).catch(() => null) : null)
60 let cover: Cover | null = null
61 for (const f of COVER_FILES) {
62 if (cover || !(await d.exists(`${at}/${f}`).catch(() => false))) continue
63 const base64 = await d.bytes(`${at}/${f}`).catch(() => null)
64 if (base64 !== null) cover = { file: f, base64 }
65 }
66 out.push({ name: e.name, manifest: await opt(`${at}/${file}.json`), page: await opt(`${at}/${file}.html`), icon: await opt(`${at}/icon.svg`), cover, isGallery: await d.exists(`${at}/${MARK}`).catch(() => false) })
67 }
68 return out
69}
70
71/** The games in `dir`, beside `taken` (names already in use); `isYours` marks the person's own. */
72export async function loadGames(d: FolderDeps, dir: string, taken: ReadonlySet<string>, quiet: () => boolean, isYours: boolean): Promise<{ games: FolderGame[]; skipped: Skipped[] }> {
73 const games: FolderGame[] = []
74 const skipped: Skipped[] = []
75 for (const r of await readFolders(d, dir, 'game')) {
76 if (taken.has(r.name)) {
77 skipped.push({ name: r.name, why: 'a game already has that name' })
78 continue
79 }
80 const c = checkGame(r)
81 if (!c.ok) {
82 skipped.push({ name: r.name, why: c.why })
83 continue
84 }
85 const page = c.page
86 games.push({
87 name: r.name,
88 title: c.manifest.title,
89 ...(c.manifest.about ? { about: c.manifest.about } : {}),
90 ...(c.manifest.emoji ? { emoji: c.manifest.emoji } : {}),
91 icon: c.icon,
92 isYours: isYours && !r.isGallery,
93 author: c.manifest.author,
94 safetyMs: GAME_SAFETY_MS,
95 build: (w, h, best) => withKit(page, { kind: 'game', width: w, height: h, best, quiet: quiet() }),
96 })
97 }
98 return { games, skipped }
99}
100
101/** The drips in `dir`, beside `taken` (names already in use); `isYours` marks the person's own. */
102export async function loadDrips(d: FolderDeps, dir: string, taken: ReadonlySet<string>, isYours: boolean): Promise<{ drips: FolderDrip[]; skipped: Skipped[] }> {
103 const drips: FolderDrip[] = []
104 const skipped: Skipped[] = []
105 for (const r of await readFolders(d, dir, 'drip')) {
106 if (taken.has(r.name)) {
107 skipped.push({ name: r.name, why: 'a drip already has that name' })
108 continue
109 }
110 const c = checkDrip(r)
111 if (!c.ok) {
112 skipped.push({ name: r.name, why: c.why })
113 continue
114 }
115 const page = c.page
116 const kind = c.manifest.kind
117 drips.push({
118 name: r.name,
119 title: c.manifest.title,
120 kind,
121 ...(c.manifest.about ? { about: c.manifest.about } : {}),
122 ...(c.manifest.emoji ? { emoji: c.manifest.emoji } : {}),
123 icon: c.icon,
124 isYours: isYours && !r.isGallery,
125 author: c.manifest.author,
126 build: (w, h, opacity, quiet) => withKit(page, { kind: 'drip', width: w, height: h, opacity, quiet }),
127 })
128 }
129 return { drips, skipped }
130}
131
132/** What the listing says of the folders left out of `where` (~/.drip/games, say). */
133export function skippedText(where: string, skipped: Skipped[]): string {
134 return skipped.map(s => `Left out ${where}/${s.name}: ${s.why}.`).join('\n')
135}
136hooks/content/gallery.ts 96 lines1// The gallery: timmetim/drip, every game and drip there is but Missiles and
2// the drips built in. The Drip pane lists it (`listing`); `/drip get <name>`,
3// Get on the pane, or /drip <name>, fetches one into the person's folder
4// (~/.drip), checked against the rules first (./rules.js), and marks it as
5// the gallery's (MARK), not theirs.
6import { COVER_FILES, NAME, checkDrip, checkGame, type Cover } from './rules.js'
7
8/** Where the gallery's files are read from, raw. */
9export const GALLERY = 'https://raw.githubusercontent.com/timmetim/drip/main'
10/** Where its covers are read from: GitHub's API hands a file's bytes over as base64. */
11export const GALLERY_API = 'https://api.github.com/repos/timmetim/drip/contents'
12
13export type GalleryDeps = {
14 fetch: (url: string) => Promise<{ ok: boolean; status: number; text: string }>
15 write: (path: string, text: string) => Promise<void>
16 /** Writes bytes given as base64 (a cover); false when it couldn't. */
17 writeBytes: (path: string, base64: string) => Promise<boolean>
18}
19
20/** One game or drip as index.json lists it. */
21export type Entry = { name: string; title: string; author?: string; about?: string; emoji?: string; files: string[] }
22type Index = { games: Entry[]; drips: Entry[] }
23
24/** The file `get` leaves in a folder it fetched: it is the gallery's, not the person's. */
25export const MARK = '.gallery'
26
27/** What the gallery has; null when it can't be read (offline, or not public). */
28export async function listing(d: Pick<GalleryDeps, 'fetch'>): Promise<Index | null> {
29 const res = await d.fetch(`${GALLERY}/index.json`).catch(() => null)
30 return res?.ok ? parseIndex(res.text) : null
31}
32
33/** The files a gallery entry may bring, by kind. */
34const FILES = { game: ['game.json', 'game.html', 'icon.svg'], drip: ['drip.json', 'drip.html', 'icon.svg'] } as const
35
36/** A cover's base64 from GitHub's contents API; null when the answer isn't one. */
37function coverOf(text: string): string | null {
38 try {
39 const v = JSON.parse(text) as { encoding?: unknown; content?: unknown }
40 return v.encoding === 'base64' && typeof v.content === 'string' ? v.content.replace(/\s+/g, '') : null
41 } catch {
42 return null
43 }
44}
45
46function parseIndex(text: string): Index | null {
47 try {
48 const v: unknown = JSON.parse(text)
49 if (!v || typeof v !== 'object') return null
50 const { games, drips } = v as Record<string, unknown>
51 const ok = (xs: unknown): xs is Entry[] =>
52 Array.isArray(xs) && xs.every(x => x && typeof x === 'object' && typeof (x as Entry).name === 'string' && Array.isArray((x as Entry).files))
53 return ok(games) && ok(drips) ? { games, drips } : null
54 } catch {
55 return null
56 }
57}
58
59/** Fetches `name` from the gallery into `home`'s folders; says what came of it. */
60export async function get(d: GalleryDeps, home: string, name: string, builtIn: ReadonlySet<string>): Promise<string> {
61 const want = name.trim().toLowerCase()
62 if (!NAME.test(want)) return `A name is lowercase letters, digits and hyphens: /drip get snake.`
63 if (builtIn.has(want)) return `${want} comes with Drip: /drip ${want} plays it.`
64 const res = await d.fetch(`${GALLERY}/index.json`).catch(() => null)
65 if (!res?.ok) return `Couldn't reach the gallery${res ? ` (${res.status})` : ''}. Try again in a moment.`
66 const index = parseIndex(res.text)
67 if (!index) return "The gallery's index didn't read. Try again later."
68 const game = index.games.find(e => e.name === want)
69 const drip = game ? undefined : index.drips.find(e => e.name === want)
70 const entry = game ?? drip
71 if (!entry) return `No game or drip called "${want}" in the gallery.`
72 const kind = game ? 'game' : 'drip'
73 const files = new Map<string, string>()
74 for (const file of entry.files) {
75 if (!(FILES[kind] as readonly string[]).includes(file)) continue
76 const r = await d.fetch(`${GALLERY}/${kind}s/${want}/${file}`).catch(() => null)
77 if (!r?.ok) return `Couldn't fetch ${file} for ${want}${r ? ` (${r.status})` : ''}. Try again in a moment.`
78 files.set(file, r.text)
79 }
80 let cover: Cover | null = null
81 const coverFile = COVER_FILES.find(f => entry.files.includes(f))
82 if (coverFile) {
83 const r = await d.fetch(`${GALLERY_API}/${kind}s/${want}/${coverFile}?ref=main`).catch(() => null)
84 const base64 = r?.ok ? coverOf(r.text) : null
85 if (base64 === null) return `Couldn't fetch ${coverFile} for ${want}${r ? ` (${r.status})` : ''}. Try again in a moment.`
86 cover = { file: coverFile, base64 }
87 }
88 const read = { name: want, manifest: files.get(`${kind}.json`) ?? null, page: files.get(`${kind}.html`) ?? null, icon: files.get('icon.svg') ?? null, cover }
89 const checked = kind === 'game' ? checkGame(read) : checkDrip(read)
90 if (!checked.ok) return `The gallery's ${want} doesn't pass the check (${checked.why}), so it wasn't kept.`
91 for (const [file, text] of files) await d.write(`${home}/.drip/${kind}s/${want}/${file}`, text)
92 await d.write(`${home}/.drip/${kind}s/${want}/${MARK}`, 'timmetim/drip\n')
93 if (!(await d.writeBytes(`${home}/.drip/${kind}s/${want}/${checked.cover.file}`, checked.cover.base64))) return `Couldn't save ${want}'s cover. Try again in a moment.`
94 return `Got ${checked.manifest.title}: /drip ${want} plays it.`
95}
96hooks/content/rules.js 188 lines1// The rules a game or drip folder must pass (the repository's README), as pure
2// functions over what was read from it. Plain JavaScript, so the plugin and
3// the skills' checker (skills/*/check.js, run with Node before Claude says a
4// game or drip is done) apply the very same rules.
5
6/** A folder's name: lowercase letters, digits and hyphens. */
7export const NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
8export const TITLE_MAX = 32
9export const ABOUT_MAX = 60
10/** A page's most, in characters. */
11export const PAGE_MAX = 512 * 1024
12export const ICON_MAX = 16 * 1024
13/** A cover's most, in bytes, and its sizes: 16:10, 480×300 to 1920×1200. */
14export const COVER_MAX = 300 * 1024
15export const COVER_MIN_W = 480
16export const COVER_MAX_W = 1920
17/** A GitHub login: letters, digits and single hyphens, at most 39. */
18export const AUTHOR = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/
19/** The author Drip's own carry, which the pane shows as "Drip built". */
20export const DRIP_AUTHOR = 'drip'
21/** The cover files a folder may have, in the order they are looked for. */
22export const COVER_FILES = ['cover.jpg', 'cover.png']
23
24/** What reaches the network: none of it may appear in a page. */
25const NETWORK = [
26 /\bfetch\s*\(/,
27 /\bXMLHttpRequest\b/,
28 /\bWebSocket\b/,
29 /\bEventSource\b/,
30 /\bsendBeacon\b/,
31 /\bimport\s*\(/,
32 /\b(?:src|href|action)\s*=\s*["']?\s*(?:https?:)?\/\//i,
33 /url\(\s*["']?\s*(?:https?:)?\/\//i,
34 /@import\b/i,
35]
36
37/**
38 * @typedef {{ file: string, base64: string }} Cover
39 * cover.jpg or cover.png as read: its name and its bytes, standard base64.
40 * @typedef {{ name: string, manifest: string | null, page: string | null, icon: string | null, cover: Cover | null }} Read
41 * A folder as read: its name, game.json or drip.json, game.html or drip.html, icon.svg and its cover (null when absent).
42 * @typedef {{ title: string, author: string, about?: string, emoji?: string }} GameManifest
43 * @typedef {{ title: string, author: string, kind: 'one-shot' | 'loop', about?: string, emoji?: string }} DripManifest
44 */
45
46const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
47
48/** Standard base64 to bytes; null when it isn't base64. @param {string} text @returns {Uint8Array | null} */
49function bytesOf(text) {
50 const t = text.replace(/\s+/g, '').replace(/=+$/, '')
51 if (/[^A-Za-z0-9+/]/.test(t) || t.length % 4 === 1) return null
52 const out = new Uint8Array(Math.floor((t.length * 3) / 4))
53 let at = 0
54 for (let i = 0; i < t.length; i += 4) {
55 const n = [0, 1, 2, 3].map(k => (i + k < t.length ? B64.indexOf(t[i + k]) : 0))
56 const v = (n[0] << 18) | (n[1] << 12) | (n[2] << 6) | n[3]
57 for (const [k, shift] of [[1, 16], [2, 8], [3, 0]]) if (i + k < t.length) out[at++] = (v >> shift) & 255
58 }
59 return out
60}
61
62/**
63 * A cover's kind and size in pixels, read from its header; null when it is
64 * neither a PNG nor a JPEG it can read.
65 * @param {Uint8Array} b @returns {{ type: 'png' | 'jpeg', w: number, h: number } | null}
66 */
67export function imageSize(b) {
68 if (b.length > 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
69 const w = ((b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19]) >>> 0
70 const h = ((b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23]) >>> 0
71 return { type: 'png', w, h }
72 }
73 if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
74 let i = 2
75 while (i + 9 < b.length) {
76 if (b[i] !== 0xff) return null
77 const marker = b[i + 1]
78 if (marker === 0xff) {
79 i++
80 continue
81 }
82 const len = (b[i + 2] << 8) | b[i + 3]
83 // A start of frame (SOF0–SOF15, not DHT, JPG or DAC) holds the size.
84 if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
85 return { type: 'jpeg', h: (b[i + 5] << 8) | b[i + 6], w: (b[i + 7] << 8) | b[i + 8] }
86 }
87 i += 2 + len
88 }
89 }
90 return null
91}
92
93/** Why a cover won't do, or null. @param {Cover | null} c @returns {string | null} */
94function coverWhy(c) {
95 if (c === null) return 'it has no cover.jpg or cover.png (its picture)'
96 const b = bytesOf(c.base64)
97 if (b === null) return `${c.file} could not be read`
98 if (b.length > COVER_MAX) return `${c.file} is over 300 KB`
99 const size = imageSize(b)
100 if (!size || size.type !== (c.file === 'cover.png' ? 'png' : 'jpeg')) return `${c.file} is not a ${c.file === 'cover.png' ? 'PNG' : 'JPEG'}`
101 if (size.w < COVER_MIN_W || size.w > COVER_MAX_W) return `${c.file} is ${size.w}×${size.h}: it must be 480 to 1920 wide (960×600 is right)`
102 if (Math.abs(size.w / size.h - 1.6) > 0.02) return `${c.file} is ${size.w}×${size.h}: it must be 16:10 (960×600 is right)`
103 return null
104}
105
106/** An emoji: one short run of non-ASCII characters (an emoji with its joiners and selectors). */
107export const EMOJI = /^[^\x00-\x7F]{1,12}$/
108
109/** Why the optional about and emoji won't do, or null; else what they add to the manifest.
110 * @param {Record<string, unknown>} m @param {string} file @returns {{ why: string } | { extra: { about?: string, emoji?: string } }} */
111function extras(m, file) {
112 if (m.about !== undefined && (typeof m.about !== 'string' || m.about.length > ABOUT_MAX)) return { why: `${file}.json's about must be text of at most ${ABOUT_MAX} characters` }
113 if (m.emoji !== undefined && (typeof m.emoji !== 'string' || !EMOJI.test(m.emoji.trim()))) return { why: `${file}.json's emoji must be one emoji` }
114 return {
115 extra: {
116 ...(typeof m.about === 'string' && m.about.trim() ? { about: m.about.trim() } : {}),
117 ...(typeof m.emoji === 'string' ? { emoji: m.emoji.trim() } : {}),
118 },
119 }
120}
121
122/** Why an author won't do, or null. @param {Record<string, unknown>} m @param {string} file @returns {string | null} */
123function authorWhy(m, file) {
124 if (typeof m.author !== 'string' || !m.author.trim()) return `${file}.json has no author (the maker's GitHub login)`
125 if (!AUTHOR.test(m.author.trim())) return `${file}.json's author must be a GitHub login`
126 return null
127}
128
129/** @param {Read} r @param {string} file @returns {string | null} */
130function common(r, file) {
131 if (!NAME.test(r.name)) return `its folder name "${r.name}" must be lowercase letters, digits and hyphens`
132 if (r.manifest === null) return `it has no ${file}.json`
133 if (r.page === null) return `it has no ${file}.html`
134 if (r.page.length > PAGE_MAX) return `${file}.html is over 512 KB`
135 if (NETWORK.some(re => re.test(r.page))) return `${file}.html reaches the network, which isn't allowed`
136 if (r.icon !== null) {
137 if (r.icon.length > ICON_MAX) return 'icon.svg is over 16 KB'
138 if (!/^\s*(<\?xml[^>]*>\s*)?<svg[\s>]/i.test(r.icon)) return 'icon.svg is not an SVG'
139 if (/<script|\bon[a-z]+\s*=|(?:href|src)\s*=\s*["']?\s*(?:https?:)?\/\//i.test(r.icon)) return 'icon.svg has scripts or links'
140 }
141 return coverWhy(r.cover)
142}
143
144/** @param {string} text @returns {Record<string, unknown> | null} */
145function parsed(text) {
146 try {
147 const v = JSON.parse(text)
148 return v && typeof v === 'object' && !Array.isArray(v) ? v : null
149 } catch {
150 return null
151 }
152}
153
154/** @param {Record<string, unknown>} m @param {string} file @returns {string | null} */
155function titled(m, file) {
156 if (typeof m.title !== 'string' || !m.title.trim()) return `${file}.json has no title`
157 if (m.title.trim().length > TITLE_MAX) return `${file}.json's title is over ${TITLE_MAX} characters`
158 return null
159}
160
161/** @param {Read} r @returns {{ ok: true, manifest: GameManifest, page: string, icon: string | null, cover: Cover } | { ok: false, why: string }} */
162export function checkGame(r) {
163 const why = common(r, 'game')
164 if (why) return { ok: false, why }
165 const m = parsed(/** @type {string} */ (r.manifest))
166 if (!m) return { ok: false, why: 'game.json is not a JSON object' }
167 const t = titled(m, 'game') ?? authorWhy(m, 'game')
168 if (t) return { ok: false, why: t }
169 const x = extras(m, 'game')
170 if ('why' in x) return { ok: false, why: x.why }
171 const manifest = { title: String(m.title).trim(), author: String(m.author).trim(), ...x.extra }
172 return { ok: true, manifest, page: r.page, icon: r.icon, cover: /** @type {Cover} */ (r.cover) }
173}
174
175/** @param {Read} r @returns {{ ok: true, manifest: DripManifest, page: string, icon: string | null, cover: Cover } | { ok: false, why: string }} */
176export function checkDrip(r) {
177 const why = common(r, 'drip')
178 if (why) return { ok: false, why }
179 const m = parsed(/** @type {string} */ (r.manifest))
180 if (!m) return { ok: false, why: 'drip.json is not a JSON object' }
181 const t = titled(m, 'drip') ?? authorWhy(m, 'drip')
182 if (t) return { ok: false, why: t }
183 if (m.kind !== 'one-shot' && m.kind !== 'loop') return { ok: false, why: 'drip.json\'s kind must be "one-shot" or "loop"' }
184 const x = extras(m, 'drip')
185 if ('why' in x) return { ok: false, why: x.why }
186 return { ok: true, manifest: { title: String(m.title).trim(), author: String(m.author).trim(), kind: m.kind, ...x.extra }, page: /** @type {string} */ (r.page), icon: r.icon, cover: /** @type {Cover} */ (r.cover) }
187}
188hooks/content/share.ts 97 lines1// /drip share <name>: one of the person's own games or drips, sent to the
2// gallery (timmetim/drip) as a pull request, all from the plugin. It runs
3// the GitHub command-line tool, signed in as the person: their fork of the
4// gallery, synced; a branch; the folder's files; the pull request. We review
5// and merge it there; from then on anyone can /drip get it.
6import { COVER_FILES, DRIP_AUTHOR, checkDrip, checkGame, type Cover } from './rules.js'
7
8/** The gallery's repository on GitHub. */
9export const REPO = 'timmetim/drip'
10
11export type Run = (argv: string[], opts?: { stdin?: string; timeoutMs?: number }) => Promise<{ exitCode: number; stdout: string; stderr: string } | null>
12
13export type ShareDeps = {
14 run: Run
15 exists: (path: string) => Promise<boolean>
16 read: (path: string) => Promise<string>
17 /** A file's bytes, standard base64 (the cover). */
18 bytes: (path: string) => Promise<string>
19 now: () => Promise<number>
20}
21
22/** What it says when the GitHub tool isn't there or isn't signed in. */
23export const NEEDS_GH = 'Sharing needs the GitHub command-line tool, signed in: brew install gh, then gh auth login.'
24
25const FILES = { game: ['game.json', 'game.html', 'icon.svg'], drip: ['drip.json', 'drip.html', 'icon.svg'] } as const
26
27/** `gh` as the plugin finds it: on PATH, else where Homebrew puts it. */
28async function ghPath(d: ShareDeps): Promise<string | null> {
29 for (const gh of ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh']) {
30 const r = await d.run([gh, '--version'])
31 if (r?.exitCode === 0) return gh
32 }
33 return null
34}
35
36const say = (r: { stderr: string; stdout: string } | null) => (r ? (r.stderr || r.stdout).trim().split('\n')[0] ?? '' : 'it did not run')
37
38/** Shares `name` from `home`'s folders; says what came of it, the pull request's link when it worked. */
39export async function share(d: ShareDeps, home: string, name: string): Promise<string> {
40 const want = name.trim().toLowerCase()
41 const kind = (await d.exists(`${home}/.drip/games/${want}/game.json`)) ? 'game' : (await d.exists(`${home}/.drip/drips/${want}/drip.json`)) ? 'drip' : null
42 if (!kind) return `No game or drip of yours called "${want}" (they live in ~/.drip/games and ~/.drip/drips).`
43 const dir = `${home}/.drip/${kind}s/${want}`
44 const files = new Map<string, string>()
45 for (const f of FILES[kind]) if (await d.exists(`${dir}/${f}`)) files.set(f, await d.read(`${dir}/${f}`))
46 let cover: Cover | null = null
47 for (const f of COVER_FILES) if (!cover && (await d.exists(`${dir}/${f}`))) cover = { file: f, base64: await d.bytes(`${dir}/${f}`) }
48 const read = { name: want, manifest: files.get(`${kind}.json`) ?? null, page: files.get(`${kind}.html`) ?? null, icon: files.get('icon.svg') ?? null, cover }
49 const checked = kind === 'game' ? checkGame(read) : checkDrip(read)
50 if (!checked.ok) return `${want} doesn't pass the check (${checked.why}), so it wasn't shared.`
51 if (checked.manifest.author === DRIP_AUTHOR) return `${want}'s author is "${DRIP_AUTHOR}", which is Drip's own: put your GitHub login in ${kind}.json.`
52
53 const gh = await ghPath(d)
54 if (!gh) return NEEDS_GH
55 if ((await d.run([gh, 'auth', 'status']))?.exitCode !== 0) return NEEDS_GH
56 const who = await d.run([gh, 'api', 'user', '--jq', '.login'])
57 const user = who?.exitCode === 0 ? who.stdout.trim() : ''
58 if (!user) return `GitHub didn't say who you are: ${say(who)}`
59 if (checked.manifest.author.toLowerCase() !== user.toLowerCase()) return `${want}'s author is "${checked.manifest.author}", but you're signed in to GitHub as ${user}: only its maker shares it.`
60
61 // Their fork, created if new and brought up to date with the gallery.
62 const fork = `${user}/drip`
63 await d.run([gh, 'repo', 'fork', REPO, '--clone=false'], { timeoutMs: 60_000 })
64 const synced = await d.run([gh, 'repo', 'sync', fork, '--source', REPO, '--branch', 'main'], { timeoutMs: 60_000 })
65 if (synced?.exitCode !== 0) return `Couldn't bring your fork ${fork} up to date: ${say(synced)}`
66 const head = await d.run([gh, 'api', `repos/${fork}/git/ref/heads/main`, '--jq', '.object.sha'])
67 if (head?.exitCode !== 0 || !head.stdout.trim()) return `Couldn't read your fork ${fork}: ${say(head)}`
68
69 const branch = `${kind}-${want}-${await d.now()}`
70 const made = await d.run([gh, 'api', `repos/${fork}/git/refs`, '--input', '-'], { stdin: JSON.stringify({ ref: `refs/heads/${branch}`, sha: head.stdout.trim() }) })
71 if (made?.exitCode !== 0) return `Couldn't make a branch on ${fork}: ${say(made)}`
72
73 const sends: [string, string][] = []
74 for (const [file, text] of files) {
75 const b64 = await d.run(['/usr/bin/base64'], { stdin: text })
76 if (b64?.exitCode !== 0) return `Couldn't prepare ${file}: ${say(b64)}`
77 sends.push([file, b64.stdout])
78 }
79 sends.push([checked.cover.file, checked.cover.base64])
80 for (const [file, base64] of sends) {
81 const path = `${kind}s/${want}/${file}`
82 const sha = await d.run([gh, 'api', `repos/${fork}/contents/${path}?ref=${branch}`, '--jq', '.sha'])
83 const body = { message: `${checked.manifest.title}: ${file}`, branch, content: base64.replace(/\s+/g, ''), ...(sha?.exitCode === 0 && sha.stdout.trim() ? { sha: sha.stdout.trim() } : {}) }
84 const put = await d.run([gh, 'api', '-X', 'PUT', `repos/${fork}/contents/${path}`, '--input', '-'], { stdin: JSON.stringify(body), timeoutMs: 60_000 })
85 if (put?.exitCode !== 0) return `Couldn't add ${file} to ${fork}: ${say(put)}`
86 }
87
88 const what = kind === 'game' ? 'Game' : 'Drip'
89 const pr = await d.run(
90 [gh, 'pr', 'create', '-R', REPO, '--head', `${user}:${branch}`, '--base', 'main', '--title', `${what}: ${checked.manifest.title}`, '--body', `${what} \`${want}\`, shared from Drip with /drip share.`],
91 { timeoutMs: 60_000 },
92 )
93 if (pr?.exitCode !== 0) return `Couldn't open the pull request: ${say(pr)}`
94 const link = pr.stdout.trim().split('\n').pop() ?? ''
95 return `Shared ${checked.manifest.title}: ${link}. Once it's merged, anyone can /drip get ${want}.`
96}
97hooks/drips/library.ts 56 lines1// The saved recipes: name → recipe, kept in the mod's store under one key.
2// Names and recipes are normalised as the desktop app Drip began in did
3// (dripId, dripRecordOf): a name is trimmed and lowercased, so one preset has
4// one name; a recipe's whitespace is collapsed. Works on a Store, closures
5// over `$.store` that ./hooks.tsx hands it.
6import type { CustomDrip } from '../../types'
7
8export const STORE_KEY = 'customs'
9
10export type Store = {
11 get(key: string): Promise<unknown>
12 set(key: string, value: unknown): Promise<void>
13}
14
15/** A preset's name: trimmed and lowercased. */
16export const dripId = (name: string): string => String(name ?? '').trim().toLowerCase()
17
18export function dripRecordOf(name: string, recipe: string): CustomDrip {
19 return { name: dripId(name), recipe: String(recipe ?? '').trim().split(/\s+/).join(' ') }
20}
21
22/** The saved recipes as a map; anything in the store that is not one is passed over. */
23export async function readCustoms(store: Store): Promise<Record<string, string>> {
24 const kept = await store.get(STORE_KEY)
25 const out: Record<string, string> = {}
26 if (!kept || typeof kept !== 'object' || Array.isArray(kept)) return out
27 for (const [name, recipe] of Object.entries(kept as Record<string, unknown>)) {
28 if (typeof recipe === 'string' && dripId(name)) out[dripId(name)] = recipe
29 }
30 return out
31}
32
33export async function listCustoms(store: Store): Promise<CustomDrip[]> {
34 return Object.entries(await readCustoms(store)).map(([name, recipe]) => ({ name, recipe }))
35}
36
37/** Saves a preset; says what it did, as the app's `.drip save` does. */
38export async function saveCustom(store: Store, name: string, recipe: string): Promise<string> {
39 const rec = dripRecordOf(name, recipe)
40 if (!rec.name) return 'A drip needs a name.'
41 if (!rec.recipe) return `A drip needs a recipe: /drip save ${rec.name} <recipe>`
42 const customs = await readCustoms(store)
43 await store.set(STORE_KEY, { ...customs, [rec.name]: rec.recipe })
44 return `saved drip "${rec.name}" → ${rec.recipe}`
45}
46
47/** Deletes a preset; says what it did. */
48export async function deleteCustom(store: Store, name: string): Promise<string> {
49 const id = dripId(name)
50 const customs = await readCustoms(store)
51 if (!(id in customs)) return `no custom drip "${id}"`
52 delete customs[id]
53 await store.set(STORE_KEY, customs)
54 return `deleted drip "${id}"`
55}
56hooks/arcade/games/index.ts 25 lines1// The game built in: Missiles, free. Every other game is a folder in the
2// gallery (timmetim/drip), fetched from the Drip pane or /drip <name> (../hooks.tsx).
3import type { Game } from './game.ts'
4import { missiles } from './missiles.ts'
5
6export const GAMES: readonly Game[] = [missiles]
7
8/** A game by its name or one of its other names, any case; else null. */
9export function gameOf(name: string): Game | null {
10 const n = name.trim().toLowerCase()
11 return GAMES.find(g => g.name === n || (g.aliases ?? []).includes(n)) ?? null
12}
13
14/** A game's document for an overlay `w` by `h`: interactive, drawn with
15 * `best` to beat, its backstop its own; with Sounds off it starts muted
16 * (its M key still unmutes it). */
17export function gameDoc(game: Game, w: number, h: number, best: number, sounds: boolean): { html: string; closeAfterMs: number; interactive: true } {
18 const html = game.build(w, h, best)
19 return {
20 html: sounds ? html : html.replace(/<body[^>]*>/, m => `${m}<script>window.__dripsQuiet=true</${'script'}>`),
21 closeAfterMs: game.safetyMs,
22 interactive: true,
23 }
24}
25