Building and restarting desktop surfaces

stado product restart [--if-running] [--json] APP.app restarts a macOS application bundle after its installed bytes change, for a bundle placed outside an installation. It reads CFBundleIdentifier from the bundle's Contents/Info.plist, asks LaunchServices (lsappinfo) whether that application runs and with which process id, asks LaunchServices to end it, and waits for that process to exit with no deadline: the wait is registered (stado waits names <bundle id> (pid <pid>) to exit). It then opens the bundle in the background and reads it running again. It prints restarted <bundle> (<bundle id>), or started … when the application was not running; with --if-running a stopped application is left stopped and the answer is not-running. --json prints {"bundle", "bundle_id", "state"}. Refusals name what failed: application bundle not found: <path>, application Info.plist not found: <path>, CFBundleIdentifier is empty in <plist>, LaunchServices lists <id> as running but reports no process id: …, open accepted <bundle>, but LaunchServices lists no running <id>, and on any other system restarting a desktop application requires macOS LaunchServices. Stado Desktop's Products screen runs the same verb as Restart a local macOS application bundle after its installed bytes change.

stado product install and update of a desktop surface do the same once the bundle is placed and the receipt says installed: each placed .app whose application is running is restarted from the new bundle, one that is not running stays stopped, and the answers are kept on the receipt as restarted. A desktop build script therefore restarts nothing itself; a restart that fails ends the installation with the refusal above, its files already placed.

Before either restarts a bundle, it sets the bundle's modification time to now and registers it with Launch Services (/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f <bundle>), so Finder, the Dock and Launchpad read the new bundle, its icon among it, instead of keeping the replaced one until the next login; the report says registered: true. A bundle whose time cannot be set is refused as stado.product.restart bundle-untouched (restarting the application <bundle>: the bundle's modification time could not be set, so Launch Services would keep what it read of the replaced bundle) with the system's answer and the account the process runs as, and a registration that fails with registering <bundle> with Launch Services and what lsregister printed; the bundle stays placed and is not restarted.

A desktop surface's build command runs with a directory first on its PATH whose swift is Stado itself (<build area>/swift-without-keychain/swift), with the real one named in STADO_REAL_SWIFT (xcrun --find swift). It runs that swift with --disable-keychain added to build, test, run and package. SwiftPM otherwise reads the login keychain for every package host it fetches from, and a build run in the background, with nobody to answer macOS's access question, waited on that read for hours, holding the installation and the service that ran it; without it, git fetches through its own credential helper. A process started as swift outside such a build is refused: stado: started as swift without STADO_REAL_SWIFT, which only a desktop build Stado runs sets; run the real swift instead.

A desktop build command also runs with WISENT_WORKSPACE naming the installation's workspace and, inside an installation, STADO_CREATES_CHECKOUTS=installation. A build script that runs Stado itself (stado product swift build) then reads its checkout in that workspace and creates the canonical checkout of a Swift package dependency the workspace lacks, as the installation creates its own source's; without them it refused the checkout as not canonical, or a dependency with no canonical checkout answers for <repository> in <workspace>; no checkout was created. Any other value is refused: STADO_CREATES_CHECKOUTS is <value>; only an installation's build sets it, to installation``.

An installation that would change a file another surface of this machine owns names the owner: installation would change <path>, which <product>/<surface> (<state>) still owns; finish, install or remove that surface first. Another surface of the same product installed from the same recipe kind, repository and manifest is not such an owner when its state is installed, rolled_back or superseded (an interrupted installation another one replaced): the installation replaces the files both place and re-records that surface's receipt with the new fingerprints and source revision.

Stado Desktop's Products screen offers Restart beside each installed application bundle of a product's desktop surface (each .app path the installation state names). It asks for confirmation, showing the command, and runs stado product restart <bundle> --if-running --json: a running application is restarted from that bundle and a stopped one is left stopped, and the answer names the state, bundle and bundle identifier the command printed. A refusal is shown as the command wrote it.

How a desktop app updates itself

