SLOPSHOPPER

drip

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…

panebandspinnerguardcommandtool
v2.0.0MITupdated 2026-10-18timmetim/drip/plugins/drip
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · drip
│ ┃ Drip ✕ › fix the failing auth test and add an audit log call │ ┃ drip │ ┃ ⏺ Read(src/auth.ts) │ ┃ Search games and drips ⏎ search ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ All 21 Games 1 Drips 20 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 🚀   Missiles Keep the missiles off the gro ⎿ 3 pass, 1 fail │ ┃ │ ┃ 🎆   Fireworks Rockets burst over Claude ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ 💖   Hearts Hearts float up the window Dr ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ 🎊   Confetti A burst of confetti Drip ★ › /drip │ ┃ ⎿ drip: Drip is open in the side panel. │ ┃ ⭐   Stars Stars twinkle and fall Drip ★ │ ┃ │ ┃ 🌧️   Rain A shower over the window Drip │ ┃ │ ┃ 🫧   Bubbles Bubbles rise and pop Drip ☆ │ ┃ │ ┃ ✨   Sparkle Sparkles glint and fade Drip │ ┃ │ ┃ 🌀   Spiral A spinning spiral of light Dr │ ┃ │ ┃ 😀   Emoji A rain of emoji Drip ☆ [ Pla ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ 💧 drip

Draws

Pane · Drip
drip Search games and drips ⏎ search All 21 Games 1 Drips 20 🚀   Missiles Keep the missiles off the ground Game ☆ [ 🎆   Fireworks Rockets burst over Claude Drip ★ [ Play 💖   Hearts Hearts float up the window Drip ★ [ Play ] 🎊   Confetti A burst of confetti Drip ★ [ Play ] ⭐   Stars Stars twinkle and fall Drip ★ [ Play ] 🌧️   Rain A shower over the window Drip ☆ [ Play ] 🫧   Bubbles Bubbles rise and pop Drip ☆ [ Play ] ✨   Sparkle Sparkles glint and fade Drip ☆ [ Play ] 🌀   Spiral A spinning spiral of light Drip ☆ [ Play ] 😀   Emoji A rain of emoji Drip ☆ [ Play ] 🌊   Wave A wave rolls across Drip ☆ [ Play ] 👍   Thumbsup A big thumbs up Drip ☆ [ Play ] ✌️   Peace Peace signs drift by Drip ☆ [ Play ] 🐱   Cat A cat strolls across Drip ☆ [ Play ] 🤠   Powow A showdown at high noon Drip ★ [ Play ] ‹ 1 of 2 ›
Prompt hint
⟨Claude Code's own drawing⟩ 💧 drip
README

Drip

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.

What it is, and where it works

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.

Requirements

  • macOS and the Xcode Command Line Tools, which bring Swift (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.
  • Node.js 18 or newer (https://nodejs.org). It runs the Drips page: your saved drips and the editor.

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.

Commands

CommandWhat it does
/dripOpens 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 listLists the games and the drips
/drip save <name> <recipe>Saves a recipe under a name
/drip delete <name>Deletes a saved drip
/drip stopStops every effect that keeps playing
/drip pageOpens the Drips page: your saved drips and an editor

/drip runs straight away, even while Claude is working.

The Drip pane

/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.

Games

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.

Make your own

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.

The drip button

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.

Recipes

A recipe is a few words, in any order:

  • An effect: 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.
  • A number from 1 to 10: how many times to play them. Each effect starts 1.5 seconds after the one before.
  • A number between 0 and 1: how see-through the effect is (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.
  • A saved drip's name: its effects join in.

/drip alone plays fireworks.

Where effects play

Open /config and find Where effects play under Drip:

  • claude-window (the default) plays effects over the window Claude is running in: the Claude desktop app's window, or the terminal window running Claude Code (Ghostty, Terminal, iTerm2, kitty, WezTerm, the VS Code and Cursor terminals, and others). The effect sits just above that window and moves and resizes with it. Windows in front of Claude, menus and notifications stay in front. If Drip can't find the window, it plays over the screen under the pointer and says so once.
  • fullscreen plays effects over the whole screen under the pointer, above every app and on every Space.

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.

Sounds

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.

The Drips page

/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:

  • Name
  • Mode: One-shot, Counted (2 to 10 times) or Persistent (toggle on/off)
  • Effects: pick one or more; + Add effect adds another
  • Recipe preview: the recipe your choices make
  • ▶ Play and ■ Stop, Save, Cancel, and Delete (it asks first)

Leaving the editor stops whatever it played.

Claude can play them too

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.

Just for fun

Two more rows in /config, both off until you turn them on:

  • Confetti after a long turn: confetti when Claude finishes a turn that ran longer than two minutes.
  • Matrix while tools run: a faint matrix rain while Claude's tools run. It goes a moment after the last one finishes. A matrix you started yourself is left alone.

Where things are kept

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.

Security

  • The Drips page is served only on 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.
  • The session key is never written to a file or a log. The page is opened with a one-time address that stops working once it has been used.
  • Each effect runs in a sandboxed frame that can run its own animation and nothing else: it can't reach the page, your files or the network.
  • The overlay is built on your Mac from the source in the plugin (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.
  • Drip has no server of its own. The only network calls are to GitHub, to list and fetch the gallery's games and drips.

For developers

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.

Source 56 files
hooks/register.tsx 13 lines
1// 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}
13
hooks/arcade/hooks.tsx 640 lines
1// 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}
640
hooks/drips/hooks.tsx 474 lines
1// 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}
474
hooks/overlay/host.ts 147 lines
1// 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}
147
hooks/overlay/target.ts 85 lines
1// 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}
85
hooks/drips/effects.ts 160 lines
1// 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}
160
hooks/content/folders.ts 136 lines
1// 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}
136
hooks/content/gallery.ts 96 lines
1// 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}
96
hooks/content/rules.js 188 lines
1// 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}
188
hooks/content/share.ts 97 lines
1// /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}
97
hooks/drips/library.ts 56 lines
1// 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}
56
hooks/arcade/games/index.ts 25 lines
1// 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