SLOPSHOPPER

port-watch

/ports: every port listening on this machine, which project or container owns it, and whether your phone can reach it, with its Tailscale URL and a QR code to…

newpanecommandtoastprocesstimer
v0.1.0MITupdated 2026-10-23THEROCKSSS/claude-mods/mods/port-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · port-watch
│ ┃ Ports ✕ › fix the failing auth test and add an audit log call │ ┃ ⇄ Ports │ ┃ 0 dev servers · 0 reachable from your phone ⏺ Read(src/auth.ts) │ ┃ · Tailscale not found ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ │ Search port, project or process… ⏎ search ⏺ Bash(bun test) │ ┃ │ ⎿ 3 pass, 1 fail │ ┃ │ [ Dev servers 0 ] Phone ok 0 All 0 scanned │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Nothing listening was found (or the scan ✻ Worked for 42s · done 4:20 PM │ ┃ tools did not run). │ › /ports │ ⎿ port-watch: Ports: 0 dev servers listening, 0 reachable from you │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ports
⇄ Ports 0 dev servers · 0 reachable from your phone · Tailscale not found ╭──────────────────────────────────────────────────────────╮ │ Search port, project or process… ⏎ search │ │ │ │ [ Dev servers 0 ] Phone ok 0 All 0 scanned 0 s ago │ ╰──────────────────────────────────────────────────────────╯ Nothing listening was found (or the scan tools did not run).
README

<img src=".claude-plugin/icon.png" width="72" align="right" alt="">

port-watch

/ports answers "what is on 5173 again, and can my phone reach it?"

What it does

For every listening port, it shows:

  • who owns it: the project, named from the process's command line (including python -m modules), or the container
  • where it is bound: loopback only, all interfaces, or the tailnet
  • whether your phone can reach it

For a phone-reachable port you get its Tailscale URL and a QR code to scan. Tailscale Serve mappings are shown on the port they front.

  • Filter chips: Dev (your projects and containers), Phone ok, All. There is a search box too.
  • Open and Copy on each row.
  • It rescans every 20 s while the pane is open, and does nothing while it is closed.

It reads, and never changes:

  • netstat, tasklist and the process command lines on Windows; lsof and ps elsewhere
  • podman ps and docker ps for published ports, which the OS does not list as the container's
  • tailscale ip -4 and tailscale serve status

The QR codes come from the vendored qrcode-generator (MIT; see THIRD_PARTY_NOTICES.md).

No settings.

