Welcome to the Jose Madrid Salsa developer docs — explore features, APIs, and deployment guides.
Jose Madrid SalsaJMS Docs

Desktop Apps

The Windows and macOS admin apps — what they are, how to install them, how they are built, and the guarded database console they carry

There are two desktop applications for running the business away from a browser tab:

AppWorkspaceTechnologyInstaller
Windows 11apps/windows-adminElectron 44 + TypeScriptJoseMadridSalsaAdmin-Setup-<version>.exe (NSIS)
macOS (Apple silicon)apps/macos-adminSwiftUI + WKWebViewJoseMadridSalsaAdmin-<version>.dmg

Both open on the desktop shell at https://www.josemadridsalsa.com/admin-desktop — the same database, drawn as a dense keyboard-driven window instead of the web panel's page chrome — and both can reach every other page of the admin panel from there.

That split is deliberate. The admin panel is 163 pages and 352 API routes, and rebuilding it natively per platform would mean writing every future admin feature three times. Because the apps load the real thing, everything the admin panel can do, they can do — orders, products, inventory, customers, financials, fundraisers, events and shows, email marketing, social publishing, media and documents, analytics, settings — on the day the feature ships, with no desktop release required. The shell sits on top of that as the fast path for the work done every day, and hands off to the web page for everything else.

What the desktop apps add over a browser tab is the native layer: their own menus and keyboard shortcuts, a save dialog for every export, real printing, a persistent signed-in session, an offline screen that explains itself, and — on Windows — automatic updates.

The desktop shell

/admin-desktop is a single full-window view: a 44px title bar the window itself drags by, a 232px sidebar grouping every section under Operations, Programs, Money, Marketing and System, one content pane, a detail inspector on the right, and a status bar of keyboard hints. It follows the system's light or dark appearance, and the appearance button in the title bar cycles light → dark → follow-system for that machine.

Both windows are frameless so the shell's own title bar runs to the top edge: macOS floats its traffic lights over the left of it, Windows paints its minimise/maximise/close buttons over the right.

All 23 sections read live data in the shell, and a section that covers more than one thing draws a tab strip under its heading for the pages inside it — 58 pages in all:

SectionWhat it shows
DashboardToday's orders and revenue, live fundraisers, jars on hand, SKUs at or below reorder, the next shows
OrdersThe newest orders with jar counts, channel, status and totals
ProductsCatalogue with retail, unit cost and the margin between them
InventoryOn hand, reserved, available and reorder point, flagged against the reorder threshold
CustomersRanked by lifetime value, with order counts and source
Purchase OrdersInbound supply by supplier, with goods, freight and what is still outstanding
InvoicesAccounts receivable, with what is open and what has run past its due date
FundraisersEvery campaign, set up on the site or mirrored from the BigCommerce fundraising store, with sales and the share going to the group. A store campaign shows "In store" while its group is in the store's checkout dropdown. The Participants page lists participants across every campaign
Events & ShowsA month calendar plus every show on record, with booth fees and takings
WholesaleTrade accounts with their discount, minimum, terms and approval state
FinancialsThe general ledger — debit, credit, category, source and QuickBooks export state
Email MarketingCampaigns with audience, recipients, open and click rates
SocialScheduled and published posts with reach, engagement and clicks
Content & BlogBlog posts with their URL, status and SEO character budgets
Lead GenerationThe prospecting pipeline with the Google rating and review count behind each lead
ReviewsCustomer feedback awaiting moderation, with the average rating
AnalyticsTwelve months of revenue against the year before, channel mix, top products, retention by first-order quarter
Media & DocsThe library by kind and size, flagging images with no alt text
MessagesOne inbox merging support conversations, the contact form and live chat
Users & RolesStaff accounts with role, two-factor state and last sign-in
Audit LogsWho did what, most recent first
SettingsStore identity, checkout rules and search settings — editable here; payment keys, carriers and integrations show live state
Database ConsoleRow counts for the core tables, and a Developer page — the Developer Console's service checks, platform counts and recent activity, with its tools one click away (needs developer:system)

The pages inside a section

