Writing and porting extensions for Seltzer
Seltzer (formerly WinClip) loads extensions written in the PopClip extension format and runs them on Windows. This guide is for extension authors: what is supported, what behaves differently from macOS, what Seltzer adds, and how to test. It describes Seltzer 0.1.0, which aims at compatibility with PopClip 2026.8.1 (build 6221); PopClip's own developer documentation remains the reference for the format itself.
Every statement here was checked against Seltzer's source code and tests.
Contents
- Quick checklist for porting
- Packages, snippets and config files
- Supported keys
- Action types
- Requirements, patterns and app filters
- Before and after steps, results
- Options and secrets
- The JavaScript environment
- The popclip, util and pasteboard APIs on Windows
- Network access
- Shell scripts, PowerShell and Command Prompt
- Key combos: Mac to Windows
- Icons
- Windows-specific variables and context
- Trust, verification and identifiers
- Testing with Seltzer.exe run
- Developing with a live folder
- Limits
Quick checklist for porting
Most extensions need no change. Check these points:
- macOS-only actions.
serviceName,shortcutNameandapplescript*actions never run on Windows: they install but are hidden from the bar (and marked "macOS only" in Settings). Offer a Windows alternative with a URL scheme, a key combo, JavaScript or a PowerShell action — in a separate action, because the first action type in the precedence list wins (see Action types). - Shell scripts.
sh/bash/zshscripts need Git for Windows or WSL and the user's opt-in setting; Mac programs (pbcopy,open,osascript,say) do not exist. Prefer JavaScript, or add apowershellaction. - Key combos. ⌘ and ⌃ both become Ctrl, ⌥ becomes Alt. Mac-specific shortcuts (⌘⇧Z redo, ⌃A line start)
may mean something else on Windows; give a
winKeyCombofor Windows. - Icons.
symbol:(SF Symbols) are replaced by similar Fluent/Lucide icons from a 92-name table; other names show the title. PopClip's ownbundle:icons do not exist in Seltzer (it has its own 15). Prefer a package SVG, a text icon oriconify:. - App filters.
requiredApps/excludedAppstake macOS bundle IDs, which Seltzer maps to Windows programs only for the apps in its alias table. AddrequiredProcesses/excludedProcesseswith Windows executable names. - Plain HTTP is refused except for local hosts (see Network access).
util.seltzerexists only in Seltzer: test for it (if (util.seltzer) …) so the extension still runs in PopClip. PopClip ignores all Seltzer-only config keys.
Packages, snippets and config files
| Form | Supported | Notes |
|---|---|---|
*.popclipext folder |
Yes | Installed by copying (Explorer verb "Install in Seltzer", drop on Settings, Install from Folder…) or loaded in place as a developer folder. |
*.popclipextz zip |
Yes | The package folder at the zip root (Name.popclipext/…, also @shortcode.identifier.popclipext/…). Zip entries are validated (no paths outside the package, no Windows device names or alternate data streams; names Windows cannot store are skipped with a warning). Unix executable bits are recorded. |
| Snippets | Yes | Selected text (≤ 5000 characters; bar item Install "name"), .popcliptxt, .js, .ts, .yaml files, dropped or clipboard text. Parsing follows PopClip's rules exactly: marker # popclip (one optional space, case-insensitive, first occurrence anywhere), comment prefix, header ends at the first line without the prefix or a blank line, whole rest is YAML for an empty prefix, name test, "Don't use tabs in YAML", classification by file suffix > language > interpreter > #! > prefix (// → TypeScript, -- → AppleScript). |
LZFSE modules (*.js.lzfse) |
Yes | Decoded once (output capped at 16 MB) and cached; require("mathjs") finds mathjs.js.lzfse. |
_Signature.plist |
Ignored | Seltzer cannot verify PopClip signatures (see Trust). |
Config files. Every root file named Config or Config.* is read, in the order Config.plist → Config.json →
Config.yaml → others (by name; those are snippet-style files such as Config.js/Config.ts, whose suffix fixes the
language) and the top-level keys are merged, later files winning. The 4-key Config.plist stub that the PopClip
directory adds to downloads is therefore harmless.
- YAML is read with the semantics of js-yaml's
JSON_SCHEMA, like PopClip:yes,no,on,offare strings (sostay visible: yesis an error — writetrue), timestamps stay strings, duplicate keys are errors, no<<merges; explicit!!str/!!int/!!float/!!bool/!!nulltags are honoured, other tags are errors. - JSON is strict (no comments, no trailing commas).
- plist: XML and binary (
bplist00); the DTD is ignored;<false/>foriconmeans "no icon". - Key names are standardised as in PopClip:
keyCombo,key combo,Key Combo,key_comboandKEY-COMBOare the same key; a leadingextensionoroptionword is dropped; legacy aliases (image file,regular expression,required software version,blocked apps,pass html,allow custom, …) are mapped; keys starting with_are left alone. Unknown keys are kept and ignored; they never fail a load. - A number where a string is expected (
title: 404) is accepted with a warning; booleans are strict. - Load problems are reported in PopClip's form
path: message (value: …): in the bar for snippets, in the install dialog, in Extension Info and in the Debug Console.
Supported keys
Extension level: name (required; string or localisation table with en), identifier, description, keywords,
icon, options, options title, entitlements (network, dynamic, script), action, actions, submenu,
show as, auth service label, auth keychain, offers multiple instances, shell script rationale (shown in the trust
prompt), module, language, app/apps, popclip version, macos version (read and ignored), plus every action key
as a default for the actions.
Action level: title, identifier, icon (and the icon property keys preserve color, preserve aspect,
flip x/flip y, move x/move y, scale, rotate, square, circle, search, strike, filled, monospaced,
legacy icon options and mask: false), requirements, regex, required apps, excluded apps, before, after,
stay visible, capture html, capture rtf, restore pasteboard, wants primary display, wants initial display,
separator (in submenus), code (modules), app/apps, and the type keys below.
Ignored for compatibility: note, credits, version, long name, position, additional actions,
options script file, options script interpreter, flags, stoppable, long running, comment, color.
Seltzer-only keys (PopClip ignores them):
| Key | Level | Meaning |
|---|---|---|
powershell / powershell file |
action | PowerShell action (details) |
cmd / batch file |
action | Command Prompt action |
native |
action | Run a Seltzer native action: speech.say, print.text, qr.show, network.resolve, terminal.run, dictionary.show, outlook.compose (the selection as a new Outlook (Classic) email, displayed and never sent; a Subject: line at the top becomes the subject; option keepSignature, default on) |
win key combo / win key combos |
action | Key combo(s) to press on Windows instead of keyCombo(s) (details) |
required processes / excluded processes |
action | App filters with Windows executable names, AUMIDs or class: window classes |
trim trailing newline |
action | Remove trailing line breaks from a script's output before the after-step |
windows version |
extension | Minimum Windows version, e.g. "10.0.19041" (quote it in YAML); a newer value than the running Windows gives a warning |
auth redirect port |
extension | Fixed loopback port for auth()'s redirect URL, for services that need an exactly registered redirect URI |
In JSON and JavaScript write them in camelCase: powershellFile, batchFile, winKeyCombo, requiredProcesses,
trimTrailingNewline, windowsVersion, authRedirectPort.
Action types
The type of a static action is decided by the first group present, in this fixed order (a warning is logged when several are set):
url → keyCombo(s)/winKeyCombo(s) → serviceName → shortcutName → applescript → shellScript → javascript* → powershell* → cmd/batchFile → native**
Because the Seltzer-only types come last, an action with both shellScript and powershell is a shell-script action.
Put a Windows variant in its own action (a macOS-only type is hidden automatically), or choose at run time in JavaScript
(see the example under Shell scripts).
| Type | On Windows |
|---|---|
URL (url, clean query, spaces as plus) |
Full. Template placeholders ***, {popclip text}, {popclip option <id>}. The text is trimmed, cleaned with clean query, quoted when the user holds Alt (verbatim search), percent-encoded (+ for spaces with spaces as plus; option values always %20). Opens in the browser the selection came from when it is a browser, else with the default handler; Shift opens a background tab. Schemes other than http/https must be on Seltzer's allow-list (below). |
Key press (key combo, key combos, key combo target) |
Translated to Windows keys (details). app target: pressed only if the captured app is still in front (no per-process posting on Windows). |
JavaScript (javascript, javascript file), TypeScript, module (module, code) |
Full (environment). Inline javascript bodies run as async () => { … }: top-level await and return work. |
Shell script (shell script, shell script file, interpreter, stdin, shell mode) |
Out of process with interpreter mapping (details). |
Service, Shortcut, AppleScript (service name, shortcut name, applescript, applescript file, applescript call) |
Not possible on Windows. The action is loaded, hidden from the bar and labelled "macOS only". |
| PowerShell, cmd, native | Seltzer-only (see Supported keys). |
| No type | A title-only action (shown disabled) or, with a submenu, a folder. |
URL schemes. http, https, the user's Extra link schemes and a curated list (mailto, tel, sms, obsidian,
logseq, notion, craftdocs, upnote, evernote, todoist, ticktick, spotify, deezer, tidal, zoommtg,
zoomus, msteams, slack, discord, tg, whatsapp, translatium, bingmaps, ms-settings) are opened. claude
(the Claude desktop app) is opened for the extensions that ship with Seltzer (Claude Code, Claude Chat and Rewrite, whose
claude://claude.ai/new?q=rep%3A… link starts a chat with a rep: prompt) and for links you open yourself; another
extension's claude: links need claude in your Extra link schemes, since such a link can start a Claude Code session in any
folder with a prompt the extension wrote, and Seltzer asks no confirmation the way a browser does.
Office URI schemes (ms-word: …), developer-tool schemes (vscode: …), search-ms, file: URLs other than existing local
folders and documents, and any link whose target names a UNC path, a file: URL or a second host are refused — so a
claude://code/new?…&folder= link opens with a folder on a local or mapped drive, but not with folder=\\server\share.
Quotes, spaces and control characters are percent-encoded before a URL reaches its handler. A non-web link that no installed
app opens fails with ERR_LAUNCH_FAILED and starts nothing.
Requirements, patterns and app filters
Requirements (default [text]; [] = always show): text, copy, cut (editable selection), paste (editable
field and pasteable clipboard; works without a selection), formatting (rich-text control), url/httpurl (exactly one
web URL; narrows the text to it), isurl (the text is a URL), urls/httpurls, email (exactly one; narrows),
emails, path (an existing path on a local fixed drive; narrows and normalises — network and removable drives are
never probed), option-<id>=<value> (booleans compare as 1/0), html (accepted, always true), each negatable with
!. Links are detected with linkifyjs 4.3.3 plus the user's extra schemes.
regex strings are evaluated by the ICU engine built into Windows (icu.dll), the same family as macOS's
NSRegularExpression, with flags 0; .NET's Regex is not used because it gives different answers on common patterns.
The first match becomes matchedText (POPCLIP_TEXT) and its groups regexResult. A pattern that does not compile is a
load error; a pattern that runs too long counts as no match. Regexes and link/e-mail/path detection are skipped for
selections longer than 51,200 characters (the action is then hidden). A JavaScript RegExp exported by a module is
evaluated in the extension's engine (50 ms budget). Keep patterns linear: they run on every selection. (?s)^.{1,200}$
counts code points, as in PopClip.
App filters. required apps/excluded apps accept macOS bundle IDs, which are mapped through Seltzer's alias table
(browsers, Microsoft Office, VS Code, Slack, Notepad ↔ TextEdit, Explorer ↔ Finder, terminals ↔ Terminal, …), and also
Windows executable names (*.exe, or a full path), AUMIDs and class:<window class>. required processes/excluded processes accept the same forms; use them for the Windows names so PopClip does not see them. app/apps with check installed: true show "X is required" with a Visit Website button only
when every bundle ID is known and known to be absent; an unmapped ID never blocks the action.
Before and after steps, results
before: cut, copy, paste, paste-plain (reduces the clipboard to plain text permanently, then pastes).
after: the before values plus:
| After-step | Clipboard | Notes |
|---|---|---|
copy-result |
Result copied (stays), "Copied" shown | |
paste-result |
Result pasted; it stays on the clipboard unless restore pasteboard: true or the user turned off Keep pasted results on the clipboard |
Shift-click copies instead; where nothing can be pasted it copies |
show-result |
Nothing copied (unless the user turned on Copy shown results) | One line, at most 160 characters |
preview-result |
As show-result | Clicking the preview pastes the full result; Shift-click copies |
show-status |
— | Tick or X |
copy-selection |
The original selection, after any paste was consumed | |
popclip-appear |
— | Shows the bar again after the action |
Only strings (and ShellScriptResult, via toString()) feed *-result steps; undefined/null close the bar. An
empty string is pasted or copied as empty; show and preview display nothing. Trailing line breaks: JavaScript strings are
used as returned; classic shell/PowerShell/cmd output is used as printed (CRLF becomes LF) unless trim trailing newline;
$ and ShellScriptResult.toString() strip them.
Options and secrets
Types string (multiline), boolean (default true; optional icon), multiple (values, value labels,
allow none, allow other/allow custom), secret, password, heading; common keys label (defaults to the
identifier), description (bare URLs and [label](url) links are clickable), default value, inset, hidden,
keychain, migrate from. Values belong to one instance in the action list (the user can duplicate an extension
unless offers multiple instances: false).
secretvalues and the sign-in secret (authsecret) are stored encrypted for the Windows user (DPAPI) on this machine only;keychain: syncbehaves as local. They are never passed to population functions (reading one there throws) and are withheld from unverified extensions that run code until the user confirmed them.passwordis never stored and is only passed toauth(); in an extension withoutauthit is treated as a legacysecret.- Module extensions may compute
optionsat load time (for example one checkbox per installed browser fromutil.seltzer.installedBrowsers()). - Scripts get options as
POPCLIP_OPTION_<ID>: the identifier upper-cased, every character outsideA–Z 0–9 _replaced by_(my-opt→POPCLIP_OPTION_MY_OPT), booleans1/0.
The JavaScript environment
Engine. JavaScript runs in Jint 4.16.3, a pure-.NET ECMAScript engine with modern syntax (classes, async/await, generators, destructuring, optional chaining, BigInt …). There is no JIT and no access to .NET from scripts.
One persistent engine per extension, as in PopClip: the module's top level runs once when the extension loads, and its state (a chat history, a cache) lives until the extension is reloaded — on install, update, reinstall, a change of its files with the developer watcher, or an app restart. Changing the extension's settings does not reload it (options are passed per call). Engines are created only for extensions in the enabled action list.
Module format. Package files are CommonJS modules, wrapped as
(function (exports, require, module, __filename, __dirname, define, defineExtension) { … }). module.exports = {…},
defineExtension({…}) and AMD define(...) work. ES module syntax (import/export) and TypeScript are transpiled with
sucrase 3.35.1 (types are stripped, not checked; output cached on disk). With export default, the default export is
the extension, and mixing it with named exports is a load error. Action functions receive (input, options, context).
require. ./ and ../ relative to the current file; other names from the package root, then the bundled
libraries; the extensions .js, .ts, .json (parsed) and .js.lzfse are tried; @popclip/<lib> is an alias of
<lib>; a missing module returns undefined (PopClip's behaviour); paths outside the package return undefined; a
package file shadows a bundled library of the same name.
Bundled libraries (PopClip's list at PopClip's versions, loaded on first require):
| Library | Version | Licence |
|---|---|---|
| axios | 1.12.2 | MIT |
| buffer | 6.0.3 | MIT |
| case-anything | 2.1.13 | MIT |
| content-type | 1.0.5 | MIT |
| dom-serializer | 2.0.0 | MIT |
| emoji-regex | 10.6.0 | MIT |
| entities | 7.0.0 | BSD-2-Clause |
| fast-json-stable-stringify | 2.1.0 | MIT |
| fast-plist | 0.1.3 | MIT |
| htmlparser2 | 10.0.0 | MIT |
| js-yaml | 4.1.0 | MIT |
| linkedom | 0.18.12 | ISC |
| linkifyjs | 4.3.3 | MIT |
| oauth-1.0a | 2.2.6 | MIT |
| rot13-cipher | 1.0.0 | MIT |
| sanitize-html | 2.17.0 | MIT |
| sucrase | 3.35.1 | MIT |
| turndown | 7.2.1 | MIT |
| valibot | 1.1.0 | MIT |
turndown accepts HTML strings (it bundles a DOM parser) as well as DOM nodes.
Globals (in addition to ECMAScript's): popclip, util, pasteboard, print, sleep(ms), RichString,
ShellScriptResult results, the $ shell tag; setTimeout/setInterval/clearTimeout/clearInterval;
XMLHttpRequest; URL and URLSearchParams (core-js, WHATWG behaviour); Buffer (with base64url); atob/btoa;
TextEncoder (UTF-8; encode() returns a Buffer); a minimal Blob; structuredClone; window (= globalThis, no
DOM behind it); Intl.
Seltzer-only extras: a console whose methods write to the extension's debug log like print() (PopClip has no
console; use print() in code that must run in both), and extra arguments to setTimeout/setInterval are passed
to the handler.
Not available: fetch (use XMLHttpRequest or axios), TextDecoder (use Buffer's toString()), the DOM
(document, DOMParser; use linkedom or htmlparser2), DOMException (errors carry its name and code), WebAssembly,
Node's fs/child_process (use $/runShellScript with the script entitlement).
Intl and dates. Collation (localeCompare, Intl.Collator, including numeric sorting of very long digit runs),
Intl.DateTimeFormat, Date.prototype.toLocale*String, Intl.Segmenter and locale-aware upper/lower case use Windows'
ICU, with the user's Windows regional format as the default locale and the Windows time zone (changes are picked up
while running). Intl.NumberFormat and any other Intl objects are Jint's own (based on .NET culture data).
util.timeZoneInfo.abbreviation comes from ICU (GMT+1 style where no abbreviation exists).
Phases. As in PopClip:
- Population functions (a module's
actionsfunction) and submenu functions need thedynamicentitlement and get 150 ms; while they run there are no network requests, no timers, nopopclip.*methods, no secret options and no clipboard writes (each throws a clear error), andpopclip.modifiersare all false. If the engine is busy (an action is running), the call is skipped for that bar. Seltzer also calls a module's population function twice with neutral sample texts when the extension loads, to warm up expensive regular expressions; the results are discarded, so keep the function free of side effects. - Actions may use everything their entitlements allow.
$andrunShellScript*needscript; network requests neednetwork. - Calling
popclip.*methods at module top level or from a callback that outlives its action throws "can only be called while an action is running".
Concurrency. One action runs per extension at a time; a second click while one runs fails with "… is still running" (other extensions are not affected). An action ends when its promise has settled and every timer, request and child process it started has finished; clicking the spinner cancels all of them. Timers created outside an action are engine-scoped (at most 64 at once).
Budgets.
| Budget | Value |
|---|---|
| Time a script may run without yielding (per slice of an action, a promise continuation or a sign-in) | 5 s (Settings → Advanced → JavaScript time slice) |
| Loading a module | 60 s or 4 × the slice |
| Population / submenu call | 150 ms; module regex 50 ms |
| Memory | 64 MB of new live objects per engine entry (loads: 4 ×, at least 256 MB) |
| Statements per entry | 200 million |
| Recursion depth | 400 calls |
| Regular expression, per match | 500 ms (Jint gives a non-Unicode new RegExp(…) created at run time 5 s) |
| Overall action duration | none (user-cancellable), as in PopClip |
A script that exceeds a budget fails with "The script took too long to run." (or the memory message); the engine and its state survive.
Known Jint differences. Regular-expression literals and RegExp objects are run by Jint's translation to .NET's
regex engine: rare constructs may behave differently from JavaScriptCore. Seltzer corrects the ones that broke real
extensions: strings iterate by code point (spread, for…of, Array.from), String.prototype.replace with a callback
receives undefined for groups that did not participate (also with the u flag), and expressions nested more than 256
levels deep (such as Babel's exports.a = exports.b = … = void 0 chains) are rewritten so they load. Deeply nested
statements still fail to load.
TypeScript typings. One file, js/types/seltzer.d.ts (@seltzer/types, not yet published; installed next to Seltzer.exe
as extensions\types\seltzer.d.ts, so a copy of that works without the package) declares the Seltzer additions on top of PopClip's own
@popclip/types: util.seltzer, the Seltzer-only config keys, console
and the extra timer arguments. js/types/README.md shows the tsconfig.json to use.
The popclip, util and pasteboard APIs on Windows
Everything in PopClip's JavaScript API is present; this table lists only what differs or needs a Windows explanation.
| API | On Windows |
|---|---|
popclip.input |
text, matchedText, regexResult, data.urls/nonHttpUrls/emails/paths (with ranges), isUrl, and with captureHtml/captureRtf: html (sanitised), xhtml, markdown (turndown, ATX headings), rtf. content holds clipboard content keyed by type (public.utf8-plain-text, public.html, public.rtf) when the text came from the clipboard. HTML comes from the app's clipboard HTML (the CF_HTML "HTML Format"); when the clipboard cannot be used, it is generated from the plain text. |
popclip.context |
hasFormatting, canPaste, canCopy, canCut, appName (from the program's version info), appIdentifier (the mapped macOS bundle ID for known apps, e.g. com.google.Chrome, else the executable name), browserUrl/browserTitle (resolved on first read from the browser's accessibility tree; empty for private windows unless the user allows it). Seltzer adds appExecutable (chrome.exe), appUserModelId and windowClass. |
popclip.modifiers |
command = Ctrl, option = Alt, shift = Shift; control is always false for clicks; the Windows key is not reported. Keys held with Return in keyboard mode count. |
pasteText, pasteContent |
Paste with Ctrl+V (Shift+Insert in terminals that need it; WM_PASTE for classic edit controls); {restore: true} restores the previous clipboard once the app has read the paste. Where nothing can be pasted, or with Shift held, the text is copied instead ("Copied"). Content types: public.utf8-plain-text, public.html, public.rtf. |
copyText, copyContent, performCommand |
As in PopClip; performCommand("copy" \| "cut" \| "paste", {transform: "plain"}). |
showText |
style: "compact" (one line in the bar) or "large" (Large Type overlay); preview: true makes it clickable. |
pressKey, pressKeys |
See Key combos. target: "session" (default), "hid" (scan codes) or "app" (only if the captured app is still in front). Combos with the Windows key are refused for extensions that are not built in, hash-verified or confirmed by the user. |
openUrl(url, {app, activate, backgroundTab}) |
app may be a macOS bundle ID that Seltzer maps to an installed browser (com.google.Chrome), an executable name (chrome.exe), a ProgId (ChromeHTML) or an AUMID; for a non-web scheme also the app registered for that scheme. It never starts an arbitrary program; otherwise the promise rejects with code: "ERR_APP_NOT_FOUND". Unspecified backgroundTab follows the Shift key. Links from a private window open privately, or reject with ERR_PRIVATE_WINDOW_UNAVAILABLE (and browserName) when the browser cannot. Other codes: ERR_URL_SCHEME_NOT_ALLOWED, ERR_LAUNCH_FAILED. A URL object's + in the query is sent as %20. |
openTemplateUrl |
Host defaults as in PopClip: verbatim = Alt, copy = the user's Copy the text when searching, background tab = Shift. |
revealFile(path) |
Returns a promise. ~ and ~/… expand to the user profile; the path must be absolute; a missing local path throws at once. |
share(serviceName, items) |
Opens the Windows share sheet. Accepted names: "system" and Apple's generic ones (com.apple.share.Mail.compose, com.apple.share.Messages.window, com.apple.share.AirDrop.send …); other names throw "not recognized". |
runShellScript, runShellScriptFile, $ |
Need script. Options interpreter (required for runShellScript; a file uses its #! line or extension otherwise), shellMode, env, stdin, arguments, prefix; the $ tag also quote. $ uses the given interpreter, else bash (when POSIX shells are allowed and Git Bash/WSL exists), else PowerShell, and quotes interpolated values for that shell (set -euo pipefail prefix for POSIX shells). Batch-file arguments containing & \| < > ^ % " or line breaks are refused with a TypeError — pass such text through stdin or env. |
runAppleScript*, runShortcut, performService |
Throw "… is not available on Windows". |
settingsRequiredError, signInRequiredError |
As in PopClip. A thrown error whose message starts with "settings error" or "not signed in" also opens the settings. |
auth(info, flow) |
flow(url, params, expect) opens the default browser (never a private window) and waits (10 minutes at most) for the redirect to http://localhost:<port>/callback/<identifier>/<state>/auth on a loopback listener (random port unless authRedirectPort); the state is a 128-bit random value. The Settings sheet stores the result (secret, label, expiresIn). |
util.localize |
Translates Cut, Copy, Paste, Search, Delete, Select All, Open Link(s), Line(s), Word(s), Character(s) into 16 languages, following Seltzer's UI language (Windows' otherwise); other strings are returned unchanged. |
util.hasDictionaryDefinition, util.getDictionaryDefinition |
English only, from the bundled Open English WordNet 2025 (headword, part of speech, glosses, examples, synonyms). |
util.getSpellingLanguages, checkSpelling, getSpellingGuesses, getPreferredSpellingLanguages |
The Windows Spell Checking API; codes like en_US (tags like en-US also work). |
util.localeInfo, util.timeZoneInfo |
From the Windows regional settings and time zone, re-read when they change. |
util.htmlToMarkdown, util.cleanHtml |
Run turndown and sanitize-html in a shared engine; turndown options must be JSON-serialisable (function options are dropped). |
util.clarify, hash, hmac, base64*, buildQuery, parseQuery, getRandomValues (≤ 65536 bytes), randomUniform, randomUuid, constant |
As in PopClip. |
pasteboard.text, pasteboard.content |
The Windows clipboard, read and written synchronously (bounded waits); inside an action a write first waits until an earlier paste of that action has been consumed. Reads work in every phase; writes throw during population. Types: public.utf8-plain-text, public.html, public.rtf. |
RichString |
{format: "rtf" \| "html" \| "markdown"}; .html and .rtf are converted with RtfPipe, Markdig and Seltzer's own HTML→RTF writer (byte-for-byte parity with macOS is not possible). |
util.seltzer (Seltzer only; read-only, synchronous; available in every phase except explorerFolder(), which only an
action may call):
const w = util.seltzer; // undefined in PopClip
w?.preferences; // { alwaysCopyPlainText, alwaysPastePlainText, copyOnSearch, copyOnOpenLink,
// spellingShowFirst, otherUrlSchemes } (a fresh snapshot per read)
w?.installedBrowsers(); // [{ name, identifier, executable, progId, isDefault }]
w?.resolveApp("com.google.Chrome"); // true when the bundle ID / exe / ProgId / AUMID is an installed browser
w?.explorerFolder?.(); // "C:\\Users\\you\\Project", "\\\\server\\share\\dir" or undefined (actions only)
identifier is the mapped bundle ID when Seltzer knows one, else the executable name; use it with openUrl(url, {app}).
explorerFolder() answers the folder of the File Explorer window you used most recently: the Explorer window highest in the
z-order (a minimised one counts; hidden windows and windows on other virtual desktops do not), and of a tabbed window its active
tab. It returns a drive path (D:\ keeps its backslash, other folders have none) or a UNC path, and undefined when no
Explorer window is open, when that tab shows no folder of the file system (This PC, Home, Recycle Bin, Network, search results,
a library, the inside of an archive), when that window cannot be read (an older window's folder is never answered instead), or
when Explorer does not answer within half a second (a second and a half if Seltzer has not read Explorer's window list yet,
right after it starts). Seltzer never activates, navigates or changes the window, and never touches a network path to check it
(a local folder must exist). It throws in population
functions, regexes and at the module top level, so building the bar never asks Explorer. Privacy: the path tells what you
are working on, so an extension learns it only when you click one of its actions — the same as the selected text and
popclip.context.browserUrl, which every extension receives — and Seltzer never logs it. The bundled Claude Code extension
uses it to start a Claude Code session in that folder; check for the function first (w?.explorerFolder?.()), since older
Seltzer versions and PopClip lack it.
Network access
- Requests (
XMLHttpRequest, axios) need thenetworkentitlement and are allowed only in actions andauth(errors:ERR_ENTITLEMENT, and the population rule above). - HTTPS is required, except for local hosts:
localhostand*.localhost, any IP address literal (loopback, LAN or public), single-label host names (http://nas,http://ollama-box) and*.localnames. Anything else over plainhttp:fails withERR_INSECURE. The rule applies to every redirect hop; redirects are followed by Seltzer, andAuthorization/Cookieheaders are dropped when a redirect changes host. - There is no default request timeout (long AI answers work; only establishing a connection gives up after 30 s);
set axios'
timeoutif you want one (ECONNABORTED). Aborts and cancellation giveERR_CANCELED, transport errorsERR_NETWORK. - Responses are capped at 32 MB; cookies are not stored; gzip/deflate/brotli are decoded automatically; the system proxy
is used; requests carry a
Seltzer/<version>User-Agent unless the script sets its own. - Cross-origin rules (CORS) do not apply: this is not a browser.
Shell scripts, PowerShell and Command Prompt
All three run as child processes in a Windows Job Object, are killed when the user cancels or when the time limit (default 60 s, Settings → Advanced → Script time limit, 0 = none) expires, and follow the same rules:
- Working directory: the package folder.
- Environment: the user's environment with a fresh
PATH, plus thePOPCLIP_*andSELTZER_*variables (list).shell mode: nonegives a minimal environment;loginandnonloginare the same on Windows. - Output: UTF-8. Exit code 0 → stdout is the result; 2 → the extension's settings open; anything else → failure (stderr goes to the debug log). Output beyond 16 MB fails the action rather than paste a truncated result.
- Secrets reach the environment only for trusted or confirmed extensions.
Shell scripts (shell script inline, shell script file):
- Inline source is written to the interpreter's standard input (
shwhen nointerpreteris given). - A file runs with
interpreter, else its#!line (the executable bit is not required, since Windows zips lose it), else by extension (.ps1→ PowerShell,.cmd/.bat→ cmd), elseshfor extensions older than PopClip 4035 or files ending in.sh. stdin: <field>feeds a variable (text,full text,html,option x…) to standard input.
interpreter / #! |
Runs |
|---|---|
python, python3, python3.12 |
py -3 / py -3.12 (Python launcher) when installed, else a real python.exe/python3.exe on PATH (Microsoft Store stubs are skipped) |
pwsh, powershell |
PowerShell 7 when installed, else Windows PowerShell 5.1 |
sh, bash, zsh, dash, ksh |
Git for Windows sh.exe/bash.exe, else a WSL distribution — only when the user turned on Allow sh and bash scripts; zsh/dash/ksh run as bash; WSL gets /mnt/<drive>/… paths |
node, ruby, perl, php, … |
<name>.exe on PATH (Node.js also from Program Files) |
/usr/bin/env python3, /usr/bin/env -S python3 -u, /usr/bin/ruby |
Resolved like the #! line: by program name |
osascript, swift, open, say, pbcopy, pbpaste |
Fail with "… is a macOS program and is not available on Windows" |
.bat/.cmd shims are never used as interpreters. Python gets PYTHONUTF8=1 and PYTHONIOENCODING=utf-8.
PowerShell (powershell inline, powershell file): runs pwsh 7 if installed, else Windows PowerShell 5.1, always
with -NoProfile -NonInteractive -ExecutionPolicy Bypass -File on a generated wrapper that sets UTF-8 console encodings
and then calls your script. Your script gets:
$PopClipTextand$PopClipFullText(the matched and the full text), and thePOPCLIP_*environment variables;- the matched text on standard input:
[Console]::In.ReadToEnd()returns it at any length, and$inputworks at the script's top level; -Fileexit-code semantics (exit 2opens the settings; a parse error or unmet#requiresis exit 1).
Write the result with [Console]::Out.Write($result) — Write-Output adds a line break (or set
trim trailing newline: true). A Group Policy that enforces an execution policy still wins; the action then fails with
that message.
Command Prompt (cmd inline, batch file): runs cmd.exe /d /c on a generated .cmd that starts with
@echo off and chcp 65001>nul (UTF-8). cmd expands %VAR% before parsing, so %POPCLIP_TEXT% turns the
selection into batch syntax (a & calc would start Calculator). Use delayed expansion:
setlocal EnableDelayedExpansion
echo(!POPCLIP_TEXT!
A cmd line holds at most 8,191 characters, so longer selections come out empty; the matched text is also on standard
input (findstr "^" copies it). For anything non-trivial, use PowerShell.
Example — one action, two platforms. Because the action type is decided by precedence, a single static action cannot say "shell script on macOS, PowerShell on Windows"; a JavaScript action can pick the script at run time:
// Config.js in WordFrequency.popclipext, next to freq.sh (macOS) and freq.ps1 (Windows)
// #popclip
// name: Word Frequency
// identifier: com.example.wordfreq
// entitlements: [script]
// after: show-result
exports.action = async (input) => {
const file = util.seltzer ? "freq.ps1" : "freq.sh"; // util.seltzer exists only in Seltzer
const result = await popclip.runShellScriptFile(file, { stdin: input.text });
return result.toString(); // stdout without the trailing line break
};
runShellScriptFile picks the interpreter from options.interpreter, the file's #! line or its extension (.ps1,
.cmd, .bat); runShellScript (inline source) requires options.interpreter.
Key combos: Mac to Windows
keyCombo/keyCombos strings are parsed with PopClip's grammar (order-free, case-insensitive) and translated:
| Mac | Windows |
|---|---|
command, cmd, ⌘ |
Ctrl |
control, ctrl, ^, ⌃ |
Ctrl |
option, opt, ⌥ (Seltzer also accepts alt) |
Alt |
shift, ⇧ |
Shift |
numpad |
The numeric-keypad key (keys Windows has; numpad = is rejected) |
win (Seltzer-only) |
The Windows key (needs a trusted or confirmed extension) |
return, enter |
Enter (numeric-keypad Enter with numpad) |
delete |
Backspace (the Mac Delete key) |
forwarddelete |
Delete |
tab, space, escape/esc, left/right/up/down, home, end, pageup, pagedown, f1–f24 |
Same keys |
help |
Insert (the key in that position) |
clear |
Clear |
| A letter | That letter key; upper case does not add Shift |
Another character (/, ;, é …) |
The key that types it in the target app's keyboard layout (Shift added when the layout needs it); characters that need AltGr fall back to the US-layout key; a character with no key fails before anything is pressed |
Mac key code: hex (0x24), decimal with two or more digits (36), or a plain number in JSON/JavaScript (a single digit is the digit key) |
The key in that position on an ANSI keyboard (so command 0x09 is Ctrl + the key where V is on a US keyboard); codes of keys Windows lacks (keypad =, Fn) are rejected |
keyCombos may contain "wait 100" entries (0–5000 ms). Each combo is sent as one atomic input sequence: modifiers the
user is holding are released and restored around it, Seltzer waits for the Windows key to be released first, and Alt/Win
presses are masked so they do not open menus or Start. The target is checked before every combo; if another window came
to the front, nothing more is pressed.
winKeyCombo/winKeyCombos replace the Mac combos on Windows (PopClip ignores them) and use Windows names:
ctrl/control, alt, shift, win/windows, delete/del (= Delete), backspace, insert/ins,
apps/menu/contextmenu, printscreen/prtsc, pause, capslock, numlock, scrolllock, pgup/pgdn,
volumeup/volumedown/volumemute/mute, 0x.. or vk_XX virtual-key codes, plus the names above:
- title: Redo
key combo: command shift z # macOS
win key combo: ctrl y # Windows apps usually redo with Ctrl+Y
- title: Line start
key combo: control a # would be Ctrl+A (Select All) on Windows ...
win key combo: home # ... so say what you mean
In JavaScript, popclip.pressKey(key, modifiers) takes a key name, character or Mac key code and the Mac modifier mask
(util.constant.MODIFIER_COMMAND 1048576, MODIFIER_OPTION 524288, MODIFIER_CONTROL 262144, MODIFIER_SHIFT 131072;
2097152 adds numpad); pressKeys([...]) takes combo strings (including wait). util.constant.KEY_* are Mac key codes
and are translated by position.
Icons
| Specifier | On Windows |
|---|---|
Text: up to 3 characters or one emoji, with square, circle, search, filled, strike, monospaced, scale=, rotate=, move-x=, move-y=, flip-x, flip-y, preserve-color, preserve-aspect; legacy [[AB]], text AB |
Same grammar and measurements as PopClip (Segoe UI Variable; Cascadia Mono for monospaced); colour emoji through DirectWrite |
Package file (icon.svg, icon.png) |
SVG (scripts and external references are removed; oversized or deeply nested documents are refused) and PNG up to 4096 px and 8 MB; drawn as a template (tinted with the bar's text colour) unless preserve-color |
svg:<svg …>, data:image/svg+xml,…, data:image/png;base64,… |
Same |
iconify:<set>:<name> |
Downloaded from the Iconify API (api.iconify.design, with fallback hosts), cached for 7 days; shows a placeholder until downloaded, and the title when the user turned off icon downloads. Coloured sets keep their colours |
symbol:<name> |
Apple SF Symbols cannot be used on Windows. 92 names are mapped to Fluent UI System Icons or Lucide (user setting: Close to macOS or Windows (Fluent)); for other names .fill, .circle, .square, .slash and .badge… suffixes are stripped and drawn as the filled style, a circle or square shape or a strike-through; names that still do not match show the title |
bundle:<name> |
Seltzer's own names only: copy, cut, paste, search, link, book, reveal, spelling, folder, plus, loading, warning, chevron-left, chevron-right, more. PopClip's bundle: artwork is not available; other names show the title |
Remote http(s) icons |
Not supported (an off-by-default hidden option in PopClip) |
When an icon cannot be drawn, the action's title is shown instead. For icons that work everywhere, ship an SVG in the package or use a text icon.
Windows-specific variables and context
Scripts receive every PopClip variable — POPCLIP_TEXT (matched), POPCLIP_FULL_TEXT, POPCLIP_URLENCODED_TEXT,
POPCLIP_HTML, POPCLIP_RAW_HTML, POPCLIP_MARKDOWN (with capture html), POPCLIP_URLS, POPCLIP_EMAILS,
POPCLIP_PATHS (one per line), POPCLIP_MODIFIER_FLAGS, POPCLIP_BUNDLE_IDENTIFIER, POPCLIP_APP_NAME,
POPCLIP_BROWSER_TITLE, POPCLIP_BROWSER_URL, POPCLIP_EXTENSION_IDENTIFIER, POPCLIP_ACTION_IDENTIFIER,
POPCLIP_DECIMAL_SEPARATOR, POPCLIP_GROUPING_SEPARATOR, POPCLIP_OPTION_<ID> — with these Windows meanings and
additions:
| Variable | Value |
|---|---|
POPCLIP_MODIFIER_FLAGS |
Mac bits: Ctrl → 1048576 (command), Alt → 524288 (option), Shift → 131072; control is never set; Win is not reported |
SELTZER_MODIFIER_FLAGS |
Windows bits: 1 Shift, 2 Ctrl, 4 Alt, 8 Win |
POPCLIP_BUNDLE_IDENTIFIER |
The mapped macOS bundle ID for known apps (com.google.Chrome), else the executable name |
POPCLIP_APP_EXECUTABLE |
The executable name, e.g. winword.exe |
POPCLIP_BROWSER_URL, POPCLIP_BROWSER_TITLE |
Resolved on demand (at most 250 ms); empty for private windows unless the user allows it |
SELTZER_OMITTED_VARIABLES |
Comma-separated names of variables left empty because their value exceeded 32,000 characters (Windows' environment limit); read large text from standard input |
SELTZER_TEXT_ON_STDIN |
1 for PowerShell and cmd children, which receive the matched text on standard input |
In JavaScript, popclip.context adds appExecutable, appUserModelId and windowClass, and util.seltzer gives the
user's preferences and the installed browsers (above).
Trust, verification and identifiers
- Verification. Signatures cannot be checked on Windows, so
_Signature.plistmeans nothing. An extension installed from a PopClip Extensions Directory link (Seltzer.exe install https://www.popclip.app/extensions/x/<shortcode>) is downloaded from the directory and every file is compared with the directory's published Git-SHA-256 hashes; if all match it is Directory (hash-verified). Everything else is Unverified. - Confirmation. An unverified extension needs the user's confirmation before anything of it is executed or even
transpiled when it has shell-script, AppleScript, PowerShell, cmd or Run in Terminal actions, any entitlement
(
network,dynamicorscript), or key combos withwin. The prompt lists these reasons and showsshell script rationale. The confirmation is bound to a digest of the package's files (paths, contents, executable bits): any change asks again. A verified extension that no longer matches its installed digest is downgraded to unverified, and its verified secrets are deleted. - Entitlement sniffing. At install, Seltzer warns (as PopClip does) when code uses an API that needs an entitlement the config does not declare ("Using X requires the 'Y' entitlement").
- Identifiers follow
^[a-z0-9]+([._-][a-z0-9]+)*$, case-insensitive, at most 100 characters (underscore allowed). Without anidentifier, a package uses its folder name and a snippet its name (app.seltzer.autoid.<slug>-<hash>).app.seltzer.*is reserved for Seltzer's own extensions (unless the user enables Allow reserved identifiers);com.pilotmoon.*andapp.popclip.*are accepted but never count as verified. Built-in and bundled extensions cannot be replaced. popclip versionnewer than 6221 gives a warning, not an error.
Testing with Seltzer.exe run
Seltzer.exe run <file.js|file.ts> [functionName] is the equivalent of PopClip run: it loads the file as a module in a
fresh engine — the same globals, bundled libraries, require and TypeScript support as a real extension — calls the named
export with no arguments if given, awaits it, prints the result and exits with 0 on success or 1 on a load error,
a thrown error or a budget overrun (Error: … is printed). It runs in the invoking process: nothing else of Seltzer
starts (no hooks, tray or windows), and it works whether or not Seltzer is running.
// wordcount.js
exports.test = async () => {
const words = "the quick brown fox".split(/\s+/).length;
print("counting …"); // printed immediately
return `${words} words`; // printed as the result
};
& "$env:LOCALAPPDATA\SeltzerApp\current\Seltzer.exe" run .\wordcount.js test | Write-Output
$LASTEXITCODE
Seltzer.exe is a Windows (GUI) program: piping its output (as above, or | more in cmd) makes the shell wait for it and
keeps the output in order. Ctrl+C cancels a run. In a test:
- the
networkandscriptentitlements are granted; popclip.input,contextandoptionsare blank;- clipboard, key, URL, share and feedback methods of
popclipdo nothing;showTextprints its text; $andrunShellScript*really run their commands;pasteboardreads and writes the real Windows clipboard.
print() output is always shown. The installed extension of the same name is never touched. (The path above is the
installer's layout; use your build or portable folder otherwise.)
Developing with a live folder
- Settings → Advanced → Load Unpacked Extension… and pick your
*.popclipextfolder (or any folder with aConfig.*file). It is loaded in place with the origin Developer folder; Seltzer never writes into it or next to it, and never deletes it (uninstalling only forgets it). - Turn on Reload developer extensions on change: about half a second after you save, the extension reloads (its top level runs again). An extension with scripts or entitlements asks for confirmation again after each change — turn on Don't ask before installing unverified extensions while developing if that gets in the way (and off afterwards).
- Turn on Extension debug output and open the Debug Console (tray menu or
Seltzer.exe console):print(),console.*, script stderr and population errors appear there underExtension.<identifier>, next to load diagnostics. - Why Didn't It Appear? (
Seltzer.exe diagnostics) shows whether a selection was captured and "No action applies to this text" when your requirements or regex did not match. - Settings → Actions → ⋯ → Extension → View Source / Export… gives you a
.popclipextzof what is installed.
Limits
| Limit | Value |
|---|---|
| Selection handled at all | 10 × 1,048,576 characters |
| Regexes and link/e-mail/path detection | first 51,200 characters (skipped above) |
| Snippet from a selection | 5,000 characters (files are unlimited) |
| Package | 16 MB unpacked, 1,000 files; LZFSE module 16 MB decoded |
| Show/preview result | one line, 160 characters |
| Open Link | 25 links |
wait in key combos |
0–5,000 ms |
| Environment variable | 32,000 characters (cmd's %VAR% lines: 8,191) |
| Script output | 16 MB per stream |
| Action-list folders | 5 levels |
PopClip is a trademark of Nicholas Moore (Pilotmoon Software). Seltzer is an independent product and is not affiliated with, endorsed by, or sponsored by Pilotmoon Software.