Branded desktop product
A branded product is your extension shipped as its own desktop application: its own name in the dock, its own icon, its own window with no WAMP chrome, its own data directory and its own update feed. One declarative file describes the whole brand, and one script runs and builds it.
What a product file is
Section titled “What a product file is”products/<brand>/product.json. Its schema is closed — an unknown key is a
build error, not an ignored line — and it is validated at build time, so a
malformed brand fails the build rather than the installed app. The validated
product is baked into the main bundle; there is no runtime brand switching, and
only the display fields ever reach the renderer.
Every field, and what it controls:
| Field | Controls |
|---|---|
id |
the stable machine slug (wamp, forge). Used on any wire that needs one, including catalog scoping. Never changes for branding reasons |
name |
the application name — dock, menu bar, and the userData directory. Also the packaged artifact name |
appId |
the bundle identifier |
executableName |
the packaged binary’s name |
icons |
a repository-relative directory holding icon.png, icon.ico, icon.icns |
wordmark |
logo text; null renders the mark alone |
tagline |
one line, shown on the brand surfaces |
copyright |
the copyright string in the binary’s metadata |
rootPageId |
the switch. null is the full WAMP shell. Set, and the app boots straight into that page’s window |
bundledExtensions |
extension ids staged into the package and seeded on first run |
settingsTabs |
an allowlist of settings tab ids; null shows every tab |
serverUrl |
the default API base. The WAMP_API_URL environment variable still overrides it at runtime |
cspConnectHosts |
extra connect-src hosts for the packaged renderer’s content security policy |
homeDirSegment |
the user-home store segment — .wamp becomes ~/.wamp. A dot-prefixed segment with no path separators. This is never the workspace-local .wamp/ directory, which is a data format inside a user’s project |
localCorePort |
the loopback port the engine daemon listens on. Explicit and unique per product |
update.url / update.channel |
the auto-update feed and channel |
accounts |
who signs in — see below. Absent means { mode: 'wamp' } |
accounts takes mode (wamp, none, or own), an appSlug (required for
own, optional for none, rejected for wamp), and signUp (a boolean, valid
only for own). The three modes and what they mean are in
Choosing a path; the mechanism behind own is
Your own backend and
Hosted end-user accounts. Your extension’s code
never reads any of this.
rootPageId is the field that makes it a product
Section titled “rootPageId is the field that makes it a product”With rootPageId: null you get WAMP with your branding on it: the main window,
the sidebar, the chat. Set it to one of your extension’s page ids and the app
boots with no main window at all — the root page’s window is the
application. Secondary windows your app opened last session are restored around
it.
The page has to come from somewhere, which means the extension that contributes
it must be in bundledExtensions. The seed of those extensions is ordered to
land before the extension loader’s first scan, so on a first run the root page
exists without any network round-trip. Page ids and window behavior are in
Pages and windows.
Scaffold a brand
Section titled “Scaffold a brand”npm run new:product -- atlasThat writes products/atlas/product.json with the default product as its
template: the same serverUrl and cspConnectHosts, homeDirSegment set to
.atlas, update.url pointed at <serverUrl>/releases/atlas, and
localCorePort set to one above the highest port any existing product uses — so
two brands can never collide on the loopback port. It also copies placeholder
icons into products/atlas/icons/, so the product builds on the day it is
created, and prints the two commands to run next.
A new product starts as the full shell with no bundles. Then you edit it: the
name, the wordmark, the tagline, bundledExtensions, and rootPageId.
Bundle your app into it
Section titled “Bundle your app into it”bundledExtensions is a list of extension ids. Each one is resolved at package
time from one of two places:
extensions/<id>— an in-repo source. It is built and staged into the package automatically by the packaging hook.products/<brand>/extensions/<id>— a tree pre-staged by that extension’s own script, for an app that lives outside the repository.
A declared bundle that is in neither place fails the build with a message naming both paths. The staged form is the same published form the marketplace serves, so an extension does not need a second build to be bundled.
Pre-staged bundles are a snapshot, which used to be the one silent failure in the pipeline — edit the app, build, and ship the old code. The build now refuses instead: if the source it was staged from is newer than the staged copy, the build stops and prints the re-stage command.
On first run each bundle is copied into the user’s extension directory before the loader scans, and on later runs it is replaced when the bundled copy has a newer version. Extension storage lives outside the extension directory and is never touched by the seed, so an update does not cost a user their data.
Run it
Section titled “Run it”npm run start:product -- atlasThat is the exact equivalent of npm start, with your product instead of WAMP.
Build the binary
Section titled “Build the binary”npm run build:product -- atlasnpm run build:product -- atlas --platform mac --arch arm64npm run build:product -- atlas --platform win --arch x64--platform accepts mac or win and defaults to the machine you are on.
--arch accepts arm64 or x64; it defaults to the host architecture on macOS
and to x64 on Windows. Anything else is rejected with the valid values.
Two directories come out, both derived from the product file rather than hardcoded:
out/<name>-<platform>-<arch>— the packaged application tree.dist/<brand>— the distributables. The default product keeps a plaindist/.
Before it builds anything the script checks the things that otherwise fail late and unhelpfully: a missing icons directory, a bundle it cannot resolve, a stale staged copy. To see what a build would do without running it:
node scripts/product.mjs plan atlas --platform mac --arch arm64That prints the derived paths, the resolved bundle sources, and the exact argument arrays as JSON, and touches nothing.
Publish to an update feed
Section titled “Publish to an update feed”The app finds its feed through a generated app-update.yml inside the package,
written from update.url and update.channel. The updater then polls
<update.url>/latest*.yml every four hours. Nothing in the application code
sets a feed URL — the product file is the only place it is decided.
bash scripts/upload-release.sh atlasThe script uploads dist/atlas’s current latest*.yml manifests, the artifacts
they reference, and their .blockmap siblings, over ssh to WAMP’s release host —
so this exact command is for whoever operates that host. If you serve your own
feed, point update.url at it and copy the same files: artifacts first, then the
manifests, so a client polling mid-upload never reads a manifest pointing at a
file that is not there yet.
Two guards run before anything is copied, and both have caught real mistakes: a
latest*.yml whose version does not match the repository’s current version is
skipped loudly rather than uploaded, because a stale manifest repoints an entire
platform’s update channel at an old release; and every artifact name must start
with the product’s name, which is what stops one product’s build from landing
in another’s channel.
macOS: two hazards that will cost you a release
Section titled “macOS: two hazards that will cost you a release”1 · Builds are ad-hoc signed and not notarized
Section titled “1 · Builds are ad-hoc signed and not notarized”The packaging step signs the application with codesign --sign - unless
APPLE_IDENTITY is exported, and no step submits anything to Apple’s notary
service. The distributable step never touches the application’s signature at
all — it wraps an already-packaged tree, and signing is skipped in that mode.
What that means concretely:
- Gatekeeper rejects the download. A user has to right-click → Open once, or clear the quarantine attribute by hand. Plan your install instructions around that.
- Auto-update is disabled on macOS, in code. The framework behind the updater hard-requires a Developer ID signature on the downloaded payload and rejects an ad-hoc one outright, so the update path is skipped on darwin rather than failing at the user. Your macOS users update by downloading a new build. Windows auto-update is unaffected.
APPLE_IDENTITYis the switch for real signing. Export it as"Developer ID Application: Name (TEAMID)"before the packaging step and the application is signed with that identity and the hardened runtime enabled. That still is not notarization, which needs a separate submission step.
2 · The release filename decides which macOS clients get the build
Section titled “2 · The release filename decides which macOS clients get the build”The macOS updater routes by filename: it treats any artifact URL containing
arm64 as the arm64 build and everything else as x64. The default artifact
names follow from that, and the x64 ones carry no architecture at all:
| Target | arm64 | x64 |
|---|---|---|
| dmg | <Name>-<version>-arm64.dmg |
<Name>-<version>.dmg |
| zip | <Name>-<version>-arm64-mac.zip |
<Name>-<version>-mac.zip |
Underneath, the packaged tree and the architecture flag are two independent inputs that the packaging tools never cross-check: the tree decides what is inside the artifact, the flag decides what the artifact is called. Mispair them and you ship an x64 payload under an arm64 name — which is exactly what happened to two shipped WAMP releases before this was fixed.
The fix is that build:product derives both from a single --arch value, so
they cannot disagree, and a test asserts the pairing for every product and
target. The hazard is not gone, though: it lives in running the distributable
tool by hand. If you assemble a release with your own command line, the tree and
the arch flag are yours to keep consistent, and nothing will tell you when they
are not.
Shipping both macOS architectures
Section titled “Shipping both macOS architectures”One build produces one architecture, and both write the same latest-mac.yml,
so the second overwrites the first. The default product has a scripted sequence
for this — build arm64, park the manifest, build x64, merge the two into one
manifest, then verify it — and the verify step reads each published zip’s binary
header and checks it against the architecture its filename claims. That sequence
is wired for the default product only; a branded two-architecture release means
running the same four steps with your product selected.
Windows
Section titled “Windows”The Windows target is a one-click NSIS installer named
<Name> Setup <version>.exe, with a desktop shortcut and a start-menu entry.
Providing WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD signs the installer and the
uninstaller; the executable inside the package is not signed by this pipeline.
An unsigned installer triggers SmartScreen on first run.
If your product’s users are your own rather than WAMP’s, the identity half is Your own backend — the same mechanism whether WAMP holds the signing key or you do.