A section is a place in the sidebar; a page is one table, board or settings sheet inside it. The strip under the heading switches between them, every page is one ⌘K away by name, and each has a URL of its own (see Opening a page directly).

SectionPages
OrdersOrders · Returns & RMAs · Shipping labels
Purchase OrdersPurchase orders · Suppliers
CustomersCustomers · Loyalty rewards
FundraisersFundraisers · Battle arena · Arena game codes
Events & ShowsCalendar · Packing manifests
WholesaleAccounts · Store locator
FinancialsLedger · Reconciliation
Email MarketingCampaigns · Templates · Automations · Lists & subscribers · Suppressions · Send log · Brand kit
SocialPosts · Connected accounts · Product feeds · Reach
Content & BlogBlog posts · Pages · Banners · FAQs · Redirects · SEO
Lead GenerationLeads · Campaigns
ReviewsReviews · Forms
AnalyticsOverview · Retention · Margin · Attribution
Media & DocsMedia library · Documents archive · Mileage log · Show archive
MessagesInbox · Live chat · Notifications
Users & RolesStaff accounts · Credential vault
SettingsStore · Payments · Shipping · Integrations

Dashboard, Products, Inventory, Invoices, Audit Logs and Database Console are one page each, and draw no strip. Loyalty rewards additionally needs settings:read, the permission the web screen checks.

Retention, Margin and Attribution read the trailing year. The web reports take a range from the query string; the window has no place to put one, so it uses the widest and says so in the totals row rather than showing a shorter period that looks like the whole story.

Some columns differ from the design they were drawn to, because the schema does not carry the field: Products shows unit cost rather than a wholesale price, Customers shows acquisition source rather than a mailing address, Fundraisers counts orders rather than jars, Wholesale shows the account's own discount and minimum rather than a price tier, Lead Generation shows the stored Google rating rather than a computed score, and the Database Console reports row counts only — on-disk size and index counts need pg_catalog queries, and this codebase goes through Prisma rather than raw SQL.

Content & Blog lists the blog. Pages, banners, FAQs and redirects are edited through /admin/content but are not one queryable list, so they are reachable from the command palette rather than drawn as a table.

Adjustable columns

Every table column has a grab strip on its trailing edge. Drag it to set that column's width; double-click it to put the column back on its default. Arrow keys resize a focused strip too — ← and → in 8px steps, held with Shift in 24px steps — so a column can be sized without a mouse.

A width you set is a property of your window, not your account: it is remembered in that browser or that desktop app, per section and per column, so the same person on a laptop and on the shop monitor keeps two different layouts. Reset columns appears in the filter bar once a section has hand-set widths, and the same command is in the palette.

A column's default may be a share of the pane rather than a fixed size. Once you drag it, it becomes an absolute width — dragging a column is saying you want it that wide, not that share of whatever is left.

Sorting by a column

