# Oficina Mac updates

Meet, Calendar, Memoria, Docs and Sheets share `RastrilloMacUpdates`, a macOS-only
Sparkle 2.9.6 integration. Each app owns one updater and puts Check for Updates
and the automatic-check preference in its application menu. By default Sparkle asks
before enabling automatic checks, and installation uses the standard
confirmation/relaunch flow and the app's normal quit handling. iOS uses Apple's
distribution mechanisms.

## Quiet updates (opt-in)

An app that must never be interrupted (Calls, during a call) opts into quiet
updates in two places, and needs both:

- `NativeUpdater(policy: .quiet)` in code. A scheduled check never opens a window.
  A staged update waits for quit, and `pendingUpdate` becomes `.ready`. The app
  menu gains "Update Ready — Restart <App>", and `NativeUpdateNotice` is a one-line
  notice for the app's home screen. If the person has turned automatic downloads
  off, a found update becomes `.available` and the notice offers it. Set
  `isBusy` while the app must not be disturbed. Scheduled checks are then declined
  and rerun once it clears, and the restart action is disabled. A declined check
  also means no download during a call. Check for Updates always shows Sparkle's
  standard UI, because the person asked for it. It is disabled while a staged
  update waits: the Restart item replaces it.
- `"install": "automatic"` in `mac-updates.json`. Packaging then writes
  `SUAllowsAutomaticUpdates`, `SUEnableAutomaticChecks`, `SUAutomaticallyUpdate`
  and a daily `SUScheduledCheckInterval` into Info.plist. There is no first-launch
  permission question. These are defaults, so a person's own choice (the menu
  toggle, or `automaticallyInstallsUpdates` in the app's settings) is stored in
  their preferences and wins. The default, `"prompt"`, is the behaviour above,
  and release and publication reject an archive whose Info.plist disagrees with
  its configuration's mode.

The installed build decides how an update arrives. A person running a build
from before quiet updates sees Sparkle's usual prompts once more, for the update
that brings quiet updates.

The delegate methods are optional Objective-C selectors. A drifted Swift
signature still compiles but is never called, and the app is back to Sparkle's
alerts. `QuietUpdaterTests.testDelegateAnswersSparklesSelectors` pins them.

Unconfigured development builds disable update controls. A release must pass its
app-specific `native/mac-updates.json` to packaging. These files contain only the
public Ed25519 key, Keychain account name, bundle ID and HTTPS appcast URL. The
private Sparkle key stays in the login Keychain under the `oficina` account; it is
separate from Apple signing and notarization credentials. Keep that key for future
updates; rotating it requires a deliberate Sparkle key migration.

## Build and notarize

Use Team Tito Limited's Developer ID Application certificate, team `9U4A84C4WK`.
The existing `slopbox-notary` Keychain profile authenticated successfully during
setup; successful notarization of the actual release is the final validation.
One team profile is reusable across the five apps. No credentials belong in Git.

For the four standard apps, from their `native` directory:

```sh
scripts/build-mac.sh --configuration release --updates-config mac-updates.json \
  --version 0.1.0 --build 2 \
  --sign-identity 'Developer ID Application: Team Tito Limited (9U4A84C4WK)'
```

Meet uses `OFICINA_BUILD_CONFIGURATION=release` instead of `--configuration`.
`OFICINA_NATIVE_PACKAGE_PATH` can select an unpublished local shared-library
checkout for development; omit it to use the reviewed pinned dependency.