Source 4 files
hooks/register.tsx 271 lines
1// Port Watch: what is listening here, who owns it, and how to reach it from your phone.
2//   - Windows: netstat -ano, tasklist and each listener's command line (one CIM query); macOS and
3//     Linux: lsof and ps. podman's (or docker's) published ports are named by their container.
4//   - The Tailscale IP turns a port bound to every interface into a URL your phone can open,
5//     shown as text and as a QR code to scan. A port bound to 127.0.0.1 says why it cannot.
6//   - /ports opens the pane; it scans again every 20 s while it is open.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { PortRow } from '../types'
11import { parseLsof, parseNetstat, parsePublished, parseServe, parseTasklist, qrSvg, rows as toRows, type Served } from './ports'
12
13const PANE = 'port-watch'
14const PER_PAGE = 12
15const RESCAN_MS = 20_000
16const TAILSCALE_WINDOWS = 'C:/Program Files/Tailscale/tailscale.exe'
17
18const rows = atom({ plugin: 'port-watch', key: 'rows' } as const, [] as PortRow[])
19const scannedAt = atom({ plugin: 'port-watch', key: 'scannedAt' } as const, 0)
20const scanning = atom({ plugin: 'port-watch', key: 'scanning' } as const, false)
21const filter = atom({ plugin: 'port-watch', key: 'filter' } as const, 'dev' as 'dev' | 'phone' | 'all')
22const query = atom({ plugin: 'port-watch', key: 'query' } as const, '')
23const qr = atom({ plugin: 'port-watch', key: 'qr' } as const, 0)
24const page = atom({ plugin: 'port-watch', key: 'page' } as const, 0)
25const tailnetIp = atom({ plugin: 'port-watch', key: 'tailnetIp' } as const, '')
26const error = atom({ plugin: 'port-watch', key: 'error' } as const, '')
27
28export const register: Register = on => {
29  on('session.start', async ($, e, next) => {
30    const r = await next(e)
31    await $.command.register({ name: 'ports', description: 'What is listening on this machine, who owns it, and a QR code to open it on your phone' }).catch(() => {})
32    $.clock.every(RESCAN_MS, () => {
33      void (async () => {
34        if ((await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)) await scan($)
35      })().catch(() => {})
36    })
37    return r
38  })
39
40  on('command.run', { command: 'ports' }, async ($, e) => {
41    const arg = e.args.trim()
42    if (/^\d+$/.test(arg)) await update($, qr, () => Number(arg))
43    await $.ui.open({ id: PANE, title: 'Ports', focus: true })
44    await scan($)
45    const list = await read($, rows)
46    return { text: `Ports: ${list.filter(r => r.kind === 'dev').length} dev servers listening, ${list.filter(r => r.kind === 'dev' && r.phoneUrl).length} reachable from your phone.` }
47  })
48
49  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
50}
51
52// ── Scanning ──────────────────────────────────────────────────────────────
53
54const slash = (p: string) => p.replace(/\\/g, '/')
55
56async function run($: EngineInterface, argv: string[], timeoutMs = 20_000) {
57  const r = await $.process.run(argv, { timeoutMs }).catch(() => null)
58  return r && r.exitCode === 0 ? r.stdout : ''
59}
60
61// The Tailscale IP and what Tailscale Serve proxies, from whichever tailscale binary answers.
62async function findTailnet($: EngineInterface, isWindows: boolean): Promise<{ ip: string; served: Served[] }> {
63  for (const exe of isWindows ? [TAILSCALE_WINDOWS, 'tailscale'] : ['tailscale']) {
64    if (isWindows && exe === TAILSCALE_WINDOWS && !(await $.fs.exists(exe).catch(() => false))) continue
65    const ip = (await run($, [exe, 'ip', '-4'], 8_000)).trim().split(/\r?\n/)[0] ?? ''
66    if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return { ip, served: parseServe(await run($, [exe, 'serve', 'status', '--json'], 8_000)) }
67  }
68  return { ip: '', served: [] }
69}
70
71async function scan($: EngineInterface) {
72  if (await read($, scanning)) return
73  await update($, scanning, () => true)
74  try {
75    const isWindows = /^[A-Za-z]:\//.test(slash($.plugin.root))
76    const { ip, served } = await findTailnet($, isWindows)
77    let published = new Map()
78    for (const runtime of ['podman', 'docker']) {
79      const out = await run($, [runtime, 'ps', '--format', 'json'])
80      if (out.trim()) {
81        published = parsePublished(out)
82        break
83      }
84    }
85    let next: PortRow[]
86    if (isWindows) {
87      const listeners = parseNetstat(await run($, ['netstat', '-ano', '-p', 'TCP'])).concat(parseNetstat(await run($, ['netstat', '-ano', '-p', 'TCPv6'])))
88      const names = parseTasklist(await run($, ['tasklist', '/fo', 'csv', '/nh']))
89      const pids = [...new Set(listeners.map(l => l.pid).filter(p => p > 4))]
90      const commandLines = new Map<number, string>()
91      if (pids.length) {
92        const filterText = pids.map(p => `ProcessId=${p}`).join(' OR ')
93        const json = await run($, ['powershell', '-NoProfile', '-NonInteractive', '-Command', `Get-CimInstance Win32_Process -Filter "${filterText}" | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress`], 30_000)
94        try {
95          const parsed = JSON.parse(json || '[]')
96          for (const p of (Array.isArray(parsed) ? parsed : [parsed]) as { ProcessId?: number; CommandLine?: string }[]) if (p.ProcessId) commandLines.set(p.ProcessId, p.CommandLine ?? '')
97        } catch {}
98      }
99      next = toRows({ listeners, names, commandLines, published, served, tailnetIp: ip })
100    } else {
101      const listeners = parseLsof(await run($, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN']))
102      const commandLines = new Map<number, string>()
103      const pids = [...new Set(listeners.map(l => l.pid))]
104      if (pids.length) {
105        for (const line of (await run($, ['ps', '-o', 'pid=,command=', '-p', pids.join(',')])).split(/\r?\n/)) {
106          const m = line.trim().match(/^(\d+)\s+(.*)$/)
107          if (m) commandLines.set(Number(m[1]), m[2]!)
108        }
109      }
110      next = toRows({ listeners, names: new Map(), commandLines, published, served, tailnetIp: ip })
111    }
112    await update($, rows, () => next)
113    await update($, tailnetIp, () => ip)
114    await update($, scannedAt, () => Date.now())
115    await update($, error, () => (next.length ? '' : 'Nothing listening was found (or the scan tools did not run).'))
116  } finally {
117    await update($, scanning, () => false)
118  }
119}
120
121// ── Doing ─────────────────────────────────────────────────────────────────
122
123async function openUrl($: EngineInterface, url: string) {
124  const isWindows = /^[A-Za-z]:\//.test(slash($.plugin.root))
125  const uname = isWindows ? '' : (await run($, ['uname', '-s'], 3_000)).trim()
126  await $.process.run(isWindows ? ['explorer.exe', url] : [uname === 'Darwin' ? 'open' : 'xdg-open', url], { timeoutMs: 10_000 }).catch(() => null)
127}
128
129async function copy($: EngineInterface, text: string) {
130  const viaSurface = await $.ui.copy({ text }).catch(() => ({ isCopied: false }))
131  const isWindows = /^[A-Za-z]:\//.test(slash($.plugin.root))
132  const ok = viaSurface.isCopied || (await $.process.run(isWindows ? ['clip'] : ['pbcopy'], { stdin: text, timeoutMs: 5_000 }).catch(() => null))?.exitCode === 0
133  $.ui.toast(ok ? `Copied ${text}` : `Copy it by hand: ${text}`)
134}
135
136// ── The pane ──────────────────────────────────────────────────────────────
137
138export function shown(list: PortRow[], f: 'dev' | 'phone' | 'all', q: string): PortRow[] {
139  const words = q.toLowerCase().split(/\s+/).filter(Boolean)
140  return list.filter(r => (f === 'all' || r.kind === 'dev') && (f !== 'phone' || r.phoneUrl !== '') && words.every(w => `${r.port} ${r.owner} ${r.process}`.toLowerCase().includes(w)))
141}
142
143async function drawPane($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0]) {
144  const elements = $.ui.resolve(e)
145  const { Box, Text, Button } = elements
146  const Input = 'Input' in elements ? elements.Input : undefined
147  const Svg = 'Svg' in elements ? elements.Svg : undefined
148  const list = await read($, rows)
149  const f = await read($, filter)
150  const q = await read($, query)
151  const open = await read($, qr)
152  const at = await read($, page)
153  const ip = await read($, tailnetIp)
154  const when = await read($, scannedAt)
155  const isScanning = await read($, scanning)
156  const problem = await read($, error)
157  const visible = shown(list, f, q)
158  const pages = Math.max(1, Math.ceil(visible.length / PER_PAGE))
159  const p = Math.min(at, pages - 1)
160  const dev = list.filter(r => r.kind === 'dev').length
161  const phone = list.filter(r => r.kind === 'dev' && r.phoneUrl).length
162  const pill = (key: string, text: string, bg: string) => (
163    <Text key={key} backgroundColor={bg} color="inverseText" weight="semibold" size="caption">
164      {` ${text} `}
165    </Text>
166  )
167  const chip = (value: 'dev' | 'phone' | 'all', label: string) => (
168    <Button
169      key={`f-${value}`}
170      label={label}
171      variant={f === value ? 'primary' : undefined}
172      plain={f === value ? undefined : true}
173      dimColor={f !== value}
174      onPress={async () => {
175        await update($, filter, () => value)
176        await update($, page, () => 0)
177      }}
178    />
179  )
180
181  return (
182    <Box flexDirection="column" gap={1}>
183      <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2} flexWrap="wrap">
184        <Box flexDirection="column">
185          <Text size="title" weight="bold">
186            <Text color="claude">{'⇄ '}</Text>
187            {'Ports'}
188          </Text>
189          <Text size="footnote" dimColor>
190            {`${dev} dev servers · ${phone} reachable from your phone${ip ? ` over Tailscale (${ip})` : ' · Tailscale not found'}`}
191          </Text>
192        </Box>
193        <Box flexDirection="row" columnGap={1} alignItems="center">
194          <Button key="scan" label={isScanning ? 'scanning…' : '↻ Scan'} plain={isScanning ? true : undefined} onPress={() => scan($)} />
195          <Button key="close" label="✕" plain dimColor role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
196        </Box>
197      </Box>
198
199      <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1} gap={1}>
200        {Input && (
201          <Input
202            key="search"
203            placeholder="Search port, project or process…"
204            value={q}
205            submitLabel="search"
206            onInput={async v => {
207              await update($, query, () => v)
208              await update($, page, () => 0)
209            }}
210            onSubmit={v => update($, query, () => v)}
211          />
212        )}
213        <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
214          {chip('dev', `Dev servers ${dev}`)}
215          {chip('phone', `Phone ok ${phone}`)}
216          {chip('all', `All ${list.length}`)}
217          <Text size="caption" dimColor>{when ? `scanned ${Math.round((Date.now() - when) / 1000)} s ago` : ''}</Text>
218        </Box>
219      </Box>
220
221      {problem !== '' && <Text color="warning">{problem}</Text>}
222      {list.length > 0 && visible.length === 0 && <Text dimColor>Nothing matches. Try All, or another word.</Text>}
223
224      {visible.slice(p * PER_PAGE, (p + 1) * PER_PAGE).map(r => {
225        const isOpen = open === r.port
226        return (
227          <Box key={`port-${r.port}`} flexDirection="column" borderStyle="round" borderColor={isOpen ? 'claude' : r.phoneUrl ? 'success' : 'inactive'} borderDimColor={!isOpen && !r.phoneUrl} hover={{ borderColor: 'claude', borderDimColor: false }} paddingX={1}>
228            <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1} flexWrap="wrap">
229              <Text wrap="truncate-end">
230                <Text size="heading" weight="bold" mono>{`:${r.port}`}</Text>
231                <Text weight="semibold">{`  ${r.owner}`}</Text>
232                <Text size="footnote" dimColor>{`  ${r.process} · pid ${r.pid}`}</Text>
233              </Text>
234              {r.phoneUrl ? pill(`b-${r.port}`, 'PHONE OK', 'success') : r.bind === 'local' ? pill(`b-${r.port}`, 'THIS PC ONLY', 'inactive') : pill(`b-${r.port}`, r.bind.toUpperCase(), 'inactive')}
235            </Box>
236            <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
237              <Text mono size="footnote" dimColor={!r.phoneUrl}>
238                {r.phoneUrl || r.url}
239              </Text>
240              <Button key={`open-${r.port}`} label="Open ↗" plain onPress={() => openUrl($, r.url)} />
241              <Button key={`copy-${r.port}`} label="⧉ Copy" plain dimColor onPress={() => copy($, r.phoneUrl || r.url)} />
242              {r.phoneUrl !== '' && <Button key={`qr-${r.port}`} label={isOpen ? '▣ Hide QR' : '▣ QR'} variant={isOpen ? 'primary' : undefined} plain={isOpen ? undefined : true} onPress={() => update($, qr, v => (v === r.port ? 0 : r.port))} />}
243            </Box>
244            {isOpen && r.phoneUrl !== '' && (
245              <Box flexDirection="row" columnGap={2} alignItems="center">
246                {Svg ? <Svg key={`qr-svg-${r.port}`} source={qrSvg(r.phoneUrl)} alt={`QR code for ${r.phoneUrl}`} width={220} height={220} /> : <Text dimColor>This surface draws no pictures.</Text>}
247                <Text size="footnote" dimColor wrap="wrap">
248                  {`Scan with your phone's camera while it is on Tailscale.\n${r.phoneUrl}`}
249                </Text>
250              </Box>
251            )}
252            {!r.phoneUrl && r.bind === 'local' && r.kind === 'dev' && (
253              <Text size="caption" dimColor>
254                Bound to 127.0.0.1, so only this PC reaches it. Start it on 0.0.0.0, or `tailscale serve` it, to open it from your phone.
255              </Text>
256            )}
257          </Box>
258        )
259      })}
260
261      {pages > 1 && (
262        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
263          <Button key="prev" label="‹ Prev" plain dimColor={p === 0} onPress={() => update($, page, n => Math.max(0, n - 1))} />
264          <Text size="footnote" dimColor>{`${p + 1} of ${pages}`}</Text>
265          <Button key="next" label="Next ›" plain dimColor={p >= pages - 1} onPress={() => update($, page, n => Math.min(pages - 1, n + 1))} />
266        </Box>
267      )}
268    </Box>
269  )
270}
271
hooks/ports.ts 191 lines
1// What port-watch reads and how it names it. Pure: no $, so the tests call it directly.
2import { qrcode } from './vendor/qrcode.mjs'
3
4export type Listener = { address: string; port: number; pid: number }
5export type Published = { container: string; containerPort: number; hostIp: string }
6export type Served = { port: number; host: string; targetPort: number } // a Tailscale Serve mapping
7export type Bind = 'local' | 'all' | 'tailnet' | 'other'
8export type Kind = 'dev' | 'app' | 'system'
9export type PortRow = {
10  port: number
11  pid: number
12  process: string
13  owner: string // the project, container or package it belongs to, as best as can be told
14  kind: Kind // dev: a runtime, a container or a Serve proxy; app: a desktop program; system: Windows' own
15  bind: Bind
16  address: string
17  url: string // where a browser reaches it from here
18  phoneUrl: string // over Tailscale (its IP, or a Serve URL); '' when the phone cannot reach it
19}
20
21// Windows' own services and anything under 1024: never a dev server.
22const SYSTEM = new Set(['system', 'system idle process', 'svchost.exe', 'lsass.exe', 'wininit.exe', 'services.exe', 'spoolsv.exe', 'smss.exe', 'csrss.exe', 'winlogon.exe', 'searchindexer.exe', 'mdnsresponder.exe', 'jhi_service.exe'])
23// The processes a dev server runs as: language runtimes, web servers, container relays.
24const RUNTIMES = /^(node|nodejs|bun|deno|python\d*(\.\d+)?|pythonw|py|uvicorn|gunicorn|hypercorn|java|javaw|dotnet|ruby|php|php-cgi|go|air|caddy|nginx|httpd|gvproxy|wslrelay|podman|docker-proxy|com\.docker\.backend|vpnkit|esbuild|vite|next-server|cargo|rust|code)(\.exe)?$/i
25
26// `netstat -ano` LISTENING lines, IPv4 and IPv6.
27export function parseNetstat(text: string): Listener[] {
28  const out: Listener[] = []
29  for (const line of text.split(/\r?\n/)) {
30    const m = line.trim().match(/^TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)$/i)
31    if (m) out.push({ address: m[1]!.replace(/^\[|\]$/g, ''), port: Number(m[2]), pid: Number(m[3]) })
32  }
33  return out
34}
35
36// `lsof -nP -iTCP -sTCP:LISTEN` (macOS, Linux): COMMAND PID ... NAME (addr:port) (LISTEN).
37export function parseLsof(text: string): (Listener & { process: string })[] {
38  const out: (Listener & { process: string })[] = []
39  for (const line of text.split(/\r?\n/).slice(1)) {
40    const cols = line.trim().split(/\s+/)
41    const name = cols.find(c => /:\d+$/.test(c))
42    if (!name || cols.length < 2) continue
43    const at = name.lastIndexOf(':')
44    out.push({ process: cols[0]!, pid: Number(cols[1]), address: name.slice(0, at).replace(/^\[|\]$/g, '').replace(/^\*$/, '0.0.0.0'), port: Number(name.slice(at + 1)) })
45  }
46  return out
47}
48
49// `tasklist /fo csv /nh`: "name","pid",... as pid → name.
50export function parseTasklist(csv: string): Map<number, string> {
51  const out = new Map<number, string>()
52  for (const line of csv.split(/\r?\n/)) {
53    const m = line.match(/^"([^"]*)","(\d+)"/)
54    if (m) out.set(Number(m[2]), m[1]!)
55  }
56  return out
57}
58
59// `podman ps --format json` (or docker's, one object per line) as host port → its container.
60export function parsePublished(text: string): Map<number, Published> {
61  const out = new Map<number, Published>()
62  let list: unknown[] = []
63  try {
64    const parsed = JSON.parse(text)
65    list = Array.isArray(parsed) ? parsed : [parsed]
66  } catch {
67    list = text.split(/\r?\n/).filter(Boolean).flatMap(l => {
68      try {
69        return [JSON.parse(l)]
70      } catch {
71        return []
72      }
73    })
74  }
75  for (const c of list as { Names?: string[] | string; Ports?: unknown }[]) {
76    const name = Array.isArray(c.Names) ? (c.Names[0] ?? '') : String(c.Names ?? '')
77    for (const p of (Array.isArray(c.Ports) ? c.Ports : []) as { host_port?: number; container_port?: number; host_ip?: string; range?: number }[]) {
78      if (!p.host_port) continue
79      for (let i = 0; i < Math.max(1, p.range ?? 1); i++) out.set(p.host_port + i, { container: name, containerPort: (p.container_port ?? 0) + i, hostIp: p.host_ip ?? '' })
80    }
81  }
82  return out
83}
84
85// `tailscale serve status --json`: each tailnet port and the local port it proxies to.
86export function parseServe(text: string): Served[] {
87  let s: { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> }
88  try {
89    s = JSON.parse(text)
90  } catch {
91    return []
92  }
93  const out: Served[] = []
94  for (const [hostPort, web] of Object.entries(s?.Web ?? {})) {
95    const at = hostPort.lastIndexOf(':')
96    const proxy = Object.values(web?.Handlers ?? {}).find(h => typeof h?.Proxy === 'string')?.Proxy ?? ''
97    const target = Number(proxy.match(/:(\d+)(?:\/|$)/)?.[1] ?? 0)
98    if (at > 0 && target) out.push({ host: hostPort.slice(0, at), port: Number(hostPort.slice(at + 1)), targetPort: target })
99  }
100  return out
101}
102
103// A process's project from its command line: a projects/<name> folder, an npx package, the app a
104// node_modules tool runs for, a Python module, else its script's folder.
105export function ownerOf(commandLine: string, process: string): string {
106  const cl = commandLine.replace(/\\\\/g, '\\')
107  const project = cl.match(/[\\/]projects[\\/]([^\\/"'\s]+)/i)?.[1]
108  if (project) return project
109  const npx = cl.match(/npx-cli\.js"?\s+(?:-y\s+)?(@?[\w.-]+(?:\/[\w.-]+)?)/i)?.[1]
110  if (npx) return npx.replace(/@[\d.]+$/, '')
111  const app = cl.match(/([\w.-]+)[\\/]node_modules[\\/]/i)?.[1] // the app whose dependency runs it (vite, next…)
112  if (app) return app
113  const module = cl.match(/\s-m\s+([\w]+)/)?.[1]
114  if (module) return module
115  const script = cl.match(/([\w.-]+)[\\/][^\\/"'\s]+\.(?:m?js|ts|py)\b/i)?.[1]
116  if (script && !/^(bin|dist|src|scripts|node_modules)$/i.test(script)) return script
117  return process.replace(/\.exe$/i, '')
118}
119
120export function bindOf(address: string, tailnetIp: string): Bind {
121  if (address === '127.0.0.1' || address === '::1' || address === 'localhost') return 'local'
122  if (address === '0.0.0.0' || address === '::' || address === '*') return 'all'
123  if (tailnetIp && address === tailnetIp) return 'tailnet'
124  return 'other'
125}
126
127// One row per port (IPv4 and IPv6 listeners merged, the widest bind kept), sorted by port:
128// container ports netstat cannot see are added, and Tailscale Serve ports named by what they serve.
129export function rows(args: {
130  listeners: (Listener & { process?: string })[]
131  names: Map<number, string>
132  commandLines: Map<number, string>
133  published: Map<number, Published>
134  served?: Served[]
135  tailnetIp: string
136}): PortRow[] {
137  const rank: Record<Bind, number> = { all: 3, tailnet: 2, other: 1, local: 0 }
138  const served = args.served ?? []
139  const byPort = new Map<number, PortRow>()
140  const put = (row: PortRow) => {
141    const had = byPort.get(row.port)
142    if (!had || rank[row.bind] > rank[had.bind]) byPort.set(row.port, had ? { ...row, owner: had.owner.startsWith('pid ') ? row.owner : had.owner } : row)
143  }
144  for (const l of args.listeners) {
145    const process = l.process ?? args.names.get(l.pid) ?? `pid ${l.pid}`
146    const pub = args.published.get(l.port)
147    const bind = pub && pub.hostIp && pub.hostIp !== '0.0.0.0' ? bindOf(pub.hostIp, args.tailnetIp) : bindOf(l.address, args.tailnetIp)
148    const owner = pub ? `${pub.container} (container :${pub.containerPort})` : ownerOf(args.commandLines.get(l.pid) ?? '', process)
149    const isSystem = !pub && (l.pid <= 4 || SYSTEM.has(process.toLowerCase()) || l.port < 1024)
150    const kind: Kind = isSystem ? 'system' : pub || RUNTIMES.test(process) || /[\\/]projects[\\/]/i.test(args.commandLines.get(l.pid) ?? '') ? 'dev' : 'app'
151    put({
152      port: l.port,
153      pid: l.pid,
154      process,
155      owner,
156      kind,
157      bind,
158      address: l.address,
159      url: `http://127.0.0.1:${l.port}/`,
160      phoneUrl: args.tailnetIp && (bind === 'all' || bind === 'tailnet') ? `http://${args.tailnetIp}:${l.port}/` : '',
161    })
162  }
163  // Published container ports the host's netstat does not list (podman relays them from its VM).
164  for (const [port, pub] of args.published) {
165    if (byPort.has(port)) continue
166    const bind = bindOf(pub.hostIp || '0.0.0.0', args.tailnetIp)
167    put({ port, pid: 0, process: 'container', owner: `${pub.container} (container :${pub.containerPort})`, kind: 'dev', bind, address: pub.hostIp || '0.0.0.0', url: `http://127.0.0.1:${port}/`, phoneUrl: args.tailnetIp && bind !== 'local' ? `http://${args.tailnetIp}:${port}/` : '' })
168  }
169  // Tailscale Serve: the tailnet port says what it serves; the local server gets the Serve URL.
170  for (const s of served) {
171    const url = `https://${s.host}${s.port === 443 ? '' : `:${s.port}`}/`
172    const target = byPort.get(s.targetPort)
173    const proxy = byPort.get(s.port)
174    if (proxy) byPort.set(s.port, { ...proxy, kind: 'dev', owner: `serve → :${s.targetPort}${target ? ` ${target.owner}` : ''}`, phoneUrl: url })
175    if (target && !target.phoneUrl) byPort.set(s.targetPort, { ...target, kind: target.kind === 'system' ? 'system' : 'dev', phoneUrl: url })
176  }
177  return [...byPort.values()].sort((a, b) => a.port - b.port)
178}
179
180// A QR code for `text` as an SVG document: one path of dark modules, a quiet zone of 4.
181export function qrSvg(text: string, size = 220): string {
182  const qr = qrcode(0, 'M')
183  qr.addData(text)
184  qr.make()
185  const n = qr.getModuleCount()
186  const cells = n + 8
187  let d = ''
188  for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (qr.isDark(r, c)) d += `M${c + 4} ${r + 4}h1v1h-1z`
189  return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${cells} ${cells}" shape-rendering="crispEdges"><rect width="${cells}" height="${cells}" fill="#ffffff"/><path d="${d}" fill="#111111"/></svg>`
190}
191
hooks/vendor/qrcode.mjs 2238 lines
1//---------------------------------------------------------------------
2//
3// QR Code Generator for JavaScript
4//
5// Copyright (c) 2009 Kazuhiko Arase
6//
7// URL: http://www.d-project.com/
8//
9// Licensed under the MIT license:
10//  http://www.opensource.org/licenses/mit-license.php
11//
12// The word 'QR Code' is registered trademark of
13// DENSO WAVE INCORPORATED
14//  http://www.denso-wave.com/qrcode/faqpatent-e.html
15//
16//---------------------------------------------------------------------
17
18//---------------------------------------------------------------------
19// qrcode
20//---------------------------------------------------------------------
21
22/**
23 * qrcode
24 * @param typeNumber 1 to 40
25 * @param errorCorrectionLevel 'L','M','Q','H'
26 */
27export const qrcode = function(typeNumber, errorCorrectionLevel) {
28
29  const PAD0 = 0xEC;
30  const PAD1 = 0x11;
31
32  let _typeNumber = typeNumber;
33  const _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel];
34  let _modules = null;
35  let _moduleCount = 0;
36  let _dataCache = null;
37  const _dataList = [];
38
39  const _this = {};
40
41  const makeImpl = function(test, maskPattern) {
42
43    _moduleCount = _typeNumber * 4 + 17;
44    _modules = function(moduleCount) {
45      const modules = new Array(moduleCount);
46      for (let row = 0; row < moduleCount; row += 1) {
47        modules[row] = new Array(moduleCount);
48        for (let col = 0; col < moduleCount; col += 1) {
49          modules[row][col] = null;
50        }
51      }
52      return modules;
53    }(_moduleCount);
54
55    setupPositionProbePattern(0, 0);
56    setupPositionProbePattern(_moduleCount - 7, 0);
57    setupPositionProbePattern(0, _moduleCount - 7);
58    setupPositionAdjustPattern();
59    setupTimingPattern();
60    setupTypeInfo(test, maskPattern);
61
62    if (_typeNumber >= 7) {
63      setupTypeNumber(test);
64    }
65
66    if (_dataCache == null) {
67      _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList);
68    }
69
70    mapData(_dataCache, maskPattern);
71  };
72
73  const setupPositionProbePattern = function(row, col) {
74
75    for (let r = -1; r <= 7; r += 1) {
76
77      if (row + r <= -1 || _moduleCount <= row + r) continue;
78
79      for (let c = -1; c <= 7; c += 1) {
80
81        if (col + c <= -1 || _moduleCount <= col + c) continue;
82
83        if ( (0 <= r && r <= 6 && (c == 0 || c == 6) )
84            || (0 <= c && c <= 6 && (r == 0 || r == 6) )
85            || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) {
86          _modules[row + r][col + c] = true;
87        } else {
88          _modules[row + r][col + c] = false;
89        }
90      }
91    }
92  };
93
94  const getBestMaskPattern = function() {
95
96    let minLostPoint = 0;
97    let pattern = 0;
98
99    for (let i = 0; i < 8; i += 1) {
100
101      makeImpl(true, i);
102
103      const lostPoint = QRUtil.getLostPoint(_this);
104
105      if (i == 0 || minLostPoint > lostPoint) {
106        minLostPoint = lostPoint;
107        pattern = i;
108      }
109    }
110
111    return pattern;
112  };
113
114  const setupTimingPattern = function() {
115
116    for (let r = 8; r < _moduleCount - 8; r += 1) {
117      if (_modules[r][6] != null) {
118        continue;
119      }
120      _modules[r][6] = (r % 2 == 0);
121    }
122
123    for (let c = 8; c < _moduleCount - 8; c += 1) {
124      if (_modules[6][c] != null) {
125        continue;
126      }
127      _modules[6][c] = (c % 2 == 0);
128    }
129  };
130
131  const setupPositionAdjustPattern = function() {
132
133    const pos = QRUtil.getPatternPosition(_typeNumber);
134
135    for (let i = 0; i < pos.length; i += 1) {
136
137      for (let j = 0; j < pos.length; j += 1) {
138
139        const row = pos[i];
140        const col = pos[j];
141
142        if (_modules[row][col] != null) {
143          continue;
144        }
145
146        for (let r = -2; r <= 2; r += 1) {
147
148          for (let c = -2; c <= 2; c += 1) {
149
150            if (r == -2 || r == 2 || c == -2 || c == 2
151                || (r == 0 && c == 0) ) {
152              _modules[row + r][col + c] = true;
153            } else {
154              _modules[row + r][col + c] = false;
155            }
156          }
157        }
158      }
159    }
160  };
161
162  const setupTypeNumber = function(test) {
163
164    const bits = QRUtil.getBCHTypeNumber(_typeNumber);
165
166    for (let i = 0; i < 18; i += 1) {
167      const mod = (!test && ( (bits >> i) & 1) == 1);
168      _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod;
169    }
170
171    for (let i = 0; i < 18; i += 1) {
172      const mod = (!test && ( (bits >> i) & 1) == 1);
173      _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod;
174    }
175  };
176
177  const setupTypeInfo = function(test, maskPattern) {
178
179    const data = (_errorCorrectionLevel << 3) | maskPattern;
180    const bits = QRUtil.getBCHTypeInfo(data);
181
182    // vertical
183    for (let i = 0; i < 15; i += 1) {
184
185      const mod = (!test && ( (bits >> i) & 1) == 1);
186
187      if (i < 6) {
188        _modules[i][8] = mod;
189      } else if (i < 8) {
190        _modules[i + 1][8] = mod;
191      } else {
192        _modules[_moduleCount - 15 + i][8] = mod;
193      }
194    }
195
196    // horizontal
197    for (let i = 0; i < 15; i += 1) {
198
199      const mod = (!test && ( (bits >> i) & 1) == 1);
200
201      if (i < 8) {
202        _modules[8][_moduleCount - i - 1] = mod;
203      } else if (i < 9) {
204        _modules[8][15 - i - 1 + 1] = mod;
205      } else {
206        _modules[8][15 - i - 1] = mod;
207      }
208    }
209
210    // fixed module
211    _modules[_moduleCount - 8][8] = (!test);
212  };
213
214  const mapData = function(data, maskPattern) {
215
216    let inc = -1;
217    let row = _moduleCount - 1;
218    let bitIndex = 7;
219    let byteIndex = 0;
220    const maskFunc = QRUtil.getMaskFunction(maskPattern);
221
222    for (let col = _moduleCount - 1; col > 0; col -= 2) {
223
224      if (col == 6) col -= 1;
225
226      while (true) {
227
228        for (let c = 0; c < 2; c += 1) {
229
230          if (_modules[row][col - c] == null) {
231
232            let dark = false;
233
234            if (byteIndex < data.length) {
235              dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1);
236            }
237
238            const mask = maskFunc(row, col - c);
239
240            if (mask) {
241              dark = !dark;
242            }
243
244            _modules[row][col - c] = dark;
245            bitIndex -= 1;
246
247            if (bitIndex == -1) {
248              byteIndex += 1;
249              bitIndex = 7;
250            }
251          }
252        }
253
254        row += inc;
255
256        if (row < 0 || _moduleCount <= row) {
257          row -= inc;
258          inc = -inc;
259          break;
260        }
261      }
262    }
263  };
264
265  const createBytes = function(buffer, rsBlocks) {
266
267    let offset = 0;
268
269    let maxDcCount = 0;
270    let maxEcCount = 0;
271
272    const dcdata = new Array(rsBlocks.length);
273    const ecdata = new Array(rsBlocks.length);
274
275    for (let r = 0; r < rsBlocks.length; r += 1) {
276
277      const dcCount = rsBlocks[r].dataCount;
278      const ecCount = rsBlocks[r].totalCount - dcCount;
279
280      maxDcCount = Math.max(maxDcCount, dcCount);
281      maxEcCount = Math.max(maxEcCount, ecCount);
282
283      dcdata[r] = new Array(dcCount);
284
285      for (let i = 0; i < dcdata[r].length; i += 1) {
286        dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset];
287      }
288      offset += dcCount;
289
290      const rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount);
291      const rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1);
292
293      const modPoly = rawPoly.mod(rsPoly);
294      ecdata[r] = new Array(rsPoly.getLength() - 1);
295      for (let i = 0; i < ecdata[r].length; i += 1) {
296        const modIndex = i + modPoly.getLength() - ecdata[r].length;
297        ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0;
298      }
299    }
300
301    let totalCodeCount = 0;
302    for (let i = 0; i < rsBlocks.length; i += 1) {
303      totalCodeCount += rsBlocks[i].totalCount;
304    }
305
306    const data = new Array(totalCodeCount);
307    let index = 0;
308
309    for (let i = 0; i < maxDcCount; i += 1) {
310      for (let r = 0; r < rsBlocks.length; r += 1) {
311        if (i < dcdata[r].length) {
312          data[index] = dcdata[r][i];
313          index += 1;
314        }
315      }
316    }
317
318    for (let i = 0; i < maxEcCount; i += 1) {
319      for (let r = 0; r < rsBlocks.length; r += 1) {
320        if (i < ecdata[r].length) {
321          data[index] = ecdata[r][i];
322          index += 1;
323        }
324      }
325    }
326
327    return data;
328  };
329
330  const createData = function(typeNumber, errorCorrectionLevel, dataList) {
331
332    const rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel);
333
334    const buffer = qrBitBuffer();
335
336    for (let i = 0; i < dataList.length; i += 1) {
337      const data = dataList[i];
338      buffer.put(data.getMode(), 4);
339      buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) );
340      data.write(buffer);
341    }
342
343    // calc num max data.
344    let totalDataCount = 0;
345    for (let i = 0; i < rsBlocks.length; i += 1) {
346      totalDataCount += rsBlocks[i].dataCount;
347    }
348
349    if (buffer.getLengthInBits() > totalDataCount * 8) {
350      throw 'code length overflow. ('
351        + buffer.getLengthInBits()
352        + '>'
353        + totalDataCount * 8
354        + ')';
355    }
356
357    // end code
358    if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) {
359      buffer.put(0, 4);
360    }
361
362    // padding
363    while (buffer.getLengthInBits() % 8 != 0) {
364      buffer.putBit(false);
365    }
366
367    // padding
368    while (true) {
369
370      if (buffer.getLengthInBits() >= totalDataCount * 8) {
371        break;
372      }
373      buffer.put(PAD0, 8);
374
375      if (buffer.getLengthInBits() >= totalDataCount * 8) {
376        break;
377      }
378      buffer.put(PAD1, 8);
379    }
380
381    return createBytes(buffer, rsBlocks);
382  };
383
384  _this.addData = function(data, mode) {
385
386    mode = mode || 'Byte';
387
388    let newData = null;
389
390    switch(mode) {
391    case 'Numeric' :
392      newData = qrNumber(data);
393      break;
394    case 'Alphanumeric' :
395      newData = qrAlphaNum(data);
396      break;
397    case 'Byte' :
398      newData = qr8BitByte(data);
399      break;
400    case 'Kanji' :
401      newData = qrKanji(data);
402      break;
403    default :
404      throw 'mode:' + mode;
405    }
406
407    _dataList.push(newData);
408    _dataCache = null;
409  };
410
411  _this.isDark = function(row, col) {
412    if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) {
413      throw row + ',' + col;
414    }
415    return _modules[row][col];
416  };
417
418  _this.getModuleCount = function() {
419    return _moduleCount;
420  };
421
422  _this.make = function() {
423    if (_typeNumber < 1) {
424      let typeNumber = 1;
425
426      for (; typeNumber < 40; typeNumber++) {
427        const rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel);
428        const buffer = qrBitBuffer();
429
430        for (let i = 0; i < _dataList.length; i++) {
431          const data = _dataList[i];
432          buffer.put(data.getMode(), 4);
433          buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) );
434          data.write(buffer);
435        }
436
437        let totalDataCount = 0;
438        for (let i = 0; i < rsBlocks.length; i++) {
439          totalDataCount += rsBlocks[i].dataCount;
440        }
441
442        if (buffer.getLengthInBits() <= totalDataCount * 8) {
443          break;
444        }
445      }
446
447      _typeNumber = typeNumber;
448    }
449
450    makeImpl(false, getBestMaskPattern() );
451  };
452
453  _this.createTableTag = function(cellSize, margin) {
454
455    cellSize = cellSize || 2;
456    margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
457
458    let qrHtml = '';
459
460    qrHtml += '<table style="';
461    qrHtml += ' border-width: 0px; border-style: none;';
462    qrHtml += ' border-collapse: collapse;';
463    qrHtml += ' padding: 0px; margin: ' + margin + 'px;';
464    qrHtml += '">';
465    qrHtml += '<tbody>';
466
467    for (let r = 0; r < _this.getModuleCount(); r += 1) {
468
469      qrHtml += '<tr>';
470
471      for (let c = 0; c < _this.getModuleCount(); c += 1) {
472        qrHtml += '<td style="';
473        qrHtml += ' border-width: 0px; border-style: none;';
474        qrHtml += ' border-collapse: collapse;';
475        qrHtml += ' padding: 0px; margin: 0px;';
476        qrHtml += ' width: ' + cellSize + 'px;';
477        qrHtml += ' height: ' + cellSize + 'px;';
478        qrHtml += ' background-color: ';
479        qrHtml += _this.isDark(r, c)? '#000000' : '#ffffff';
480        qrHtml += ';';
481        qrHtml += '"/>';
482      }
483
484      qrHtml += '</tr>';
485    }
486
487    qrHtml += '</tbody>';
488    qrHtml += '</table>';
489
490    return qrHtml;
491  };
492
493  _this.createSvgTag = function(cellSize, margin, alt, title) {
494
495    let opts = {};
496    if (typeof arguments[0] == 'object') {
497      // Called by options.
498      opts = arguments[0];
499      // overwrite cellSize and margin.
500      cellSize = opts.cellSize;
501      margin = opts.margin;
502      alt = opts.alt;
503      title = opts.title;
504    }
505
506    cellSize = cellSize || 2;
507    margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
508
509    // Compose alt property surrogate
510    alt = (typeof alt === 'string') ? {text: alt} : alt || {};
511    alt.text = alt.text || null;
512    alt.id = (alt.text) ? alt.id || 'qrcode-description' : null;
513
514    // Compose title property surrogate
515    title = (typeof title === 'string') ? {text: title} : title || {};
516    title.text = title.text || null;
517    title.id = (title.text) ? title.id || 'qrcode-title' : null;
518
519    const size = _this.getModuleCount() * cellSize + margin * 2;
520    let c, mc, r, mr, qrSvg='', rect;
521
522    rect = 'l' + cellSize + ',0 0,' + cellSize +
523      ' -' + cellSize + ',0 0,-' + cellSize + 'z ';
524
525    qrSvg += '<svg version="1.1" xmlns="http://www.w3.org/2000/svg"';
526    qrSvg += !opts.scalable ? ' width="' + size + 'px" height="' + size + 'px"' : '';
527    qrSvg += ' viewBox="0 0 ' + size + ' ' + size + '" ';
528    qrSvg += ' preserveAspectRatio="xMinYMin meet"';
529    qrSvg += (title.text || alt.text) ? ' role="img" aria-labelledby="' +
530        escapeXml([title.id, alt.id].join(' ').trim() ) + '"' : '';
531    qrSvg += '>';
532    qrSvg += (title.text) ? '<title id="' + escapeXml(title.id) + '">' +
533        escapeXml(title.text) + '</title>' : '';
534    qrSvg += (alt.text) ? '<description id="' + escapeXml(alt.id) + '">' +
535        escapeXml(alt.text) + '</description>' : '';
536    qrSvg += '<rect width="100%" height="100%" fill="white" cx="0" cy="0"/>';
537    qrSvg += '<path d="';
538
539    for (r = 0; r < _this.getModuleCount(); r += 1) {
540      mr = r * cellSize + margin;
541      for (c = 0; c < _this.getModuleCount(); c += 1) {
542        if (_this.isDark(r, c) ) {
543          mc = c*cellSize+margin;
544          qrSvg += 'M' + mc + ',' + mr + rect;
545        }
546      }
547    }
548
549    qrSvg += '" stroke="transparent" fill="black"/>';
550    qrSvg += '</svg>';
551
552    return qrSvg;
553  };
554
555  _this.createDataURL = function(cellSize, margin) {
556
557    cellSize = cellSize || 2;
558    margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
559
560    const size = _this.getModuleCount() * cellSize + margin * 2;
561    const min = margin;
562    const max = size - margin;
563
564    return createDataURL(size, size, function(x, y) {
565      if (min <= x && x < max && min <= y && y < max) {
566        const c = Math.floor( (x - min) / cellSize);
567        const r = Math.floor( (y - min) / cellSize);
568        return _this.isDark(r, c)? 0 : 1;
569      } else {
570        return 1;
571      }
572    } );
573  };
574
575  _this.createImgTag = function(cellSize, margin, alt) {
576
577    cellSize = cellSize || 2;
578    margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
579
580    const size = _this.getModuleCount() * cellSize + margin * 2;
581
582    let img = '';
583    img += '<img';
584    img += '\u0020src="';
585    img += _this.createDataURL(cellSize, margin);
586    img += '"';
587    img += '\u0020width="';
588    img += size;
589    img += '"';
590    img += '\u0020height="';
591    img += size;
592    img += '"';
593    if (alt) {
594      img += '\u0020alt="';
595      img += escapeXml(alt);
596      img += '"';
597    }
598    img += '/>';
599
600    return img;
601  };
602
603  const escapeXml = function(s) {
604    let escaped = '';
605    for (let i = 0; i < s.length; i += 1) {
606      const c = s.charAt(i);
607      switch(c) {
608      case '<': escaped += '&lt;'; break;
609      case '>': escaped += '&gt;'; break;
610      case '&': escaped += '&amp;'; break;
611      case '"': escaped += '&quot;'; break;
612      default : escaped += c; break;
613      }
614    }
615    return escaped;
616  };
617
618  const _createHalfASCII = function(margin) {
619    const cellSize = 1;
620    margin = (typeof margin == 'undefined')? cellSize * 2 : margin;
621
622    const size = _this.getModuleCount() * cellSize + margin * 2;
623    const min = margin;
624    const max = size - margin;
625
626    let y, x, r1, r2, p;
627
628    const blocks = {
629      '██': '█',
630      '█ ': '▀',
631      ' █': '▄',
632      '  ': ' '
633    };
634
635    const blocksLastLineNoMargin = {
636      '██': '▀',
637      '█ ': '▀',
638      ' █': ' ',
639      '  ': ' '
640    };
641
642    let ascii = '';
643    for (y = 0; y < size; y += 2) {
644      r1 = Math.floor((y - min) / cellSize);
645      r2 = Math.floor((y + 1 - min) / cellSize);
646      for (x = 0; x < size; x += 1) {
647        p = '█';
648
649        if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) {
650          p = ' ';
651        }
652
653        if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) {
654          p += ' ';
655        }
656        else {
657          p += '█';
658        }
659
660        // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square.
661        ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p];
662      }
663
664      ascii += '\n';
665    }
666
667    if (size % 2 && margin > 0) {
668      return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀');
669    }
670
671    return ascii.substring(0, ascii.length-1);
672  };
673
674  _this.createASCII = function(cellSize, margin) {
675    cellSize = cellSize || 1;
676
677    if (cellSize < 2) {
678      return _createHalfASCII(margin);
679    }
680
681    cellSize -= 1;
682    margin = (typeof margin == 'undefined')? cellSize * 2 : margin;
683
684    const size = _this.getModuleCount() * cellSize + margin * 2;
685    const min = margin;
686    const max = size - margin;
687
688    let y, x, r, p;
689
690    const white = Array(cellSize+1).join('██');
691    const black = Array(cellSize+1).join('  ');
692
693    let ascii = '';
694    let line = '';
695    for (y = 0; y < size; y += 1) {
696      r = Math.floor( (y - min) / cellSize);
697      line = '';
698      for (x = 0; x < size; x += 1) {
699        p = 1;
700
701        if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) {
702          p = 0;
703        }
704
705        // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square.
706        line += p ? white : black;
707      }
708
709      for (r = 0; r < cellSize; r += 1) {
710        ascii += line + '\n';
711      }
712    }
713
714    return ascii.substring(0, ascii.length-1);
715  };
716
717  _this.renderTo2dContext = function(context, cellSize) {
718    cellSize = cellSize || 2;
719    const length = _this.getModuleCount();
720    for (let row = 0; row < length; row++) {
721      for (let col = 0; col < length; col++) {
722        context.fillStyle = _this.isDark(row, col) ? 'black' : 'white';
723        context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize);
724      }
725    }
726  }
727
728  return _this;
729};
730
731//---------------------------------------------------------------------
732// qrcode.stringToBytes
733//---------------------------------------------------------------------
734
735qrcode.stringToBytes = function(s) {
736  const bytes = [];
737  for (let i = 0; i < s.length; i += 1) {
738    const c = s.charCodeAt(i);
739    bytes.push(c & 0xff);
740  }
741  return bytes;
742};
743
744//---------------------------------------------------------------------
745// qrcode.createStringToBytes
746//---------------------------------------------------------------------
747
748/**
749 * @param unicodeData base64 string of byte array.
750 * [16bit Unicode],[16bit Bytes], ...
751 * @param numChars
752 */
753qrcode.createStringToBytes = function(unicodeData, numChars) {
754
755  // create conversion map.
756
757  const unicodeMap = function() {
758
759    const bin = base64DecodeInputStream(unicodeData);
760    const read = function() {
761      const b = bin.read();
762      if (b == -1) throw 'eof';
763      return b;
764    };
765
766    let count = 0;
767    const unicodeMap = {};
768    while (true) {
769      const b0 = bin.read();
770      if (b0 == -1) break;
771      const b1 = read();
772      const b2 = read();
773      const b3 = read();
774      const k = String.fromCharCode( (b0 << 8) | b1);
775      const v = (b2 << 8) | b3;
776      unicodeMap[k] = v;
777      count += 1;
778    }
779    if (count != numChars) {
780      throw count + ' != ' + numChars;
781    }
782
783    return unicodeMap;
784  }();
785
786  const unknownChar = '?'.charCodeAt(0);
787
788  return function(s) {
789    const bytes = [];
790    for (let i = 0; i < s.length; i += 1) {
791      const c = s.charCodeAt(i);
792      if (c < 128) {
793        bytes.push(c);
794      } else {
795        const b = unicodeMap[s.charAt(i)];
796        if (typeof b == 'number') {
797          if ( (b & 0xff) == b) {
798            // 1byte
799            bytes.push(b);
800          } else {
801            // 2bytes
802            bytes.push(b >>> 8);
803            bytes.push(b & 0xff);
804          }
805        } else {
806          bytes.push(unknownChar);
807        }
808      }
809    }
810    return bytes;
811  };
812};
813
814//---------------------------------------------------------------------
815// QRMode
816//---------------------------------------------------------------------
817
818const QRMode = {
819  MODE_NUMBER :    1 << 0,
820  MODE_ALPHA_NUM : 1 << 1,
821  MODE_8BIT_BYTE : 1 << 2,
822  MODE_KANJI :     1 << 3
823};
824
825//---------------------------------------------------------------------
826// QRErrorCorrectionLevel
827//---------------------------------------------------------------------
828
829const QRErrorCorrectionLevel = {
830  L : 1,
831  M : 0,
832  Q : 3,
833  H : 2
834};
835
836//---------------------------------------------------------------------
837// QRMaskPattern
838//---------------------------------------------------------------------
839
840const QRMaskPattern = {
841  PATTERN000 : 0,
842  PATTERN001 : 1,
843  PATTERN010 : 2,
844  PATTERN011 : 3,
845  PATTERN100 : 4,
846  PATTERN101 : 5,
847  PATTERN110 : 6,
848  PATTERN111 : 7
849};
850
851//---------------------------------------------------------------------
852// QRUtil
853//---------------------------------------------------------------------
854
855const QRUtil = function() {
856
857  const PATTERN_POSITION_TABLE = [
858    [],
859    [6, 18],
860    [6, 22],
861    [6, 26],
862    [6, 30],
863    [6, 34],
864    [6, 22, 38],
865    [6, 24, 42],
866    [6, 26, 46],
867    [6, 28, 50],
868    [6, 30, 54],
869    [6, 32, 58],
870    [6, 34, 62],
871    [6, 26, 46, 66],
872    [6, 26, 48, 70],
873    [6, 26, 50, 74],
874    [6, 30, 54, 78],
875    [6, 30, 56, 82],
876    [6, 30, 58, 86],
877    [6, 34, 62, 90],
878    [6, 28, 50, 72, 94],
879    [6, 26, 50, 74, 98],
880    [6, 30, 54, 78, 102],
881    [6, 28, 54, 80, 106],
882    [6, 32, 58, 84, 110],
883    [6, 30, 58, 86, 114],
884    [6, 34, 62, 90, 118],
885    [6, 26, 50, 74, 98, 122],
886    [6, 30, 54, 78, 102, 126],
887    [6, 26, 52, 78, 104, 130],
888    [6, 30, 56, 82, 108, 134],
889    [6, 34, 60, 86, 112, 138],
890    [6, 30, 58, 86, 114, 142],
891    [6, 34, 62, 90, 118, 146],
892    [6, 30, 54, 78, 102, 126, 150],
893    [6, 24, 50, 76, 102, 128, 154],
894    [6, 28, 54, 80, 106, 132, 158],
895    [6, 32, 58, 84, 110, 136, 162],
896    [6, 26, 54, 82, 110, 138, 166],
897    [6, 30, 58, 86, 114, 142, 170]
898  ];
899  const G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0);
900  const G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0);
901  const G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1);
902
903  const _this = {};
904
905  const getBCHDigit = function(data) {
906    let digit = 0;
907    while (data != 0) {
908      digit += 1;
909      data >>>= 1;
910    }
911    return digit;
912  };
913
914  _this.getBCHTypeInfo = function(data) {
915    let d = data << 10;
916    while (getBCHDigit(d) - getBCHDigit(G15) >= 0) {
917      d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) );
918    }
919    return ( (data << 10) | d) ^ G15_MASK;
920  };
921
922  _this.getBCHTypeNumber = function(data) {
923    let d = data << 12;
924    while (getBCHDigit(d) - getBCHDigit(G18) >= 0) {
925      d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) );
926    }
927    return (data << 12) | d;
928  };
929
930  _this.getPatternPosition = function(typeNumber) {
931    return PATTERN_POSITION_TABLE[typeNumber - 1];
932  };
933
934  _this.getMaskFunction = function(maskPattern) {
935
936    switch (maskPattern) {
937
938    case QRMaskPattern.PATTERN000 :
939      return function(i, j) { return (i + j) % 2 == 0; };
940    case QRMaskPattern.PATTERN001 :
941      return function(i, j) { return i % 2 == 0; };
942    case QRMaskPattern.PATTERN010 :
943      return function(i, j) { return j % 3 == 0; };
944    case QRMaskPattern.PATTERN011 :
945      return function(i, j) { return (i + j) % 3 == 0; };
946    case QRMaskPattern.PATTERN100 :
947      return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; };
948    case QRMaskPattern.PATTERN101 :
949      return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; };
950    case QRMaskPattern.PATTERN110 :
951      return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; };
952    case QRMaskPattern.PATTERN111 :
953      return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; };
954
955    default :
956      throw 'bad maskPattern:' + maskPattern;
957    }
958  };
959
960  _this.getErrorCorrectPolynomial = function(errorCorrectLength) {
961    let a = qrPolynomial([1], 0);
962    for (let i = 0; i < errorCorrectLength; i += 1) {
963      a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) );
964    }
965    return a;
966  };
967
968  _this.getLengthInBits = function(mode, type) {
969
970    if (1 <= type && type < 10) {
971
972      // 1 - 9
973
974      switch(mode) {
975      case QRMode.MODE_NUMBER    : return 10;
976      case QRMode.MODE_ALPHA_NUM : return 9;
977      case QRMode.MODE_8BIT_BYTE : return 8;
978      case QRMode.MODE_KANJI     : return 8;
979      default :
980        throw 'mode:' + mode;
981      }
982
983    } else if (type < 27) {
984
985      // 10 - 26
986
987      switch(mode) {
988      case QRMode.MODE_NUMBER    : return 12;
989      case QRMode.MODE_ALPHA_NUM : return 11;
990      case QRMode.MODE_8BIT_BYTE : return 16;
991      case QRMode.MODE_KANJI     : return 10;
992      default :
993        throw 'mode:' + mode;
994      }
995
996    } else if (type < 41) {
997
998      // 27 - 40
999
1000      switch(mode) {
1001      case QRMode.MODE_NUMBER    : return 14;
1002      case QRMode.MODE_ALPHA_NUM : return 13;
1003      case QRMode.MODE_8BIT_BYTE : return 16;
1004      case QRMode.MODE_KANJI     : return 12;
1005      default :
1006        throw 'mode:' + mode;
1007      }
1008
1009    } else {
1010      throw 'type:' + type;
1011    }
1012  };
1013
1014  _this.getLostPoint = function(qrcode) {
1015
1016    const moduleCount = qrcode.getModuleCount();
1017
1018    let lostPoint = 0;
1019
1020    // LEVEL1
1021
1022    for (let row = 0; row < moduleCount; row += 1) {
1023      for (let col = 0; col < moduleCount; col += 1) {
1024
1025        let sameCount = 0;
1026        const dark = qrcode.isDark(row, col);
1027
1028        for (let r = -1; r <= 1; r += 1) {
1029
1030          if (row + r < 0 || moduleCount <= row + r) {
1031            continue;
1032          }
1033
1034          for (let c = -1; c <= 1; c += 1) {
1035
1036            if (col + c < 0 || moduleCount <= col + c) {
1037              continue;
1038            }
1039
1040            if (r == 0 && c == 0) {
1041              continue;
1042            }
1043
1044            if (dark == qrcode.isDark(row + r, col + c) ) {
1045              sameCount += 1;
1046            }
1047          }
1048        }
1049
1050        if (sameCount > 5) {
1051          lostPoint += (3 + sameCount - 5);
1052        }
1053      }
1054    };
1055
1056    // LEVEL2
1057
1058    for (let row = 0; row < moduleCount - 1; row += 1) {
1059      for (let col = 0; col < moduleCount - 1; col += 1) {
1060        let count = 0;
1061        if (qrcode.isDark(row, col) ) count += 1;
1062        if (qrcode.isDark(row + 1, col) ) count += 1;
1063        if (qrcode.isDark(row, col + 1) ) count += 1;
1064        if (qrcode.isDark(row + 1, col + 1) ) count += 1;
1065        if (count == 0 || count == 4) {
1066          lostPoint += 3;
1067        }
1068      }
1069    }
1070
1071    // LEVEL3
1072
1073    for (let row = 0; row < moduleCount; row += 1) {
1074      for (let col = 0; col < moduleCount - 6; col += 1) {
1075        if (qrcode.isDark(row, col)
1076            && !qrcode.isDark(row, col + 1)
1077            &&  qrcode.isDark(row, col + 2)
1078            &&  qrcode.isDark(row, col + 3)
1079            &&  qrcode.isDark(row, col + 4)
1080            && !qrcode.isDark(row, col + 5)
1081            &&  qrcode.isDark(row, col + 6) ) {
1082          lostPoint += 40;
1083        }
1084      }
1085    }
1086
1087    for (let col = 0; col < moduleCount; col += 1) {
1088      for (let row = 0; row < moduleCount - 6; row += 1) {
1089        if (qrcode.isDark(row, col)
1090            && !qrcode.isDark(row + 1, col)
1091            &&  qrcode.isDark(row + 2, col)
1092            &&  qrcode.isDark(row + 3, col)
1093            &&  qrcode.isDark(row + 4, col)
1094            && !qrcode.isDark(row + 5, col)
1095            &&  qrcode.isDark(row + 6, col) ) {
1096          lostPoint += 40;
1097        }
1098      }
1099    }
1100
1101    // LEVEL4
1102
1103    let darkCount = 0;
1104
1105    for (let col = 0; col < moduleCount; col += 1) {
1106      for (let row = 0; row < moduleCount; row += 1) {
1107        if (qrcode.isDark(row, col) ) {
1108          darkCount += 1;
1109        }
1110      }
1111    }
1112
1113    const ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5;
1114    lostPoint += ratio * 10;
1115
1116    return lostPoint;
1117  };
1118
1119  return _this;
1120}();
1121
1122//---------------------------------------------------------------------
1123// QRMath
1124//---------------------------------------------------------------------
1125
1126const QRMath = function() {
1127
1128  const EXP_TABLE = new Array(256);
1129  const LOG_TABLE = new Array(256);
1130
1131  // initialize tables
1132  for (let i = 0; i < 8; i += 1) {
1133    EXP_TABLE[i] = 1 << i;
1134  }
1135  for (let i = 8; i < 256; i += 1) {
1136    EXP_TABLE[i] = EXP_TABLE[i - 4]
1137      ^ EXP_TABLE[i - 5]
1138      ^ EXP_TABLE[i - 6]
1139      ^ EXP_TABLE[i - 8];
1140  }
1141  for (let i = 0; i < 255; i += 1) {
1142    LOG_TABLE[EXP_TABLE[i] ] = i;
1143  }
1144
1145  const _this = {};
1146
1147  _this.glog = function(n) {
1148
1149    if (n < 1) {
1150      throw 'glog(' + n + ')';
1151    }
1152
1153    return LOG_TABLE[n];
1154  };
1155
1156  _this.gexp = function(n) {
1157
1158    while (n < 0) {
1159      n += 255;
1160    }
1161
1162    while (n >= 256) {
1163      n -= 255;
1164    }
1165
1166    return EXP_TABLE[n];
1167  };
1168
1169  return _this;
1170}();
1171
1172//---------------------------------------------------------------------
1173// qrPolynomial
1174//---------------------------------------------------------------------
1175
1176const qrPolynomial = function(num, shift) {
1177
1178  if (typeof num.length == 'undefined') {
1179    throw num.length + '/' + shift;
1180  }
1181
1182  const _num = function() {
1183    let offset = 0;
1184    while (offset < num.length && num[offset] == 0) {
1185      offset += 1;
1186    }
1187    const _num = new Array(num.length - offset + shift);
1188    for (let i = 0; i < num.length - offset; i += 1) {
1189      _num[i] = num[i + offset];
1190    }
1191    return _num;
1192  }();
1193
1194  const _this = {};
1195
1196  _this.getAt = function(index) {
1197    return _num[index];
1198  };
1199
1200  _this.getLength = function() {
types/index.d.ts 29 lines
1// One listening port; hooks/ports.ts holds the same shape.
2export type PortRow = {
3  port: number
4  pid: number
5  process: string
6  owner: string
7  kind: 'dev' | 'app' | 'system'
8  bind: 'local' | 'all' | 'tailnet' | 'other'
9  address: string
10  url: string
11  phoneUrl: string
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'port-watch': {
17      rows: PortRow[]
18      scannedAt: number
19      scanning: boolean
20      filter: 'dev' | 'phone' | 'all'
21      query: string
22      qr: number // the port whose QR code is open, 0 for none
23      page: number
24      tailnetIp: string
25      error: string
26    }
27  }
28}
29