Click any column header to sort the table by it. The first click puts numbers, money and dates biggest and newest first (a fundraiser's Runs column lists the newest campaign at the top) and text A to Z. A second click flips the order, and a third puts the rows back in the order the section loaded them. A small arrow marks the sorted column.

Dates sort by the actual date, not the Oct 21 text, so campaigns from different years land in the right order. Empty cells (—, Never) always go to the bottom. The sort applies to the rows the window has loaded and to whatever the filter box and chips leave showing, and the selected row stays selected wherever the sort moves it.

Like column widths, the sort is remembered in that browser or desktop app, separately for each page.

What each section needs to open

The shell is a second door onto the same data, so it refuses exactly what the web panel refuses. Each section is gated on the permission its /admin page checks, and the sidebar is built from the sections the signed-in account may actually load — nothing is offered that would then be denied. Requesting a gated section directly returns 403 from /api/admin/desktop/[section].

A page inside a section can ask for more than the section does, mirroring the check its own /admin page makes, and the strip leaves out the tabs the account cannot load:

PageAlso needs
Users & Roles → Credential vaultcredentials:read
Settings → Integrationsapi_keys:manage
Email Marketing → Lists & subscriberscontent:write
Email Marketing → Brand kitsettings:read
Wholesale → Store locatorcontent:read
Reviews → Formscontent:read
Media & Docs → the three archivesanalytics:read

?page= is checked against the section in the path, so it is never a way to read one section's rows through another the account happens to hold.

PermissionSections it opens
orders:readOrders, Fundraisers
products:readProducts, Inventory
inventory:readPurchase Orders
financials:readInvoices, Financials
users:readCustomers, Wholesale, Users & Roles, Audit Logs
content:readEmail Marketing, Content & Blog
content:writeReviews, Media & Docs
social_media:composeSocial
messaging:readMessages
analytics:readAnalytics
settings:readSettings
developer:databaseDatabase Console
—Dashboard, Events & Shows, Lead Generation (staff, matching their pages)

Working in the shell

KeyDoes
J / K or ↑ / ↓Move the selection
↵Edit the selected row, in the window
/Focus the filter box
FCycle the filter chips
IShow or hide the inspector
F5Read this section's data again
SpaceMark or unmark the selected row for a bulk action
Ctrl/⌘-click, Shift-clickMark one row, or every row from the selection to this one
EscClear the marks
Ctrl/⌘ KCommand palette — jump to a section, a page or a row, or run a command
letters or digitsType-ahead: jump to the first row starting with what you typed

Selecting a row fills the inspector on the right, and the buttons at its foot are the jobs you can do with that row, on the key each label shows. A label ending in … opens a form; a bare label acts on the record as soon as you press it, after a confirmation if it is destructive.

Those keys are checked against the native menus: a combination either window's menu bar already owns (⌘R reload, ⌘P print, ⌘A, ⌘V, ⌘M and the rest) is never advertised, because the menu handles it before the page ever sees the key.

Staying current

A window left open keeps itself current. Every 60 seconds it reads the page on screen again, and it does so at once when you come back to it after more than 30 seconds away — switching back from another app, or un-minimising. The sidebar badges (unfulfilled orders, low stock, live fundraisers) are read again with it. The refresh is silent: it keeps the filter, the chip, the search and the row you were on, and it waits while a sheet, a confirmation or the palette is open, or while a save is in flight. A refresh that fails leaves the screen as it was. The sidebar footer shows when the data was read.

Large lists: search, load more, export

A table reads its newest 250 rows. When there may be more, the filter bar says 250 of 250+ rows and offers Load more, which reads the next 250, up to 2,000.

On the lists that outgrow any window — Orders, Customers, Audit Logs, Lists & subscribers and the three Media archives — the filter box also searches the database once you stop typing, so a customer outside the newest rows is still found. Every word has to match, each in any of the searched fields (for customers: email, name, phone, source; for orders: order number, email, name, city, tracking number). The filter bar says matches in the database while it is showing those results. Every other table is small enough that the filter box narrows the rows already loaded.

The file button at the end of the filter bar (also Export to CSV in ⌘K) downloads the rows the filter shows — chip and filter box applied the same way — without the 250-row window, up to 25,000 rows. Each export is written to the audit log.

Bulk actions

Mark rows with Space, ⌘/Ctrl-click or Shift-click, or with Mark every row shown in ⌘K, and a bar above the table offers every action all of the marked rows share — Mark paid, Mark shipped, Approve, Unsubscribe, Delete and so on. Choosing one asks once, then runs that row's own action for each marked row, one at a time, through the same write route, permission check and audit row as pressing it on each record. The toast reports how many went through and the first reason any failed. Actions that open a form are not offered in bulk, because one form filled once does not mean the same thing for thirty records.

Scanning stock

Inventory's Scan stock opens a sheet for a USB or Bluetooth barcode scanner — any scanner that acts as a keyboard and sends Enter after the code, which is how they ship. Pick what the scans mean:

  • Stock count — scan every jar on the shelf. Each product you scanned is set to the number scanned; products you did not scan are left alone, so a count can cover one shelf.
  • Receiving — scan every jar that arrived. Each scan adds one.

The sheet tallies as you go and shows each product's on-hand count and what it will become. Fix a miscount with − and +, then Apply. Nothing is written until then, and each product is one Adjust stock write — the same permission check, stale-count guard and inventory log entry as adjusting by hand, with the reason Stock count (scanned) or Received (scanned). If one fails (say a sale moved the count mid-scan), only that product stays in the sheet to apply again.

A code matches a product's barcode however the scanner pads it (UPC-A 093662452874, EAN-13 0093662452874), or its SKU typed by hand. Barcodes are also in the Products and Inventory search text, so scanning into the filter box finds the jar. A product needs its barcode filled in on its edit sheet first.

Printing shipping labels

Print label on a shipping label sends the label straight to the label printer chosen in the app's settings — File → Server Settings… on Windows, Settings on macOS — with no dialog. Set it to Ask each time (the default) to get the print dialog instead; a printer that has been removed shows as not found. A failed job raises a notification (Windows) or an alert (macOS). In a browser tab, the label opens in a new tab beside the shell.

Label paper picks the stock: Letter sheet (the default for a new install) prints the label at 4×6 in the top-left corner of an ordinary sheet, outlined to cut out; 4×6 label stock is for a thermal label printer. An install that already had a label printer chosen keeps 4×6.

Order tickets on the receipt printer

With a receipt printer set up, every new order prints a ticket within half a minute of being paid — website, fundraiser, phone, wholesale, any channel — for gathering the jars. The ticket shows the order number large, who it ships to, every line with its quantity in big type and its SKU, the totals, the customer's note, and the order number as a barcode for the pack sheet.

Set it up in the app's settings under Receipt printer (built for an 80mm ESC/POS printer such as the Rongta RP332):

  • Network (Ethernet) — the printer's IP address (print its self-test page to find it) and port, nearly always 9100.
  • Installed printer (USB) — install the manufacturer's driver so the printer appears in the system, then pick it. The app sends the ticket raw, so the driver passes it straight through.

Print a test ticket checks the connection. Orders already in when receipts are switched on are not printed. Each order prints once per computer, however many windows are open or however often the order is saved afterwards; Print receipt on an order prints it again. A ticket that fails (printer off, out of paper) is retried every minute for ten minutes, with a notification; after that, use Print receipt once the printer is back. A desktop app left closed catches up on orders from the last four days when it opens. Only computers with a receipt printer set up print, and a browser tab never does.

Packing and shipping with the scanner

Pack & ship on Orders (or Pack & ship… on a paid order that has not shipped) opens the pack sheet:

  1. Scan the barcode on the order's ticket, or type the order number.
  2. Scan every jar as it goes in the box. Each scan ticks one off its line. A jar that is not on the order, or one too many, is refused with a low tone and a message — take it back out. Fix a mistaken scan with −. A line whose product has no barcode on file takes its SKU typed by hand.
  3. When the last jar is scanned, postage is bought: the cheapest rate for the parcel the order's own items make, from the same route as the order page's label button. The order is marked shipped, and the label prints on the label printer and the packing slip on the Packing slip printer (Ask each time shows the dialog).

Nothing is bought until every jar is scanned, and an order that already has postage is never bought twice — the sheet offers to print its label and slip again instead. If the purchase fails (no shipping origin configured, a bad address), the sheet says why, nothing is charged, and Try buying postage again retries. Scan the next ticket to keep going. An order with no shipping address, unpaid, cancelled or already shipped cannot be packed here.

The packing slip is the insert for the box: what is in it, the tracking number and the customer's note, with no prices. It prints in a window of its own with scripts off.

Creating and editing, in the window

The shell writes. Creating a fundraiser, editing a product, adjusting stock, receiving a purchase order, approving a review, publishing a post — all of it happens in a sheet over the table, and the table reloads with the row you were working on still selected.

The forms are descriptions rather than screens. apps/storefront/lib/admin-desktop/forms.ts says what fields a form has; apps/storefront/lib/admin-desktop/writes.ts holds the permission, the validation and the write itself, keyed by the same id. One renderer draws all of them, and one route — POST /api/admin/desktop/write — runs all of them, so every operation goes through the same staff check, the same permission check and the same audit row. Adding a way to create something is an entry in each file, not a new screen.

What a form needs from the database it does not already have — the product list behind a line item, the categories behind a product, the templates behind a campaign — is fetched from /api/admin/desktop/options/[source] when the sheet opens, so a picker is never stale.

Some jobs still open the web admin, and say so with a … that leads out of the window:

JobWhy it lives there
Sending a campaignThe audience is previewed and a test send offered first
Connecting a social accountIt is an OAuth round trip through the browser
Running a lead campaign or a feed syncThe scrape streams its progress
Replying to a message or a live chatThe thread is a conversation, not a record
Uploading files, importing a CSV or a bank statementThe page owns the drop zone and its progress
Printing a packing slip or an invoiceIt is a document, not a record
Revealing or entering a secret — payment keys, integration credentials, the vaultThey are written once and never shown back
Building a form or a reportBoth are structure editors, not tables
Refunds, and receiving against a partial countMoney and carrier calls, with their own confirmations
Completing a returnIt settles the refund, credit or exchange and restocks — and it is terminal

The credential vault page is the sharpest case: it lists what is stored, who it is for and when it was last rotated, and it never reads the encrypted value — the vault reveals a secret behind its own confirmation, and that is deliberately the only place it happens.

Everything a write can refuse, it refuses with a reason: a derived ledger row says the record it came from owns it, a product that has been sold is retired rather than deleted, a campaign with orders against it is cancelled rather than removed, and a blog post whose effective title falls outside the 30–60 character budget says how long it is before it will save.

A few fields are deliberately absent from the sheet that would otherwise carry them, because the value is derived or is an identity rather than an attribute:

Not editableWhere it comes from instead
An order's fulfillment stateDerived from its status, which stamps shipping and delivery
A purchase order's received stateDerived from receipt quantities — receive the stock
A customer's email addressThe key order sync, imports and mailing lists join on
A return's COMPLETED stateThe settlement on the return's own page

Two checks depend on what was typed rather than on which form is open, and are made against the operator's own permissions: queueing or publishing a social post needs social_media:schedule or social_media:publish on top of social_media:compose, and the DEVELOPER role can only be assigned — or an account holding it edited — by a developer. Deleting a staff account needs the owner account, not users:write, because it cascades through everything that account owns.

Opening a page directly

/admin-desktop?section=<id> opens the window at a section — ?section=invoices, ?section=ledger, and so on — and ?page=<id> names one page inside it, ?section=email&page=email.suppressions. Both use the ids in apps/storefront/lib/admin-desktop/sections.ts, where a page id is always its section's id plus a dot. Both menu bars navigate this way, so choosing Orders from the menu stays in the shell instead of leaving it for the web page, and the shell keeps the query in step as you move so a reload comes back to the same page. The one exception is Developer Console, which has no view in the shell and opens the web page.

A section or page id that is unknown, or one the account may not open, falls back rather than erroring — an unknown page opens its section, and an unknown section opens the first one the account can see. A menu item or an old bookmark should not be a dead end.

Publishing from the shell is not the whole job

Publishing a blog post from the shell adds its URL to the sitemap, because app/sitemap.ts reads published posts from the database. Search Console is a separate step nobody here can do for you: run URL Inspection on the new URL, and check a few weeks later that the indexed-page count is rising.

Installing

Windows 11

  1. Download the installer from www.josemadridsalsa.com/api/desktop/updates/JoseMadridSalsaAdmin-Setup-latest.exe, which is always the current build.
  2. Run it. The build is not code-signed yet, so SmartScreen shows "Windows protected your PC" — choose More info → Run anyway. This is expected once.
  3. The installer offers a folder choice and creates desktop and Start menu shortcuts.

Afterwards the app updates itself: it checks on launch and every six hours, downloads in the background, and offers to restart when an update is ready. Help → Check for Updates… forces a check.

macOS

  1. Download JoseMadridSalsaAdmin-latest.dmg and drag the app to Applications. It is a universal app — the same download runs on Apple silicon and Intel Macs, macOS 14 or later.
  2. The build carries an ad-hoc signature rather than a Developer ID, so the first launch needs right-click → Open → Open. Afterwards it opens normally.

The macOS app checks the same feed on launch and every six hours, and when there is a newer version it offers to download the new disk image (each version is offered once). Help → Check for Updates… checks now and says so when you are up to date. It cannot replace itself while it is ad-hoc signed — macOS does not allow it — so installing is quit, open the image, drag to Applications.

Using them

Sign in once with the same account used on the web. The session persists across restarts.

Notifications and the badge

When an order arrives that needs fulfilling, or a customer message arrives, the app shows a system notification — New order to fulfil · 3 waiting to ship — and clicking it opens that section. The dock icon (macOS) or taskbar button (Windows, as a red dot) shows how many orders and messages are waiting. It keeps working while the window is minimised or behind others: the page checks the counts every minute even when hidden. Only sections your account can open count, the counts the window opened with are never announced, and with several windows open each alert appears once. macOS asks for notification permission the first time there is something to show.

More than one window

File → New Window (Ctrl+Shift+N / ⌘⇧N) opens another admin window, and any link the admin opens in a new tab opens one too — orders on one screen, inventory on the other. All windows share one sign-in. The menu bar acts on the window in front. (⌘N is not used: the shell already binds it to section jumps.)

Keyboard shortcuts

Both apps share one section list, so the shortcuts match:

ShortcutSectionShortcutSection
Ctrl/⌘ 1DashboardCtrl/⌘ 6Financials
Ctrl/⌘ 2OrdersCtrl/⌘ 7Fundraisers
Ctrl/⌘ 3ProductsCtrl/⌘ 8Events & Shows
Ctrl/⌘ 4InventoryCtrl/⌘ 9Email Marketing
Ctrl/⌘ 5CustomersCtrl/⌘ PPrint the current page

The rest of the sections — Purchase Orders, Invoices, Battle Arena Codes, Wholesale, Social, Content & Blog, Lead Generation, Reviews, Analytics, Media & Documents, Messages, Users & Roles, Audit Logs, Settings, Developer Console, Database Console — are in the Go menu, grouped the same way the shell's sidebar groups them.

Downloads and printing

CSV exports, invoices, packing slips and shipping labels open the operating system's save dialog. On Windows a finished download raises a notification; clicking it reveals the file in Explorer. On macOS the file is revealed in Finder. Print… prints whatever the window is showing.

Which server the app talks to

File → Server Settings… on Windows, Settings on macOS. HTTPS is required except for a localhost development server, which is how you point either app at npm run dev. A bare origin is treated as /admin-desktop; any other path on the same origin is accepted as typed, so https://www.josemadridsalsa.com/admin still opens the web panel directly.

Links to other sites — Stripe, QuickBooks, Vercel, a customer's website — open in the default browser rather than inside the app. Identity providers are the exception: Google, GitHub, Facebook and Apple sign-in redirects stay in the app window, because an OAuth round trip that finishes in a browser sets the session cookie there and leaves the app on the sign-in page.

The database console

Both apps reach /admin/developer/database, a guarded SQL console restricted to the developer:database permission (the DEVELOPER role). It exists for direct data work that has no admin screen. It is not a replacement for the admin panel: normal editing should go through the pages, which enforce validation, permissions and the transactional side effects that make inventory, payments and email behave correctly.

The console never exposes database credentials to the desktop app. Statements are posted to POST /api/developer/admin/sql and run on the server. The guards are:

  • One statement per request. Comments and string literals are stripped before parsing, so a '; DROP TABLE orders; --' inside a value cannot smuggle a second statement through.
  • Reads run read-only. A SELECT executes inside a READ ONLY transaction with a 15-second statement timeout and a row cap (200 by default, 1000 maximum). Results export to CSV.
  • Writes are previewed before they commit. An INSERT, UPDATE or DELETE is executed against real data to get a true affected-row count and then rolled back. The console reports what it would change — shouting if an UPDATE or DELETE has no WHERE clause — and commits only when you confirm. The confirmation is signed against that exact statement and user and expires in five minutes, so it cannot be replayed against a different query.
  • Data-modifying CTEs count as writes. WITH x AS (DELETE ... RETURNING *) SELECT * FROM x reads like a SELECT but is treated as a write.
  • Schema changes are refused. CREATE, ALTER, DROP, TRUNCATE, GRANT, REVOKE, transaction control and EXPLAIN ANALYZE are rejected by name. Schema changes belong in a Prisma migration, which keeps the migration history and the generated client honest.
  • Everything is audited. Reads, previews, commits, refusals and errors all write an AuditLog row (developer.sql.*) with the statement, the affected-row count and the duration. They are visible in Audit Logs.

Building

Windows installer

npm run dist:win --workspace=@jose-madrid/windows-admin

The installer and the latest.yml update feed land in apps/windows-admin/release/. This cross-compiles from macOS and Linux as well as Windows.

Day-to-day work on the shell:

npm run start      --workspace=@jose-madrid/windows-admin  # run it
npm run test       --workspace=@jose-madrid/windows-admin  # endpoint and navigation rules
npm run type-check --workspace=@jose-madrid/windows-admin
npm run lint       --workspace=@jose-madrid/windows-admin

macOS app

cd apps/macos-admin && ./build-app.sh

The script first compiles and runs the endpoint checks in Tests/main.swift — the rules for where the app may point and what it hands to the browser — then builds the bundle into dist/ and packages it as dist/JoseMadridSalsaAdmin-<version>.dmg, the app beside an /Applications symlink so the whole install is one drag. The version is read from the root package.json, so the disk image and the Windows installer always carry the same number.

A full Xcode installation is required. The standalone Command Line Tools do not ship the SwiftUI macro plugins that @State expands through, and the build fails with plugin for module 'SwiftUIMacros' not found followed by a cascade of cannot assign to property: 'self' is immutable. Only the first error is real; the rest are fallout from @State never expanding. Install Xcode from the App Store and point the toolchain at it:

sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
xcode-select -p   # should no longer say /Library/Developer/CommandLineTools

The pure-logic files still compile without Xcode, which is why the endpoint checks run first — swiftc Sources/JoseMadridAdmin/AdminEndpoint.swift Sources/JoseMadridAdmin/AdminSections.swift Tests/main.swift -o .build/endpoint-check verifies the endpoint and menu rules on a machine that cannot build the app at all.

No Xcode? Let CI build it

The Desktop Apps workflow runs on a macos-15 runner, which has a full Xcode. Running it is the supported way to get a .dmg without installing Xcode locally.

Releasing both

The Desktop Apps workflow (.github/workflows/desktop-release.yml) builds the Windows installer on windows-latest and the macOS app on macos-15, then publishes both to the update feed and archives the build as a GitHub release. Run it from the Actions tab, or push a desktop-v* tag to archive under that tag instead.

Each archive gets its own tag (desktop-build-<run>-<attempt>). This repository's releases are immutable, and an immutable release reserves its tag name forever, even after it is deleted, so a reused tag such as the old rolling desktop-latest fails every run after the first. Archives are marked not-latest so they never shadow the platform releases.

The update feed is not GitHub. The repository is private, so its release assets answer 404 to an app with no GitHub session — an app pointed there would never see an update. Instead the workflow's publish step (.github/scripts/publish-desktop-updates.mjs) uploads the build to the public Vercel Blob store under desktop/, and both apps read it through the storefront at https://www.josemadridsalsa.com/api/desktop/updates/<file>, which redirects there. That address is baked into every installed copy, so the files can move without breaking updates. The installers go up before latest.yml, so a check mid-publish never sees a manifest naming a missing file, and older builds are deleted afterwards. The step needs the BLOB_READ_WRITE_TOKEN repository secret, and fails the run without it.

Code signing

Neither app is signed yet, which is the source of the SmartScreen and right-click-to-open prompts above. The workflow signs as soon as the certificates exist — adding the repository secrets is the whole job:

  • Windows — WINDOWS_CSC_LINK (the code-signing .pfx, base64) and WINDOWS_CSC_KEY_PASSWORD. Without them the build logs a warning and stays unsigned.
  • macOS — MACOS_CERT_P12 (a Developer ID Application certificate, base64) and MACOS_CERT_PASSWORD sign the app with the hardened runtime (build-app.sh reads the identity from MACOS_SIGN_IDENTITY). Add APPLE_ID, APPLE_TEAM_ID and APPLE_APP_PASSWORD (an app-specific password) and the disk image is notarised and stapled too. Developer ID membership is $99 a year from Apple.

These steps have not run yet — there has been no certificate to run them with — so expect the first signed build to need a look.

How is this guide?

Edit on GitHub

Last updated on

On this page