Skip to content

Releases and Auto Updates

Pablo Martínez López edited this page Nov 10, 2026 · 11 revisions

Releases and Auto-Updates

English · Español

How a version is cut (pnpm release), what the release workflow builds (desktop installers and the Android APK), how they are signed, and how the installed app finds, downloads and installs a newer version. The updater lives in Rust (src-tauri/src/actualizar.rs, commands/actualizar.rs); the frontend only drives it and shows progress.

Overview

sequenceDiagram
    participant M as Maintainer
    participant S as scripts/release.mjs
    participant GH as GitHub (git, Actions, Releases)
    participant A as Installed app

    M->>S: pnpm release patch
    S->>GH: bump 4 files, commit "Version X", push main
    GH-->>S: ci.yml green for that commit
    S->>GH: dispatch release.yml
    GH-->>GH: build + sign installers, upload to DRAFT release vX with latest.json
    GH-->>GH: android job (after the desktop legs): signed APK + its .sig into the same draft
    S->>GH: set notes on draft and in latest.json, add the android-aarch64 entry
    M->>S: confirm publish
    S->>GH: gh release edit --draft=false --latest (creates tag vX)
    A->>GH: GET releases/latest/download/latest.json
    A->>A: compare versions, download package, verify minisign signature
    A->>A: install and restart
Loading

Key decisions and why:

  • Releases are cut by scripts/release.mjs and the release workflow is manual-only. A tag trigger rebuilt everything when the draft was published (publishing creates the tag), so the workflow runs only via workflow_dispatch.
  • Drafts do not count for the updater. The app reads releases/latest/download/latest.json; GitHub's "latest" ignores drafts, so installed apps only see a version once the draft is published.
  • No OS code signing (SmartScreen and Gatekeeper will warn; see User Guide). The signature that matters here is the updater's minisign signature.
  • The Android APK is part of the same release. It is built by the android job of the same workflow into the same draft, and listed in the same latest.json under android-aarch64. Who writes what in latest.json is deliberate; see "The Android APK" below.

The release script

scripts/release.mjs (run as pnpm release <patch|minor|major|X.Y.Z> [options], needs git and an authenticated gh). Options (parsed by leerArgs in scripts/release-notas.mjs):

Option Effect
--dry Run the checks and print what would happen; problems become warnings instead of aborting
--publicar Publish the draft without asking
--notas "text" Release notes inline
--notas-fichero PATH Release notes from a Markdown file
--sin-notas Only the fixed text

Only one of the three notes options may be given. With none, the script writes a template to tmp/release-notas-v<version>.md (the template is a comment containing the commit list since the last tag) and opens $VISUAL, $EDITOR, nano/vi, or notepad on Windows; empty notes abort before anything is touched.

Steps, in order:

  1. Checks: gh auth status; the four version locations agree; the new version differs and is X.Y.Z; branch is main; working tree clean; no commits behind or ahead of origin/main; tag vX.Y.Z and a GitHub release of that tag do not exist.
  2. Notes (as above).
  3. Bump the version in four places: package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, src-tauri/Cargo.lock (the proxy-builder package entry). versionActual() refuses to start if they disagree.
  4. Commit Version X.Y.Z and push main.
  5. Wait for the CI run of that commit (ci.yml) and fail if it fails (gh run watch --exit-status, output streamed so the terminal does not look hung).
  6. Dispatch release.yml on main and wait (roughly ten minutes). If the run fails, esperarEjecucion (with toleraAndroid) reads its jobs (gh run view --json jobs) and classifies them with fallosDeRelease (scripts/release-notas.mjs, pure and tested: failed, cancelled or timed-out jobs, split into the one named android and the desktop ones). If only android failed (typically the ANDROID_KEY_* secrets are missing) it warns and carries on without the APK: latest.json gets no android-aarch64 entry, so phones do not see this version, and a loose .apk left in the draft should be deleted before publishing. If any desktop job failed it stops as before, naming those jobs. Why: the desktop installers are the main release and should not wait for the phone; a release with a broken desktop build must never go out.
  7. Validate the draft: it must be a draft, its targetCommitish must equal the pushed commit, if the android job passed it must contain the APK (Tapete_<version>_android-arm64.apk) and its .sig (otherwise the ANDROID_KEY_* secrets are probably missing) and a latest.json asset (otherwise the signing secret is probably missing).
  8. Put the notes on the draft (componer(notas) appends the fixed text) and rewrite latest.json: its notes with the real notes (notasDelActualizador) and, when the android job passed, the Android entry (conAndroid: key android-aarch64, the download URL of the APK in this release and the contents of its .sig), then re-upload it with --clobber. The workflow's own latest.json carries the fixed text, which means nothing inside the app.
  9. Ask to publish (s/si/y/yes), or publish directly with --publicar. Publishing: gh release edit vX --draft=false --latest, which creates the tag. If you decline, the script prints the command to publish later.