`tools/release_mac.py` takes the built app, `--config`, `--sparkle-tools` (the
Sparkle SwiftPM artifact's `bin` directory), `--identity`, `--team`,
`--notary-profile` and a new `--output` directory. It copies the app, signs helpers
and frameworks before the host, verifies the selected Developer ID team,
notarizes and staples the app, checks Gatekeeper, then creates and EdDSA-signs the
final ZIP. Meet receives camera and audio-input hardened-runtime entitlements.
The result contains the ZIP and a `release.json` audit manifest. Nothing is
installed or published by preparation.

The initial binaries built here target Apple silicon. `generate_appcast` reads
minimum macOS and architecture requirements from the archive, so incompatible
machines are not offered these updates. Docs and Sheets require macOS 26;
Calendar, Memoria and Meet require macOS 14.

## Amadan hosting

Use a dedicated **public, artifacts-only** repository `oficina/native-releases`.
Do not change the visibility of any application source repository. Initialize its
`main` branch and clone it before publishing. The proposed feeds are:

```
https://raw.amadan.net/oficina/native-releases/main/<app>/appcast.xml
```

Git holds the feeds and nothing else. Each archive is an Amadan release asset,
stored in object storage, on a release tagged `<app>-<build>`:

```
https://amadan.net/oficina/native-releases/-/releases/<app>-<build>/<archive>.zip
```

That URL redirects to a short-lived signed download, which Sparkle follows.
Never commit an archive: every zip in Git stays in history for good and is
copied by every clone. On 2026-10-26 a repack of about 1.1 GB of them took
amadan.net down for ten minutes (`oficina/native-releases#1`). Assets can be up
to 500 MB each, and `release_mac.py` still caps archives at 100 MiB.

Run `tools/publish_mac_update.py RELEASE --config CONFIG --repo ARTIFACT_CLONE
--sparkle-tools TOOLS` to validate and print a plan. Add `--publish` only when the
concrete release is ready to publish. It verifies the archive signature, actual
archived app metadata, manifest, Keychain public key, monotonic build number,
clean main checkout and correct origin. It pushes the release tag and uploads the
archive with `amadan release create`. Then it downloads the archive anonymously
and checks its length and SHA-256. Only after that does it generate and push the
appcast, using Sparkle's official tool. The generated version must match the
signed app. Existing feed items are preserved; deltas are disabled to keep
upload sizes bounded.

`amadan release create` needs writer access to the artifact repository. Set
`AMADAN_HOME` to the publishing account's credentials (an agent publishes as its
agent account), and use a Git credential for that same account to push the tag
and feed.

If publication stops before the feed commit, the old feed stays usable. Rerun the
same command. The tag is reused, and an asset cannot be replaced, so the second
upload is refused. The publisher accepts that refusal only when the public
download already matches the signed archive, and then publishes the feed. A
different archive for a build already uploaded needs a new build number. If it
stopped while writing the feed, before its commit, discard the half-written feed
and rerun. The publisher only starts on a clean checkout, so this removes nothing
else: `git reset --hard HEAD`, then `git clean -f -- <app>/appcast.xml` for an
app's first feed. If only the final feed push failed, the feed commit is already
verified. Push it with `git push origin HEAD:refs/heads/main`, and do not rerun,
because a rerun refuses a build the local feed already lists.

The hub does not yet make an asset's bytes atomic against two simultaneous
uploads of the same name. Only one publisher may run for a build at a time.
Sparkle still checks each download's EdDSA signature, so replaced bytes fail
to install rather than installing.

Older feed items point at zips committed before this change, by full commit SHA
on `raw.amadan.net`. They keep working. Removing those zips from history is a
separate decision, for the repository's owner.

Users of pre-Sparkle builds need one initial installation of a Sparkle-enabled
release. Subsequent releases can update in place. The first publication should
include a real update/relaunch check before broad distribution.

## Validation

`make ci` includes Swift tests, Mac/iOS companion builds, Go race tests, relocated
resource packaging and release-boundary tests. Publication tests check that
metadata tampering, archive tampering, failed public download, a refused upload
without a matching asset and generated version mismatch cannot publish a feed.
They also check that no archive is ever added to Git, and that the archive is
uploaded and verified between the tag push and the feed push. Independent mutation probes confirmed these
checks detect removal of the corresponding guards.