A Wisent Mac app updates through Sparkle, from the feed Stado serves at /api/release/appcast?product=<product>. What decides whether an installed copy can ever take a release is checked where it is made:

  • One owner per copy. Every build stamps its bundle with stado product sparkle stamp --info-plist <bundle>/Contents/Info.plist [--feed <feed>]: CFBundleVersion is always CFBundleShortVersionString, because Sparkle compares it with the appcast's sparkle:version (a copy carrying 4 reads as newer than every 0.x release and is never offered one), and SUFeedURL is the feed only when the build is handed one. Only a release passes --feed, so only a copy installed from a release updates itself through Sparkle; a source build, the one Stado's sync installs, has an empty feed and its updater stays off, and Stado alone updates it (stado product update, or a notify rule and stado product available). The step refuses a feed that is not HTTPS and a plist with no CFBundleShortVersionString, and prints the version, the feed and whether the updater is on.
  • Old copies come back on their own. A copy built before every build stamped its bundle this way still carries a feed and a bundle number of its own, and being current with origin/main it would never be built again. Sync reads each .app a source-built desktop surface installed; one whose SUFeedURL is not empty, or whose CFBundleVersion is not its CFBundleShortVersionString, is installed again as the surface's update rule allows (a held rule still holds it), the row's note naming the bundle and what it carries.
  • The feed address. A release resolves its feed with stado web origin url /api/release/appcast --query product=<product>. That command asks a public resolver first and refuses an origin whose name has no public A or AAAA record (dns_unresolved, naming the origin, its name, stado web origin converge <name> --apply and declaring the path on a web-edge origin) and one it cannot check (dns_unavailable, as infra_down), so no release stamps a feed a client outside the fleet could never reach. The release refuses a bundle whose SUFeedURL is not that feed.
  • The signing key. stado credentials signing key mint <product> --info-plist App/Info.plist --host <vault owner> makes the Ed25519 pair, stores it as the item playing role <product>-sparkle in the vault owner's vault, where a release's secret_env reads it, and writes the public half into the plist as SUPublicEDKey. With Skarbiec as the credential store a mint without --host is refused (vault-owner-unnamed) before anything is made, and a role that already holds a key is refused without --replace, because copies installed with the old key accept only updates it signs.
  • The update contract. stado release gate app updates --info-plist App/Info.plist, a release step every desktop app declares (app check runs the same contract), refuses an Info.plist with a CFBundleVersion of its own, no SUFeedURL key or one carrying an address in source, no SUPublicEDKey (stado credentials signing key mint <product> --info-plist <path>), SURequireSignedFeed not true, or a SUScheduledCheckInterval (a schedule in source with no declaration behind it: Sparkle checks on its own schedule, and the menu's Check for Updates Automatically decides whether it checks at all), naming every problem at once. A plist with no LSMinimumSystemVersion is refused as not a Mac app's.
  • The appcast. stado product sparkle appcast --name <App> --version <V> --archive-url <URL> --signature <archive>.sparkle-signature --info-plist <bundle Info.plist> --published <feed URL> --output appcast.xml [--notes <file>] [--critical] writes the release's item (the minimum macOS read from the bundle, the archive's HTTPS address and the line sign_update printed, the notes as plain text, <sparkle:criticalUpdate> for a security release) and then every item of the feed the channel serves now, read through the one wait mechanism; a 404 makes this release the feed's first, and a rebuilt release replaces its own earlier item. It refuses a bundle whose CFBundleVersion is not the release version, a signature line sign_update did not print, a non-HTTPS archive address, a feed that answers neither 200 nor 404 (with its status and body) and one that answers 200 without an appcast. The release then signs the appcast with Sparkle's sign_update.
  • Rolling out in groups. --phased-rollout-interval SECONDS on stado product sparkle appcast writes <sparkle:phasedRolloutInterval> into the release's item, and every item carries the <pubDate> Sparkle counts from: copies checking on their own take the release one group per interval, so a fault reaches a few copies before all of them. The interval is the app's own release declaration, which its release script passes; without it every copy is offered the release at once. A person's own Check for Updates… is never held back, and a --critical release is offered to everyone.

In each app's menu, the shared wisent-desktop-update library offers Check for Updates…, Check for Updates Automatically, Install Updates Automatically, and one line saying how the last check ended: up to date, a version available, offline (no network, not a failure of the channel) or a failed check with its cause and feed address.

Source: this website