Helpers in scripts/release-notas.mjs (unit-tested by scripts/__tests__/release-notas.test.mjs, which needs neither git nor gh): leerArgs, limpiarNotas (strips <!-- --> comments and collapses blank lines), plantilla, componer (notes plus TEXTO_FIJO, the text release.yml also sets), notasDelActualizador (throws if the JSON is not for the expected version or has no platforms), nombreApk(version) (the APK name; it must match the name release.yml gives the APK, because the android-aarch64 URL in latest.json is built from it) and conAndroid(json, version, repo, firma) (throws if the JSON is not that version's or the signature is empty or not base64; leaves everything already there untouched).

Resuming a cut release: Inferred: because every step after the push is a separate gh operation, an interrupted release can be finished by hand from the draft (gh release edit, uploading a corrected latest.json); the script has no resume flag. The lockfile must be in the same commit as the packageManager change, per the repository's release practice.

Release notes are written in English ("What's changed since X").

The release workflow

.github/workflows/release.yml; the build job is a matrix over four legs (fail-fast: false), followed by the android job:

Leg Produces
ubuntu-22.04 .deb, .rpm, AppImage (Tauri bundle targets all)
windows-latest NSIS and MSI installers
macos-latest with --target aarch64-apple-darwin Apple Silicon .app bundle (and DMG)
macos-latest with --target x86_64-apple-darwin Intel .app bundle (and DMG)

tauri-apps/tauri-action@v0 builds each leg and uploads to a draft release named Tapete v__VERSION__ (body: the fixed text "Instaladores de Tapete (https://tapete.zodhub.app). Sin firma de código: ver el README.", the same string as TEXTO_FIJO) with tag v__VERSION__. Environment: GITHUB_TOKEN, TAURI_SIGNING_PRIVATE_KEY, TAURI_SIGNING_PRIVATE_KEY_PASSWORD (the key has no password; the secret may be empty). Without the private key secret tauri build fails, because bundle.createUpdaterArtifacts is true.

The Android APK

The android job runs on ubuntu-24.04 and needs: build: the draft release is created by tauri-action in build, and the APK is uploaded to it, so the job has to wait for it. Steps: Java 21 (Temurin), the Android SDK (android-actions/setup-android) with platform 37, build-tools 36.0.0 and the stable NDK 28.2 installed through sdkmanager (NDK_HOME set from it), Node, Rust with the aarch64-linux-android target, pnpm install --frozen-lockfile, then:

  1. The signing key. The secrets ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD and ANDROID_KEY_BASE64 (the .jks in base64) are written as keystore.properties in src-tauri/gen/android/, read by app/build.gradle.kts (one password for both store and key). If any is missing the job fails on purpose before compiling (an unsigned APK is not installable). Without the file, a local build still works but produces app-universal-release-unsigned.apk. A last step (if: always()) deletes the key and the properties file.
  2. Build: pnpm tauri android build --apk --target aarch64 --ci with CARGO_PROFILE_RELEASE_STRIP=symbols set only on this step. Why: without it the Rust library weighs about 100 MB and the arm64 release APK about 102 MB; stripped symbols bring the library to about 72 MB, and the APK stores it uncompressed, so the APK shrinks by about the same 28 MB. Desktop binaries are not affected (the variable is set on this step only). Only aarch64 is built for releases (x86_64 is for the emulator).
  3. Name, sign and check. There must be exactly one release APK, not *unsigned*; it is copied to Tapete_<version>_android-arm64.apk; apksigner verify must pass; aapt dump badging must show a versionCode of major*1000000 + minor*1000 + patch (what Tauri derives from the version; it has to grow with each release or Android refuses to update); and the updater signature is made with pnpm tauri signer sign --app-version <version> using the same updater key as the desktop installers, giving the .sig. --app-version is needed because requireSignedVersion expects the version inside the signature.
  4. Upload the APK and the .sig to the draft with gh release upload --clobber. It does not touch latest.json.

Why the job does not edit latest.json: the desktop legs of build rewrite that file in parallel (each merges its entries) and a fourth writer would overwrite or be overwritten. Instead scripts/release.mjs adds android-aarch64 at the end, once every job has finished, from the .sig it downloads.

The expected files in the draft are now 19: nine installers and bundles, seven .sig, latest.json, plus the APK and its .sig (it was 17 before Android). If a job fails because of the network, re-run only the failed jobs of that run and publish with all five jobs green and the draft complete.

Desktop installers and latest.json

With createUpdaterArtifacts, each installer gets a minisign .sig file, and tauri-action merges one entry per installer into latest.json, with keys like linux-x86_64-deb, windows-x86_64-nsis, darwin-aarch64-app (named in the workflow comment). The updater picks the entry that matches the installer type the running binary came from.

From Proxy Builder to Tapete (4.0.0)

From 4.0.0 the visible name of the app is Tapete: productName and the window titles (tauri.conf.json, tauri.android.conf.json), the Android app_name (res/values/strings.xml), index.html, the strings of the interface that name the app, the release title and text, the APK file name, the PDF metadata, the .pbdeck filter name in file dialogs, the exported identity file name (identidad-tapete.json) and the HTTP User-Agent (tls::AGENTE, see Rust-App-Layer).

The technical identity does not change, on purpose: it is what keeps an installed copy updating over itself and finding its data.

Kept as it was Why
identifier com.estigio.proxybuilder The data folders, the Android application id and the Windows registry keys derive from it; changing it would start every user with an empty library, and Android would refuse to install the APK over the old one
Data folders, log file proxy-builder.log, localStorage keys proxybuilder.* Same data for the old and the new name
ALPN proxy-builder/*/1, codes PB1- / PBD1- A 3.x and a 4.0 copy keep playing and passing decks to each other
.pbdeck and its manifest format; identity file type proxy-builder-identidad Files written by older versions still open, and older versions still open new files
proxybuilder:// links (still accepted; the app now shares the web links https://tapete.zodhub.app/join/…, and tapete:// is accepted since 3.6.0) Links already sent keep opening the app, and a 3.5 or earlier copy only understands proxybuilder://
Crate and package names, executable proxy-builder, packs repository proxy-builder-packs, Android Keystore alias Internal names nobody sees; the Keystore alias seals the identity key, and a new alias could not open it

Because productName changes, the installers would otherwise leave two apps installed side by side (the install folder, the Windows "Uninstall" entry and the Linux package name all come from it). Each installer therefore replaces the old one:

  • .deb: bundle.linux.deb declares conflicts, replaces and provides: proxy-builder, so dpkg/apt removes the old package when the new one goes in.
  • .rpm: bundle.linux.rpm declares conflicts and obsoletes: proxy-builder.
  • Windows (NSIS): bundle.windows.nsis.installerHooks points to src-tauri/windows/hooks.nsh. Its NSIS_HOOK_PREINSTALL looks up the old "Proxy Builder" entry under HKCU (Tauri's default currentUser mode; the install folder is read from Software\<publisher>\Proxy Builder, the publisher coming from the identifier) and, if it exists in a folder other than the new one, runs its uninstall.exe with /S _?=<folder> and then deletes uninstall.exe and the folder.
    • PREINSTALL, not later: the old uninstaller deletes the registry entries of the links (proxybuilder://, tapete://); the new installer writes them afterwards, so they survive.
    • /S: silent, so the "delete the app data" checkbox stays unticked. The data is shared by both names because it derives from the unchanged identifier. Without /UPDATE, so the old shortcuts with the old name go too.
    • _?=: runs the uninstaller in place so the installer can wait for it; that is why uninstall.exe and the folder are removed by hand afterwards.
  • AppImage and macOS: nothing to declare. Inferred: the updater writes the new bundle where the old one was, so there is no second copy.

These installer rules have not yet been tried on real systems; the first 4.0.0 install on each one is the test.

Signing model

  • A private minisign key signs every bundle at build time. It lives only on the maintainer's machine and in the repository secret TAURI_SIGNING_PRIVATE_KEY.
  • The matching public key is embedded in src-tauri/tauri.conf.json at plugins.updater.pubkey (base64 of the minisign public key file).
  • Consequence: if the private key is lost, installed versions will reject every future update, because they only trust that public key. Treat it as irreplaceable and back it up.
  • The Android signing key is a second, separate secret and just as irreplaceable. Android only installs an update if it is signed with the same key as the installed app; with another key the install fails (INSTALL_FAILED_UPDATE_INCOMPATIBLE) and the only way out is uninstalling, which deletes the app's data (the library). It lives in a keystore kept by the maintainer outside the repository and in the repository secrets ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD and ANDROID_KEY_BASE64. Never replace it; keep copies in two places. The APK therefore carries two signatures with different jobs: Android's (who may update it) and the updater's minisign .sig (a second barrier checked by the app before handing the file to the system).
  • requireSignedVersion: true makes the signature embed the version (written by tauri build). An attacker therefore cannot announce a newer version while serving an older signed package.
  • The Rust test la_configuracion_del_actualizador_es_valida (src-tauri/src/actualizar.rs) pins these settings, see Testing and CI.

Updater configuration

From src-tauri/tauri.conf.json:

Key Value
bundle.createUpdaterArtifacts true
plugins.updater.endpoints 1. https://github.com/PoolFlamingo/tapete/releases/latest/download/latest.json 2. https://github.com/PoolFlamingo/proxy-builder/releases/latest/download/latest.json
plugins.updater.pubkey minisign public key (base64)
plugins.updater.requireSignedVersion true
plugins.updater.windows.installMode passive
bundle.android.minSdkVersion (tauri.android.conf.json) 26

Two endpoints, in this order. The repository is going to be renamed for the app's new name (Tapete). The updater plugin tries the endpoints in order and moves to the next one when a URL answers with an error status, so: before the rename the new URL gives 404 and the old one answers; after the rename the new one answers directly (and the old one would still work, because GitHub redirects a renamed repository). Either way the copies installed today reach the versions published under the new name. The test la_configuracion_del_actualizador_es_valida pins both URLs in this order. PAGINA_DE_RELEASES (actualizar.rs, opened by update_open_release) keeps the old repository URL on purpose: it is a page in the browser, not a download, and GitHub's redirect covers it.

The plugin is registered in src-tauri/src/lib.rs (tauri_plugin_updater::Builder::new().build()) and the Actualizador state is .managed there. It is not exposed to the webview: src-tauri/capabilities/default.json has no updater permission, so the page cannot call the plugin, only the four app commands. This follows the rule that all network work is done by Rust (Architecture).

Rust side

Module src-tauri/src/actualizar.rs

Item Role
Actualizador Managed state: Mutex<Option<Pendiente>>, where Pendiente { update: Update, paquete: Option<Paquete> } holds the found update and, once downloaded, the package: Paquete::Memoria(Vec<u8>) on desktop (kept in memory until installed) or Paquete::Apk(PathBuf) on Android (the APK written in app_cache_dir/actualizacion/: it weighs tens of MB and the system installer reads it from a file)
estado(app) → ActualizacionEstado { soportada, actual, instalaElSistema } soportada() is `!cfg!(debug_assertions) && (bundle_type().is_some()
buscar(app, estado) → Option<ActualizacionInfo> Builds the updater (with an on_before_exit hook that saves the window state), on Android sets the target android-<arch> (std::env::consts::ARCH, so android-aarch64), calls check(), stores the pending update; when already up to date on Android it deletes a previously downloaded APK. Returns ErrorCode::Unsupported if unsupported. ActualizacionInfo { version, actual, notas, fecha, pide_permiso }; pide_permiso is true when the bundle type is Deb or Rpm
bajar(estado, cache, version, al_avanzar) Downloads the package for that exact version (Actualizador::update(version) returns NotFound if a later check replaced it) while reporting (hecho, total); the plugin verifies the signature during download. On Android the bytes are then written to app_cache_dir/actualizacion/Tapete_<version>.apk (ruta_apk) after emptying that folder (only one APK fits)
instalar(app, estado, version) Takes the package, runs update.install inside spawn_blocking (a .deb/.rpm install waits for the pkexec password), restores the pending update on failure so the user can retry (e.g. cancelled password), and on success calls app.request_restart() so exit events run and the window-state plugin saves the window
instalar_android (private) Android's instalar: looks for the downloaded APK of that version, checks the minisign signature again (update.verify) and hands it to android::instalar_apk. No restart. The package stays if the person cancels the system installer, so they can press Install again. If the system does not yet let the app install packages, the plugin opens the "install unknown apps" setting of the app and Rust answers Unsupported with motivo: instalar_permiso: the person allows it, comes back and presses Install again
error(e) Maps plugin errors: network errors to Network; ReleaseNotFound/TargetNotFound/TargetsNotFound to NotFound (no latest.json on the latest release, or no package for this installer type); AuthenticationFailed to Cancelled; Io to Io; the rest to Internal

Install method per installer type: the module documents that it downloads the package of the installer the app was installed with (.deb, .rpm, AppImage, NSIS, MSI or .app) and verifies its signature. .deb/.rpm are installed through pkexec (system password prompt; pide_permiso), and on Windows the installer closes the app itself and relaunches it (hence the on_before_exit window-state save). Inferred: AppImage and macOS .app are replaced in place by tauri-plugin-updater; the exact mechanics live inside that plugin, not in this repository. On Android the plugin looks up and downloads (with the signature checked) but does not install: that is the system installer's job, as above.

Testing the updater in an emulator: actualizar-local

The only Cargo feature of the app crate. With it, the updater reads the latest.json at the URL in PB_ACTUALIZAR_URL (at run time or, failing that, the value present when compiling) instead of GitHub's, so an emulator (x86_64, hence the android-x86_64 target) can update from a file served on the development machine. 🛑 It is never turned on by default, by CI, by the workflow or by package.json: a test (la_feature_de_pruebas_no_se_activa_nunca_sola) reads Cargo.toml, ci.yml, release.yml, package.json and tauri.conf.json and fails if the feature or a default = [ appears. Without it an update URL cannot be changed in a release build.

Commands src-tauri/src/commands/actualizar.rs

Command Behaviour
update_status Returns ActualizacionEstado
update_check Returns Option<ActualizacionInfo>
update_download(version) Emits update://progress with ActualizacionProgressEvent { hecho, total } (total is 0 when the server does not say)
update_install(version) Busy guards, both before doing anything. First the game: if AppState::hay_partida() (a session in AppState.mesa, even if the user is on another tab) it returns Busy with params.motivo = "partida", which src/lib/errores.ts (MOTIVOS.Busy.partida) shows as «Hay una partida abierta: termínala o sal de la mesa para actualizar.». Then AppState::ocupado(): true while any TrabajoEnMarcha guard from AppState::trabajo() is alive (export, resolving a list, OCR batch, design batch, downloads, game preparation; see commands/export.rs, cards.rs, identify.rs, diseno.rs, fuentes.rs, mesa.rs, red.rs) or a game is open; that Busy has no motivo and shows the general «Hay un trabajo en marcha; espera a que termine.». Test con_partida_la_app_esta_ocupada. Why: restarting in the middle of a PDF export would lose it, and in the middle of a game would close it under the player (it is saved, but the session ends)

The four commands are registered in src-tauri/src/lib.rs and typed in src/lib/ipc.ts (update_status, update_check, update_download, update_install; event update://progress). ActualizacionInfo, ActualizacionEstado and ActualizacionProgressEvent are exported to src/bindings/ by ts-rs.

Frontend side

stateDiagram-v2
    [*] --> inactiva
    inactiva --> buscando: buscar()
    buscando --> alDia: no update
    buscando --> disponible: found
    buscando --> inactiva: error (no info)
    disponible --> bajando: bajar()
    bajando --> lista: package downloaded
    bajando --> disponible: error
    lista --> instalando: instalar()
    instalando --> lista: error (retry)
    instalando --> [*]: app restarts
Loading

(alDia is the step "al-dia".)

src/stores/actualizacion-store.ts (useActualizacion)

State: soportada: boolean | null, actual, paso ("inactiva" | "buscando" | "al-dia" | "disponible" | "bajando" | "lista" | "instalando"), info, progreso (0-100 or null), error (a raw AppError, translated by mensaje).

Action Details
iniciar() Asks update_status once and caches; any failure means unsupported
buscar() No-op while busy (buscando, bajando, instalando); if already lista, returns the info without searching again (a downloaded package is kept until restart); otherwise calls update_check
bajar() Only from disponible. Subscribes to update://progress before calling update_download, converts to a percentage, and unsubscribes in finally
instalar() Only from lista. Calls guardarAhora() (flush the deck autosave) then update_install. With the table open it only sets alSalirDeLaMesa and returns (see "Updating with a game open" below). If it returns after update_install, it failed: the step returns to lista and the error is thrown. On success the app restarts and this never returns

src/features/shell/actualizacion-automatica.tsx

Export Behaviour
useActualizacionAutomatica() Mounted once in src/App.tsx. If the actualizarSola preference is on (default true, in ui-store), waits 8 s after start (PRIMERA_MS, so the app is fully up), then runs a round (iniciar, buscar, bajar) and repeats every 6 h (CADA_MS). No network or no release means silence
useAvisosActualizacion() Mounted once in App. Subscribes to the store and shows a toast with id actualizacion: a loading toast with the percentage while downloading, a persistent success toast with a "Reiniciar" action when ready (text differs when a password will be asked), and an error toast if the download fails. Works for automatic and manual downloads
BotonActualizar() Top-bar button visible only when a version is lista or instalando
PuntoActualizacion() Dot on the Settings button: amber and pulsing while downloading, accent colour when ready
useAvisoAjustes() Tooltip text for the Settings button

Android

The same store and screens serve Android, with instalaElSistema changing the words and one step: "Install" instead of "Restart" (toast action, Settings button), and no restart, so after update_install returns the step goes back to lista (the system installer is on screen; if the person cancels it they can press Install again). The first time, Unsupported with instalar_permiso shows its own sentence ("Android needs your permission to install the update: its setting has been opened. Allow installing apps from Proxy Builder, come back and press Install again"). Installing with the game table open still waits until the table is left (see below); Rust also answers Busy there.

The flow, end to end: check (the target android-aarch64 of latest.json) → download to app_cache_dir/actualizacion/ with the minisign signature checked → Install → signature checked again → system installer (after the "install unknown apps" setting the first time) → Android verifies that the signing key and a higher versionCode match → the app is replaced and relaunched. Android requires the same signing key and a larger versionCode; the minisign signature is a second barrier. Library, lists, history and network identity survive an update (they live in the app's data folder).

Interaction with closing the window

Closing the window while an update is bajando or instalando asks for confirmation (ConfirmarCierre, reason "An update is being downloaded or installed."; an update that is only available or already lista does not count). In the other direction, the close guard does not block updating: "Restart and update" saves the deck itself and restarts from Rust with request_restart, which does not go through the window close request. Inferred: from Tauri's behaviour; not exercised end to end. See Settings and Shell.

Updating with a game open

The install does not refuse and give up: it waits for the player to leave the table and then runs by itself.

  • instalar() does not call update_install while the table is open (useMesa.activa): it sets alSalirDeLaMesa and returns, with no error and no restart. useAvisosActualizacion toasts «Se instalará al salir de la mesa.» and BotonActualizar stays disabled, with that same text as its tooltip. The same happens when the screen did not know about the game and Rust refuses with Busy motivo: partida: the store takes that refusal as «wait» (esperaALaPartida), not as a failure.
  • When the table closes (useMesa.activa goes from true to false: salir() in features/mesa/conexion.ts after mesa_salir, both «Dejarla para luego» and «Terminarla», or the host closing the game for a guest), the same hook calls instalar() again: it saves the deck, installs and restarts. If that fails it is toasted like any other install error.
  • Checking and downloading are not blocked: with auto-update on, the new version is downloaded during the game and only the install waits.
  • The Rust guard stays as a safety net (update_install still returns Busy with motivo: partida, test con_partida_la_app_esta_ocupada): the app never restarts under a game, even if the screen asked at the wrong time.
  • A replay opened from the history also lives in AppState.mesa, so it also holds the install back. Watching an online game as a spectator lives in AppState.mirada, which hay_partida() does not look at, so it does not.
  • Inferred: a game that has just ended but is still on the table keeps the install waiting, because the table only closes when the player leaves it.

src/features/settings/actualizaciones.tsx

Actualizaciones (card in the Settings dialog): "Buscar ahora" (manual check; if actualizarSola is on it also downloads), the auto-update switch, a status line, "Descargar la X" when disponible, "Reiniciar y actualizar" when lista, a progress bar, the translated error, and the release notes in a ScrollArea. If soportada === false it explains that updates exist only in the installed app. ReiniciarParaActualizar repeats the restart button in the dialog header. Tested by src/stores/__tests__/actualizacion-store.test.ts.

Operational checklist and gotchas

Situation What to know
First updater-enabled version Versions before 2.4.0 have no updater; users install 2.4.0 by hand once
latest.json missing from the draft Secrets TAURI_SIGNING_PRIVATE_KEY absent or the build failed; release.mjs aborts at step 7
Draft not published Installed apps see nothing (GitHub "latest" ignores drafts)
Update on Linux packages .deb/.rpm need pkexec and show the password prompt on restart
Updating during an export or with a game open update_install returns Busy (with a game, motivo: partida and its own message); with the table open the screen does not even ask: the install waits and runs when the player leaves the table (see "Updating with a game open" above, Game Table)
Dev builds soportada is false; Settings says updates are available only in the installed app
Changing the key or endpoint Breaks all installed copies; the guard test fails first
Four version files Keep them in sync; only release.mjs should change them
Files in the draft 19 expected (17 before Android); release.mjs aborts if the APK or its .sig is missing
Android key lost or changed Nobody can update over an installed app; reinstalling deletes the library. Keep the keystore and its password in two places
android job failing the first time It had only been run locally before: SDK package names, Gradle cache or the secrets are the usual causes. Re-run only the failed job and read its log first. pnpm release itself goes on without the APK when only that job fails (step 6)
Android and unknown sources The first update asks for "install unknown apps" for the app (Tapete from 4.0.0, Proxy Builder before); some manufacturers put the setting elsewhere, and Play Protect may say "app not recognised": Install anyway
Installing 4.0.0 over Proxy Builder The installers remove the old package or installation (see "From Proxy Builder to Tapete"); data, identity and links stay. Check it on each system the first time
Lockfile and packageManager Commit the lockfile with the packageManager it was generated by, or the release CI install (--frozen-lockfile) fails
Re-running failed jobs Transient network failures in matrix legs can be re-run in GitHub Actions; the draft collects whatever legs have uploaded

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Mobile · Móvil

Frontend · Frontend

Reference · Referencia

Clone this wiki locally