From 2e1e08304faddd43eba9c541c7d3acfd4cb01cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:48:06 +0200 Subject: [PATCH] feat(macos): rFirma se actualiza sola en macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2024: rFirma se actualiza sola en macOS - Canal propio `Channel::MacOs`: lee /macos/latest.json y exige su entrada darwin-aarch64 - El plugin updater también en macOS, con la misma pública minisign que Windows - La entrega empaqueta, firma y sirve el .app.tar.gz en /macos/ con su latest.json --- .github/workflows/build.yml | 8 +- .github/workflows/release.yml | 2 +- docs/adr/0015-canal-de-distribucion-propio.md | 3 +- justfile | 5 + packaging/macos/tauri.macos.json | 6 ++ packaging/repo/Caddyfile | 4 +- packaging/repo/README.md | 21 ++-- packaging/repo/build-tree.sh | 53 +++++++++- packaging/repo/build-tree.test.sh | 96 +++++++++++++++++++ packaging/windows/sign-updater.sh | 52 ++++++---- packaging/windows/sign-updater.test.sh | 39 ++++++-- rfirma-app/src-tauri/Cargo.toml | 4 +- .../src-tauri/src/desktop/adapters/channel.rs | 17 ++-- .../src/desktop/adapters/channel/tests.rs | 37 ++++++- .../src/desktop/adapters/installation.rs | 2 +- .../src/desktop/adapters/installer.rs | 23 +++-- .../src/desktop/adapters/releases.rs | 6 +- .../src/desktop/adapters/releases/tests.rs | 41 +++++++- .../src/desktop/application/status.rs | 2 +- .../src/desktop/application/status/tests.rs | 1 + .../src/desktop/application/version.rs | 13 ++- .../src/desktop/application/version/tests.rs | 56 +++++++++++ .../src-tauri/src/desktop/domain/channel.rs | 14 ++- .../src-tauri/src/site/application/trust.rs | 2 +- .../site/application/trust/tests/unmarked.rs | 5 + scripts/packages-manifest.sh | 4 +- scripts/tests/packages_manifest_test.sh | 20 +++- 27 files changed, 458 insertions(+), 78 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e2d2047c..b602895cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -265,11 +265,13 @@ jobs: - name: Empaqueta la .app y el .dmg run: just bundle - - name: Sube el .dmg + - name: Sube el .dmg y la actualizacion uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: bundle-macos - path: rfirma-app/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + path: | + rfirma-app/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + rfirma-app/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.app.tar.gz if-no-files-found: error retention-days: 1 @@ -317,7 +319,7 @@ jobs: name: bundle-windows path: ${{ runner.temp }}/paquetes - - name: Descarga el .dmg de macOS + - name: Descarga el .dmg y la actualizacion de macOS uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: bundle-macos diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fc63b6c2..d176d9219 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: packaging/verify-packages.sh paquetes --against "${{ runner.temp }}/SHA256SUMS.construccion" # Despues de comparar con la construccion y antes del `SHA256SUMS`, que cubre el `.sig` (ADR-0015). - - name: Firma de actualizacion de cada -setup.exe + - name: Firma de actualizacion de cada -setup.exe y .app.tar.gz if: ${{ env.IS_PRERELEASE != 'true' }} env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} diff --git a/docs/adr/0015-canal-de-distribucion-propio.md b/docs/adr/0015-canal-de-distribucion-propio.md index 5d75b2680..6aa8ee542 100644 --- a/docs/adr/0015-canal-de-distribucion-propio.md +++ b/docs/adr/0015-canal-de-distribucion-propio.md @@ -74,9 +74,10 @@ nunca. | `/apt/` | con `dists/stable/main/binary-amd64/` | | `/rpm/` | con `repodata/` | | `/windows/` | el `-setup.exe` y su `.sig` de cada versión, y `latest.json` | +| `/macos/` | el `.app.tar.gz` y su `.sig` de cada versión que lo trae, y `latest.json` | Estas rutas van dentro del `.flatpakref` y del `.flatpakrepo`, de las órdenes de alta publicadas y del *endpoint* -del *updater* embebido en cada instalación de Windows, así que se fijan aquí. +del *updater* embebido en cada instalación de Windows y de macOS, así que se fijan aquí. **apt con una sola suite**, no repositorio plano: el plano es más barato y **no admite `Suites:`/`Components:` en un fichero `.sources` deb822**, que es el formato obligado para diff --git a/justfile b/justfile index e2440a02f..8186f563d 100644 --- a/justfile +++ b/justfile @@ -608,6 +608,11 @@ bundle: check-native build-ts exit 1 fi echo "dmg: $dmg ($(du -h "$dmg" | cut -f1))" + # La actualización que baja el updater: la .app sola, sin firmar aquí (ADR-0015). + rm -f "$salida"/dmg/*.app.tar.gz + archivo="$salida/dmg/$(basename "$dmg" .dmg).app.tar.gz" + COPYFILE_DISABLE=1 tar -czf "$archivo" -C "$salida/macos" rfirma.app + echo "actualizacion: $archivo ($(du -h "$archivo" | cut -f1))" # Regenera cargo-sources.json y el sello de Cargo.lock. [group('release')] diff --git a/packaging/macos/tauri.macos.json b/packaging/macos/tauri.macos.json index 66e9cf7a9..793d95af0 100644 --- a/packaging/macos/tauri.macos.json +++ b/packaging/macos/tauri.macos.json @@ -10,5 +10,11 @@ "signingIdentity": "-", "hardenedRuntime": false } + }, + "plugins": { + "updater": { + "endpoints": ["https://rfirma.sgomez.me/macos/latest.json"], + "pubkey":"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0RjFGMDhEMEQyMUY4RjkKUldUNStDRU5qZkR4Ukx5VlNSZWR2aGl2VVJlSzhUSEMvZGVrVDBIc09Va01lYzN3Um1sVGdHYnMK" + } } } diff --git a/packaging/repo/Caddyfile b/packaging/repo/Caddyfile index da9253796..9524394fb 100644 --- a/packaging/repo/Caddyfile +++ b/packaging/repo/Caddyfile @@ -53,7 +53,7 @@ # `.flatpakref` y de las órdenes de alta publicadas—, así que se enumeran # en vez de servir el montaje entero: lo que no esté en esta lista no se # sirve aunque alguien lo deje ahí. - @repositorio path /flatpak/* /apt/* /rpm/* /rfirma.asc /rfirma.flatpakref /rfirma.flatpakrepo /windows/* + @repositorio path /flatpak/* /apt/* /rpm/* /rfirma.asc /rfirma.flatpakref /rfirma.flatpakrepo /windows/* /macos/* handle @repositorio { root * /srv/rfirma-repo/actual header /rfirma.flatpakrepo Content-Type application/vnd.flatpak.repo @@ -79,7 +79,7 @@ # quien las pide es apt, dnf o flatpak, no una persona. handle_errors 404 { import cabeceras - @web not path /flatpak/* /apt/* /rpm/* /rfirma.asc /rfirma.flatpakref /rfirma.flatpakrepo /windows/* + @web not path /flatpak/* /apt/* /rpm/* /rfirma.asc /rfirma.flatpakref /rfirma.flatpakrepo /windows/* /macos/* handle @web { root * /srv rewrite * /404.html diff --git a/packaging/repo/README.md b/packaging/repo/README.md index 09b0a13ff..c062a42c2 100644 --- a/packaging/repo/README.md +++ b/packaging/repo/README.md @@ -265,17 +265,19 @@ Si el `grep` no encuentra nada, el fichero lleva la maestra: no se sube. Al term ## La clave de actualizaciones de Windows -Un par minisign propio, distinto de la GPG (ADR-0015): firma el `-setup.exe` de cada versión -estable para el *updater* de Tauri. La pública se versiona en un solo sitio, -`plugins.updater.pubkey` de `packaging/windows/tauri.windows.json`, la configuración que se -pasa con `--config` al bundler: es en la que confía la versión que se instala. +Un par minisign propio, distinto de la GPG (ADR-0015): firma el `-setup.exe` y el +`.app.tar.gz` de macOS de cada versión estable para el *updater* de Tauri. La pública se +versiona en `plugins.updater.pubkey` de `packaging/windows/tauri.windows.json` y de +`packaging/macos/tauri.macos.json`, las configuraciones que se pasan con `--config` al +bundler: es en la que confía la versión que se instala. Las dos llevan la misma, y una +prueba de `desktop/adapters/releases` lo comprueba. La privada y su contraseña son los secretos `TAURI_SIGNING_PRIVATE_KEY` y `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` del entorno `release`, y no aparecen en ningún otro workflow: `.github/check-workflows.sh` lo comprueba. -`release.yml` firma con `packaging/windows/sign-updater.sh`, que deja el `.sig` al lado del -instalador y falla si falta la clave o si la firma no verifica con la pública embebida en la +`release.yml` firma con `packaging/windows/sign-updater.sh`, que deja el `.sig` al lado de +cada paquete y falla si falta la clave o si la firma no verifica con la pública embebida en la **última etiqueta estable**, que es la que aceptan las instalaciones que ya existen; si todavía no hay ninguna estable con *updater*, con la embebida actual. Todo lo de abajo se hace en un equipo propio, nunca en el CI, desde la raíz del repositorio y con las dependencias de @@ -296,9 +298,10 @@ ficheros, la privada `rfirma-actualizaciones.key` y la pública - **Versionar la pública**, tal cual, por PR a `main`: ```bash - CONF=packaging/windows/tauri.windows.json - jq --arg k "$(cat "$DIR/rfirma-actualizaciones.key.pub")" '.plugins.updater.pubkey = $k' \ - "$CONF" > "$DIR/conf.json" && mv "$DIR/conf.json" "$CONF" + for CONF in packaging/windows/tauri.windows.json packaging/macos/tauri.macos.json; do + jq --arg k "$(cat "$DIR/rfirma-actualizaciones.key.pub")" '.plugins.updater.pubkey = $k' \ + "$CONF" > "$DIR/conf.json" && mv "$DIR/conf.json" "$CONF" + done ``` - **Custodiar la privada**: copia `rfirma-actualizaciones.key` fuera de línea, con copia de diff --git a/packaging/repo/build-tree.sh b/packaging/repo/build-tree.sh index a23101fcc..02e3bb2a7 100755 --- a/packaging/repo/build-tree.sh +++ b/packaging/repo/build-tree.sh @@ -42,6 +42,8 @@ # /apt/ con dists/stable/main/binary-amd64/ # /rpm/ con repodata/ # /windows/ el `-setup.exe` y el `.sig` de cada version, y `latest.json` +# /macos/ el `.app.tar.gz` y el `.sig` de cada version que lo trae, y +# `latest.json` # # APT CON SUITE `stable` Y NO REPOSITORIO PLANO (ID-175): el plano es mas # barato y NO admite `Suites:`/`Components:` en un `.sources` deb822, que es el @@ -99,7 +101,7 @@ exe_de_la_version() { return 1 fi mapfile -t exes < <("$manifiesto" files "$dir" nsis) - mapfile -t sigs < <("$manifiesto" files "$dir" minisign) + mapfile -t sigs < <("$manifiesto" files "$dir" minisign | grep -v '\.app\.tar\.gz\.sig$' || true) if [ "${#exes[@]}" -ne 1 ] || [ "${#sigs[@]}" -ne 1 ] || [ "${sigs[0]}" != "${exes[0]}.sig" ]; then echo "a la version $(basename "$dir") le falta el .exe de Windows o su .sig" >&2 return 1 @@ -111,6 +113,25 @@ exe_de_la_version() { echo "${exes[0]}" } +# La actualizacion de macOS no la traen las versiones de antes de macOS: sin ella, nada. +app_de_la_version() { + local dir="$1" apps sigs + mapfile -t apps < <("$manifiesto" files "$dir" updater) + if [ "${#apps[@]}" -eq 0 ]; then + return 0 + fi + mapfile -t sigs < <("$manifiesto" files "$dir" minisign | grep '\.app\.tar\.gz\.sig$' || true) + if [ "${#apps[@]}" -ne 1 ] || [ "${#sigs[@]}" -ne 1 ] || [ "${sigs[0]}" != "${apps[0]}.sig" ]; then + echo "a la version $(basename "$dir") le falta la actualizacion de macOS o su .sig" >&2 + return 1 + fi + if [ ! -f "$dir/${apps[0]}" ] || [ ! -f "$dir/${sigs[0]}" ]; then + echo "el manifiesto de $(basename "$dir") nombra ${apps[0]} y ${sigs[0]} y no estan en la Release" >&2 + return 1 + fi + echo "${apps[0]}" +} + # Las versiones de la serie, en orden de version y no de listado: el orden en # que se importan los bundles es la historia del repositorio ostree, asi que # tiene que ser el mismo en cada reconstruccion. @@ -168,6 +189,10 @@ exe_de=() for version in "${versiones[@]}"; do exe_de+=("$(exe_de_la_version "$serie/$version")") || exit 1 done +app_de=() +for version in "${versiones[@]}"; do + app_de+=("$(app_de_la_version "$serie/$version")") || exit 1 +done # Las herramientas, TODAS de golpe y por su nombre de paquete: que la # publicacion se caiga a la mitad porque falta `createrepo_c` deja el arbol a @@ -235,7 +260,7 @@ ceba_el_agente() { } rm -rf "$arbol" -mkdir -p "$arbol/flatpak" "$arbol/apt" "$arbol/rpm" "$arbol/windows" +mkdir -p "$arbol/flatpak" "$arbol/apt" "$arbol/rpm" "$arbol/windows" "$arbol/macos" # La clave publica no la genera nada: es la misma que firma las Releases, y # aqui se copia tal cual para que apt y dnf la encuentren en la ruta que dicen @@ -439,4 +464,28 @@ jq -n \ platforms: {"windows-x86_64": {signature: ($firma | rtrimstr("\n")), url: $url}}}' \ > "$arbol/windows/latest.json" +# ----------------------------------------------------------------- 5. macos -- +# Como `windows/`, con las versiones estables que traen la actualizacion de macOS. +echo " macos: colocando las actualizaciones y latest.json" +ultima_app="" +for i in "${!versiones[@]}"; do + [[ "${versiones[$i]}" == *-* ]] && continue + [ -n "${app_de[$i]}" ] || continue + cp "$serie/${versiones[$i]}/${app_de[$i]}" "$serie/${versiones[$i]}/${app_de[$i]}.sig" "$arbol/macos/" + ultima_app="$i" +done +if [ -z "$ultima_app" ]; then + echo " macos: ninguna version estable trae la actualizacion de macOS, no hay latest.json" +else + app_ultima="${app_de[$ultima_app]}" + jq -n \ + --arg version "${versiones[$ultima_app]#v}" \ + --arg fecha "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg url "https://rfirma.sgomez.me/macos/$(jq -rn --arg f "$app_ultima" '$f | @uri')" \ + --rawfile firma "$arbol/macos/$app_ultima.sig" \ + '{version: $version, notes: "", pub_date: $fecha, + platforms: {"darwin-aarch64": {signature: ($firma | rtrimstr("\n")), url: $url}}}' \ + > "$arbol/macos/latest.json" +fi + echo "OK arbol construido en $arbol con ${#versiones[@]} version(es): ${versiones[*]}" diff --git a/packaging/repo/build-tree.test.sh b/packaging/repo/build-tree.test.sh index 06896e5a5..792519e89 100755 --- a/packaging/repo/build-tree.test.sh +++ b/packaging/repo/build-tree.test.sh @@ -77,6 +77,20 @@ instalador_de_pega() { "$raiz/scripts/packages-manifest.sh" write "$dir" } +actualizacion_de_macos_de_pega() { + local dir="$1" version="$2" + echo "app $version" > "$dir/rFirma_${version}_aarch64.app.tar.gz" + printf 'firma mac de %s\n' "$version" > "$dir/rFirma_${version}_aarch64.app.tar.gz.sig" + "$raiz/scripts/packages-manifest.sh" write "$dir" +} + +sin_actualizacion_de_macos() { + local dir="$1" + rm -f "$dir"/*.app.tar.gz "$dir"/*.app.tar.gz.sig + jq 'map(select(.platform != "macos"))' "$dir/paquetes.json" > "$dir/paquetes.json.nuevo" + mv "$dir/paquetes.json.nuevo" "$dir/paquetes.json" +} + # an_incomplete_series_stops_the_publication incompleta="$tmp/incompleta" paquetes_de_pega "$incompleta/v0.4.0" "0.4.0" @@ -151,6 +165,20 @@ else ok "una clave publica que no existe detiene la publicacion" fi +# a_macos_update_without_its_signature_stops_the_publication +mac_sin_sig="$tmp/mac-sin-sig" +paquetes_de_pega "$mac_sin_sig/v0.4.0" "0.4.0" +actualizacion_de_macos_de_pega "$mac_sin_sig/v0.4.0" "0.4.0" +rm "$mac_sin_sig/v0.4.0"/*.app.tar.gz.sig +"$raiz/scripts/packages-manifest.sh" write "$mac_sin_sig/v0.4.0" +if salida="$("$construye" "$mac_sin_sig" "$tmp/arbol-malo" "$tmp/rfirma.asc" "$SIN_FIRMA" 2>&1)"; then + fail "una actualizacion de macOS sin su .sig no detiene la publicacion" +elif grep -q "actualizacion de macOS" <<< "$salida"; then + ok "una actualizacion de macOS sin su .sig detiene la publicacion" +else + fail "una actualizacion de macOS sin su .sig se para por otro motivo: $salida" +fi + # --------------------------------------------------------------------------- # Las ordenes de alta de la landing: no necesitan construir nada y son la # unica atadura entre lo que se sirve y lo que la gente copia. @@ -251,12 +279,16 @@ for version in "${versiones[@]}"; do deb_de_prueba "$version" "$dir/rfirma_${version}_amd64.deb" rpm_de_prueba "$version" "$dir/rfirma-$version.x86_64.rpm" instalador_de_pega "$dir" "$version" + # La primera version es de antes de macOS: no trae su actualizacion. + [ "$version" = 0.4.0 ] || actualizacion_de_macos_de_pega "$dir" "$version" done # Assets subidos a mano a la Release DESPUES del manifiesto: no estan en el. echo "de mas" > "$serie/v0.4.10/rFirma_9.9.9_x64-setup.exe" echo "de mas" > "$serie/v0.4.10/rFirma_9.9.9_x64-setup.exe.sig" echo "de mas" > "$serie/v0.4.10/subido-a-mano.exe" +echo "de mas" > "$serie/v0.4.10/rFirma_9.9.9_aarch64.app.tar.gz" +echo "de mas" > "$serie/v0.4.10/rFirma_9.9.9_aarch64.app.tar.gz.sig" # --------------------------------------------------------------------------- # El arbol, dos veces y en dos directorios distintos @@ -471,6 +503,62 @@ else fail "una candidata aparece en windows/ o en latest.json" fi +# --------------------------------------------------------------------------- +# /macos/: la actualizacion y latest.json +# --------------------------------------------------------------------------- +# macos_has_the_update_and_signature_of_every_version_that_brings_it +faltan_de_macos=0 +for version in 0.4.2 0.4.10; do + for fichero in "rFirma_${version}_aarch64.app.tar.gz" "rFirma_${version}_aarch64.app.tar.gz.sig"; do + [ -f "$tmp/arbol/macos/$fichero" ] || { faltan_de_macos=1; echo " falta $fichero" >&2; } + done +done +if [ "$faltan_de_macos" -eq 0 ] && [ -z "$(find "$tmp/arbol/macos" -name '*0.4.0*' -print -quit)" ] \ + && [ ! -e "$tmp/arbol/macos/rFirma_9.9.9_aarch64.app.tar.gz" ]; then + ok "macos/ trae la actualizacion y su .sig de cada version que la trae, y nada fuera del manifiesto" +else + fail "macos/ no trae lo que dicen los manifiestos de la serie" +fi + +# macos_latest_json_announces_the_darwin_aarch64_entry_of_the_highest_version +latest_mac="$tmp/arbol/macos/latest.json" +app_alta="rFirma_0.4.10_aarch64.app.tar.gz" +if [ -f "$latest_mac" ] \ + && [ "$(jq -r .version "$latest_mac")" = "0.4.10" ] \ + && [ "$(jq -r '.platforms | keys | join(",")' "$latest_mac")" = "darwin-aarch64" ] \ + && [ "$(jq -r '.platforms["darwin-aarch64"].url' "$latest_mac")" = "https://rfirma.sgomez.me/macos/$app_alta" ] \ + && [ "$(jq -r '.platforms["darwin-aarch64"].signature' "$latest_mac")" = "$(cat "$tmp/arbol/macos/$app_alta.sig")" ] \ + && jq -e '.pub_date != null and .notes != null' "$latest_mac" > /dev/null; then + ok "el latest.json de macos anuncia la entrada darwin-aarch64 de la version mas alta" +else + fail "el latest.json de macos no cumple el contrato del updater: $(cat "$latest_mac" 2>&1)" +fi + +# a_candidate_never_appears_in_macos +actualizacion_de_macos_de_pega "$con_candidata/v0.4.11-rc.1" "0.4.11-rc.1" +"$construye" "$con_candidata" "$tmp/arbol-rc-mac" "$tmp/rfirma.asc" "$SIN_FIRMA" > /dev/null 2>&1 \ + || fail "la construccion con una candidata de macOS en la serie falla" +if [ "$(jq -r .version "$tmp/arbol-rc-mac/macos/latest.json")" = "0.4.10" ] \ + && [ -z "$(find "$tmp/arbol-rc-mac/macos" -name '*rc*' -print -quit)" ]; then + ok "una candidata no aparece en macos/ ni en su latest.json" +else + fail "una candidata aparece en macos/ o en su latest.json" +fi + +# a_series_without_any_macos_update_still_builds_without_announcing_one +sin_mac="$tmp/sin-mac" +cp -r "$serie" "$sin_mac" +for version in "${versiones[@]}"; do + sin_actualizacion_de_macos "$sin_mac/v$version" +done +if "$construye" "$sin_mac" "$tmp/arbol-sin-mac" "$tmp/rfirma.asc" "$SIN_FIRMA" > /dev/null 2>&1 \ + && [ ! -e "$tmp/arbol-sin-mac/macos/latest.json" ] \ + && [ -f "$tmp/arbol-sin-mac/windows/latest.json" ]; then + ok "una serie sin actualizacion de macOS se construye y no anuncia ninguna" +else + fail "una serie sin actualizacion de macOS no se construye o anuncia una" +fi + # the_caddyfile_serves_windows_from_the_current_link if grep -E '^\s*@repositorio path .*/windows/\*' "$raiz/packaging/repo/Caddyfile" > /dev/null; then ok "el Caddyfile sirve /windows/* desde actual" @@ -478,6 +566,14 @@ else fail "el Caddyfile no sirve /windows/*" fi +# the_caddyfile_serves_macos_from_the_current_link +if grep -E '^\s*@repositorio path .*/macos/\*' "$raiz/packaging/repo/Caddyfile" > /dev/null \ + && grep -E '^\s*@web not path .*/macos/\*' "$raiz/packaging/repo/Caddyfile" > /dev/null; then + ok "el Caddyfile sirve /macos/* desde actual" +else + fail "el Caddyfile no sirve /macos/*" +fi + # --------------------------------------------------------------------------- # Lo demas # --------------------------------------------------------------------------- diff --git a/packaging/windows/sign-updater.sh b/packaging/windows/sign-updater.sh index 9c43ca15d..5cc64f949 100755 --- a/packaging/windows/sign-updater.sh +++ b/packaging/windows/sign-updater.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Firma de actualización (minisign) de cada instalador de Windows de una entrega, comprobada contra la pública embebida en la última etiqueta estable (ADR-0015). +# Firma de actualización (minisign) de cada instalador de Windows y de cada `.app.tar.gz` de macOS de una entrega, comprobada contra la pública embebida en la última etiqueta estable de su plataforma (ADR-0015). # # Uso: packaging/windows/sign-updater.sh # Entorno: TAURI_SIGNING_PRIVATE_KEY y TAURI_SIGNING_PRIVATE_KEY_PASSWORD. @@ -13,11 +13,13 @@ fi root="$(cd "$(dirname "$0")/../.." && pwd)" manifest="$root/scripts/packages-manifest.sh" -config=packaging/windows/tauri.windows.json +windows_config=packaging/windows/tauri.windows.json +macos_config=packaging/macos/tauri.macos.json mapfile -t installers < <("$manifest" files "$dir" nsis) -if [ "${#installers[@]}" -eq 0 ]; then - echo "sin instalador de Windows que firmar" +mapfile -t archives < <("$manifest" files "$dir" updater) +if [ "${#installers[@]}" -eq 0 ] && [ "${#archives[@]}" -eq 0 ]; then + echo "sin instalador de Windows ni actualizacion de macOS que firmar" exit 0 fi @@ -27,7 +29,7 @@ embedded_pubkey() { # No la actual: en la versión puente de una rotación ya embebe la nueva (ADR-0015). trusted_pubkey() { - local tag pubkey + local config="$1" tag pubkey while read -r tag; do [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue pubkey="$(git -C "$root" show "$tag:$config" 2> /dev/null | embedded_pubkey)" || pubkey="" @@ -39,12 +41,19 @@ trusted_pubkey() { echo "HEAD $(embedded_pubkey < "$root/$config")" } -read -r source pubkey < <(trusted_pubkey) -if [ -z "${pubkey-}" ]; then - echo "falta plugins.updater.pubkey en $config ($source)" >&2 - echo "Se crea como dice «La clave de actualizaciones de Windows» en packaging/repo/README.md." >&2 - exit 1 -fi +declare -A source_of=() pubkey_of=() +for config in "$windows_config" "$macos_config"; do + if [ "$config" = "$windows_config" ] && [ "${#installers[@]}" -eq 0 ]; then continue; fi + if [ "$config" = "$macos_config" ] && [ "${#archives[@]}" -eq 0 ]; then continue; fi + read -r source pubkey < <(trusted_pubkey "$config") + if [ -z "${pubkey-}" ]; then + echo "falta plugins.updater.pubkey en $config ($source)" >&2 + echo "Se crea como dice «La clave de actualizaciones de Windows» en packaging/repo/README.md." >&2 + exit 1 + fi + source_of["$config"]="$source" + pubkey_of["$config"]="$pubkey" +done if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -z "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" ]; then echo "faltan TAURI_SIGNING_PRIVATE_KEY o TAURI_SIGNING_PRIVATE_KEY_PASSWORD en el entorno" >&2 exit 1 @@ -59,18 +68,25 @@ done cli_version="$(jq -r '.devDependencies["@tauri-apps/cli"]' "$root/rfirma-app/package.json")" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT -printf '%s' "$pubkey" | base64 -d > "$work/updater.pub" -for name in "${installers[@]}"; do - installer="$dir/$name" - rm -f "$installer.sig" - npx --yes "@tauri-apps/cli@$cli_version" signer sign "$installer" < /dev/null - base64 -d "$installer.sig" > "$work/updater.minisig" - if ! minisign -V -q -p "$work/updater.pub" -m "$installer" -x "$work/updater.minisig"; then +sign_against() { + local config="$1" name="$2" package="$dir/$2" source="${source_of[$1]}" + printf '%s' "${pubkey_of[$config]}" | base64 -d > "$work/updater.pub" + rm -f "$package.sig" + npx --yes "@tauri-apps/cli@$cli_version" signer sign "$package" < /dev/null + base64 -d "$package.sig" > "$work/updater.minisig" + if ! minisign -V -q -p "$work/updater.pub" -m "$package" -x "$work/updater.minisig"; then echo "$name.sig no verifica con la clave publica embebida en $config de $source" >&2 exit 1 fi echo "OK $name.sig verifica con la clave publica embebida en $config de $source" +} + +for name in "${installers[@]}"; do + sign_against "$windows_config" "$name" +done +for name in "${archives[@]}"; do + sign_against "$macos_config" "$name" done "$manifest" write "$dir" diff --git a/packaging/windows/sign-updater.test.sh b/packaging/windows/sign-updater.test.sh index b25898ba7..4b8427625 100755 --- a/packaging/windows/sign-updater.test.sh +++ b/packaging/windows/sign-updater.test.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Prueba la firma de actualización del instalador de Windows: sin clave no firma, y la firma verifica contra la pública embebida en la última estable y falla contra otra. +# Prueba la firma de actualización del instalador de Windows y del `.app.tar.gz` de macOS: sin clave no firma, y la firma verifica contra la pública embebida en la última estable y falla contra otra. set -euo pipefail root="$(cd "$(dirname "$0")/../.." && pwd)" @@ -19,20 +19,27 @@ git_in() { -c tag.gpgsign=false "$@" > /dev/null } -embed() { - local repo="$1" pubkey="$2" config="$1/packaging/windows/tauri.windows.json" +embed_in() { + local config="$1" original="$2" pubkey="$3" if [ -n "$pubkey" ]; then - jq --arg k "$pubkey" '.plugins.updater.pubkey = $k' "$root/packaging/windows/tauri.windows.json" > "$config" + jq --arg k "$pubkey" '.plugins.updater.pubkey = $k' "$original" > "$config" else - jq 'del(.plugins.updater)' "$root/packaging/windows/tauri.windows.json" > "$config" + jq 'del(.plugins.updater)' "$original" > "$config" fi +} + +embed() { + local repo="$1" pubkey="$2" platform + for platform in windows macos; do + embed_in "$repo/packaging/$platform/tauri.$platform.json" "$root/packaging/$platform/tauri.$platform.json" "$pubkey" + done git_in "$repo" add -A git_in "$repo" commit -q --allow-empty -m "embebe $pubkey" } repo_embedding() { local repo="$1" - mkdir -p "$repo/packaging/windows" "$repo/scripts" "$repo/rfirma-app" + mkdir -p "$repo/packaging/windows" "$repo/packaging/macos" "$repo/scripts" "$repo/rfirma-app" cp "$root/packaging/windows/sign-updater.sh" "$repo/packaging/windows/" cp "$manifest" "$repo/scripts/" cp "$root/rfirma-app/package.json" "$repo/rfirma-app/" @@ -44,12 +51,13 @@ delivery() { mkdir "$1" echo flatpak > "$1/me.sgomez.rfirma.flatpak" [ "${2-}" = sin-instalador ] || echo instalador > "$1/rfirma_1.0.0_x64-setup.exe" + [ "${2-}" != con-macos ] || echo app > "$1/rfirma_1.0.0_aarch64.app.tar.gz" "$manifest" write "$1" } signs() { local repo="$1" key="$2" password="$3" name="$4" - delivery "$tmp/$name" + delivery "$tmp/$name" "${5-}" TAURI_SIGNING_PRIVATE_KEY="$(cat "$key")" TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$password" \ "$repo/packaging/windows/sign-updater.sh" "$tmp/$name" > /dev/null 2>&1 } @@ -67,6 +75,18 @@ if TAURI_SIGNING_PRIVATE_KEY=k TAURI_SIGNING_PRIVATE_KEY_PASSWORD=p \ fi [ ! -e "$tmp/sin-publica-entrega/rfirma_1.0.0_x64-setup.exe.sig" ] || fail "sin clave publica no se firma" +repo_embedding "$tmp/macos-sin-publica" cHVibGljYQ== +embed_in "$tmp/macos-sin-publica/packaging/macos/tauri.macos.json" "$root/packaging/macos/tauri.macos.json" "" +git_in "$tmp/macos-sin-publica" commit -q -am "macos sin updater" +delivery "$tmp/macos-sin-publica-entrega" con-macos +if salida="$(TAURI_SIGNING_PRIVATE_KEY=k TAURI_SIGNING_PRIVATE_KEY_PASSWORD=p \ + "$tmp/macos-sin-publica/packaging/windows/sign-updater.sh" "$tmp/macos-sin-publica-entrega" 2>&1)"; then + fail "sin clave publica embebida en la configuracion de macOS: debia fallar" +fi +grep -qF packaging/macos/tauri.macos.json <<< "$salida" || fail "sin clave publica de macOS: dice que configuracion" +[ ! -e "$tmp/macos-sin-publica-entrega/rfirma_1.0.0_x64-setup.exe.sig" ] \ + || fail "sin clave publica de macOS no se firma tampoco el instalador de Windows" + repo_embedding "$tmp/con-publica" cHVibGljYQ== delivery "$tmp/sin-privada" if env -u TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD=p \ @@ -103,6 +123,11 @@ signs "$repo" "$tmp/vieja.key" vieja sin-estable-vieja || fail "sin estable, ver if signs "$repo" "$tmp/nueva.key" nueva sin-estable-nueva; then fail "sin estable, una firma que no verifica con la embebida actual: debia fallar" fi +signs "$repo" "$tmp/vieja.key" vieja con-macos con-macos || fail "firma tambien la actualizacion de macOS" +[ "$("$manifest" files "$tmp/con-macos" minisign | sort | tr '\n' ' ')" \ + = "rfirma_1.0.0_aarch64.app.tar.gz.sig rfirma_1.0.0_x64-setup.exe.sig " ] \ + || fail "el .sig de la actualizacion de macOS es una fila del manifiesto" +[ "$(cat "$tmp/con-macos/rfirma_1.0.0_aarch64.app.tar.gz")" = app ] || fail "firmar no toca el .app.tar.gz" repo="$tmp/estable-sin-updater" repo_embedding "$repo" "" diff --git a/rfirma-app/src-tauri/Cargo.toml b/rfirma-app/src-tauri/Cargo.toml index a8384d7ed..94c52b956 100644 --- a/rfirma-app/src-tauri/Cargo.toml +++ b/rfirma-app/src-tauri/Cargo.toml @@ -216,7 +216,7 @@ windows-sys = { version = "0.61", features = [ # de la CA local sin persistirla en el almacén de claves del usuario (ADR-0036). tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } # Instalar desde la aplicación la versión que anuncia `latest.json`, verificada con minisign. -# Solo en Windows: en Linux instala el gestor de paquetes (ADR-0035). +# Solo en Windows y macOS: en Linux instala el gestor de paquetes (ADR-0035). tauri-plugin-updater = { version = "2", default-features = false, features = ["native-tls"] } # macOS no trae OpenSSL: se compila desde las fuentes, sin depender del de @@ -234,6 +234,8 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std security-framework = "3.7" security-framework-sys = "2.17" core-foundation = "0.10" +# Como en Windows: el `.app` se actualiza sola, también la que instaló Homebrew (ADR-0040). +tauri-plugin-updater = { version = "2", default-features = false, features = ["native-tls"] } [target.'cfg(not(windows))'.dev-dependencies] # La tarjeta falsa de las pruebas: no se compila en Windows y nunca llega a un paquete (ADR-0004). diff --git a/rfirma-app/src-tauri/src/desktop/adapters/channel.rs b/rfirma-app/src-tauri/src/desktop/adapters/channel.rs index 1a59d31bd..4a218b3f1 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/channel.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/channel.rs @@ -6,19 +6,20 @@ use std::path::Path; const SANDBOX_MARKER: &str = "/.flatpak-info"; pub use crate::desktop::domain::channel::Channel; +use crate::desktop::domain::platform::Platform; impl Channel { /// Detecta el canal examinando la presencia del testigo de sandbox. pub fn detected() -> Self { - Self::on_platform(cfg!(windows), Path::new(SANDBOX_MARKER)) + Self::on_platform(Platform::CURRENT, Path::new(SANDBOX_MARKER)) } - /// Windows es su propio canal; en el resto manda el testigo de sandbox. - fn on_platform(windows: bool, marker: &Path) -> Self { - if windows { - Self::Windows - } else { - Self::over(marker) + /// Windows y macOS son su propio canal; en Linux manda el testigo de sandbox. + fn on_platform(platform: Platform, marker: &Path) -> Self { + match platform { + Platform::Windows => Self::Windows, + Platform::MacOs => Self::MacOs, + Platform::Linux => Self::over(marker), } } @@ -45,7 +46,7 @@ pub enum RegisteredHandlers { pub fn registered_handlers_for_scheme(channel: Channel, scheme: &str) -> RegisteredHandlers { match channel { Channel::Flatpak => RegisteredHandlers::NotAvailableInsideTheSandbox, - Channel::Native | Channel::Windows => { + Channel::Native | Channel::Windows | Channel::MacOs => { #[cfg(target_os = "linux")] let handlers = gio::AppInfo::all_for_type(&content_type_for(scheme)) .iter() diff --git a/rfirma-app/src-tauri/src/desktop/adapters/channel/tests.rs b/rfirma-app/src-tauri/src/desktop/adapters/channel/tests.rs index e8bdc66c1..784721ee9 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/channel/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/channel/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::desktop::domain::handlers::OUR_DESKTOP_FILE; +use crate::desktop::domain::platform::Platform; use std::fs; #[test] @@ -27,13 +28,43 @@ fn windows_is_its_own_channel_whatever_the_marker_says() { let marker = directory.path().join(".flatpak-info"); fs::write(&marker, b"[Application]\n").expect("deberia escribirse"); - assert_eq!(Channel::on_platform(true, &marker), Channel::Windows); - assert_eq!(Channel::on_platform(false, &marker), Channel::Flatpak); + assert_eq!( + Channel::on_platform(Platform::Windows, &marker), + Channel::Windows + ); + assert_eq!( + Channel::on_platform(Platform::Linux, &marker), + Channel::Flatpak + ); +} + +#[test] +fn macos_is_its_own_channel_whatever_the_marker_says() { + let directory = tempfile::tempdir().expect("deberia haber directorio temporal"); + let marker = directory.path().join(".flatpak-info"); + fs::write(&marker, b"[Application]\n").expect("deberia escribirse"); + + assert_eq!( + Channel::on_platform(Platform::MacOs, &marker), + Channel::MacOs + ); +} + +#[test] +fn a_homebrew_installation_does_not_turn_off_the_updater() { + let directory = tempfile::tempdir().expect("deberia haber directorio temporal"); + let caskroom = directory.path().join("opt/homebrew/Caskroom/rfirma"); + fs::create_dir_all(&caskroom).expect("deberia crearse"); + + let channel = Channel::on_platform(Platform::MacOs, &caskroom.join(".flatpak-info")); + + assert!(channel.installs_from_the_app()); } #[test] -fn only_the_windows_channel_installs_from_the_app() { +fn windows_and_macos_install_from_the_app_and_linux_does_not() { assert!(Channel::Windows.installs_from_the_app()); + assert!(Channel::MacOs.installs_from_the_app()); assert!(!Channel::Native.installs_from_the_app()); assert!(!Channel::Flatpak.installs_from_the_app()); } diff --git a/rfirma-app/src-tauri/src/desktop/adapters/installation.rs b/rfirma-app/src-tauri/src/desktop/adapters/installation.rs index 4b26d6e5f..56a1e62c5 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/installation.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/installation.rs @@ -16,7 +16,7 @@ pub fn this_installation(channel: Channel) -> Installation { match channel { Channel::Flatpak => Installation::Flatpak(flatpak_installation()), Channel::Windows => Installation::WindowsInstaller(installer_scope()), - Channel::Native if cfg!(target_os = "macos") => Installation::MacOs, + Channel::MacOs => Installation::MacOs, Channel::Native => native_installation(), } } diff --git a/rfirma-app/src-tauri/src/desktop/adapters/installer.rs b/rfirma-app/src-tauri/src/desktop/adapters/installer.rs index 0d997ee17..5f7efeb2e 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/installer.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/installer.rs @@ -1,4 +1,4 @@ -//! El instalador de la versión anunciada detrás de `UpdateInstaller`: el plugin updater en Windows, «no disponible» en el resto (ADR-0035). +//! El instalador de la versión anunciada detrás de `UpdateInstaller`: el plugin updater en Windows y macOS, «no disponible» en Linux (ADR-0035). use crate::desktop::domain::installation::InstallFailure; use crate::desktop::ports::UpdateInstaller; @@ -6,7 +6,7 @@ use crate::desktop::ports::UpdateInstaller; /// La clave del plugin en `plugins` de la configuración de Tauri. const UPDATER: &str = "updater"; -/// Registra el plugin updater, solo en Windows y solo si la configuración de Windows lo trae. +/// Registra el plugin updater, solo en Windows y macOS y solo si su configuración de plataforma lo trae. pub fn with_the_updater( builder: tauri::Builder, config: &tauri::Config, @@ -14,14 +14,14 @@ pub fn with_the_updater( if !configured(config) { return builder; } - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); builder } -/// El instalador de esta máquina: el plugin en Windows con su configuración, y si no, ninguno. +/// El instalador de esta máquina: el plugin en Windows y macOS con su configuración, y si no, ninguno. pub fn this_desktop(app: tauri::AppHandle) -> Box { - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] if configured(app.config()) { return Box::new(plugin::PluginInstaller::new(app)); } @@ -29,7 +29,7 @@ pub fn this_desktop(app: tauri::AppHandle) -> Box { Box::new(NotAvailable) } -/// Sin la configuración de Windows que pasa `--config`, `just dev` y `cargo test` no la traen (ADR-0035). +/// Sin la configuración de plataforma que pasa `--config`, `just dev` y `cargo test` no la traen (ADR-0035). fn configured(config: &tauri::Config) -> bool { config.plugins.0.contains_key(UPDATER) } @@ -47,7 +47,7 @@ impl UpdateInstaller for NotAvailable { } } -#[cfg(windows)] +#[cfg(any(windows, target_os = "macos"))] mod plugin { use std::sync::Mutex; @@ -56,7 +56,7 @@ mod plugin { use crate::desktop::domain::installation::InstallFailure; use crate::desktop::ports::UpdateInstaller; - /// El plugin updater: `latest.json`, la firma minisign y el NSIS en modo `passive`. + /// El plugin updater: `latest.json`, la firma minisign y el NSIS en modo `passive` o el `.app` sustituida. pub struct PluginInstaller { app: tauri::AppHandle, found: Mutex>, @@ -94,7 +94,12 @@ mod plugin { tauri::async_runtime::block_on(async move { update.download_and_install(|_, _| {}, || {}).await }) - }) + })?; + // En Windows el NSIS ya cerró rFirma; en macOS la `.app` nueva solo corre al relanzarla. + if cfg!(target_os = "macos") { + self.app.request_restart(); + } + Ok(()) } } diff --git a/rfirma-app/src-tauri/src/desktop/adapters/releases.rs b/rfirma-app/src-tauri/src/desktop/adapters/releases.rs index c82db6194..6c70e4b1d 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/releases.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/releases.rs @@ -1,4 +1,4 @@ -//! Consulta HTTP de la última publicación oficial según el canal (la API de GitHub o el `latest.json` de rfirma.sgomez.me en Windows); no es el instalador, que es `installer.rs` (ADR-0015). +//! Consulta HTTP de la última publicación oficial según el canal (la API de GitHub o el `latest.json` de rfirma.sgomez.me en Windows y macOS); no es el instalador, que es `installer.rs` (ADR-0015). use std::time::Duration; @@ -11,6 +11,9 @@ pub const LATEST_RELEASE_ENDPOINT: &str = /// `latest.json` estable del servidor propio, el feed del canal de Windows. pub const WINDOWS_FEED_ENDPOINT: &str = "https://rfirma.sgomez.me/windows/latest.json"; +/// `latest.json` estable del servidor propio, el feed del canal de macOS. +pub const MACOS_FEED_ENDPOINT: &str = "https://rfirma.sgomez.me/macos/latest.json"; + /// Tiempo máximo de espera para la respuesta del servidor. const TIMEOUT: Duration = Duration::from_secs(4); @@ -24,6 +27,7 @@ pub fn latest_release(channel: Channel) -> Option { fn endpoint_for(channel: Channel) -> &'static str { match channel { Channel::Windows => WINDOWS_FEED_ENDPOINT, + Channel::MacOs => MACOS_FEED_ENDPOINT, Channel::Native | Channel::Flatpak => LATEST_RELEASE_ENDPOINT, } } diff --git a/rfirma-app/src-tauri/src/desktop/adapters/releases/tests.rs b/rfirma-app/src-tauri/src/desktop/adapters/releases/tests.rs index 7cc924d52..67f6d1663 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/releases/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/releases/tests.rs @@ -1,15 +1,20 @@ -use super::{endpoint_for, LATEST_RELEASE_ENDPOINT, WINDOWS_FEED_ENDPOINT}; +use super::{endpoint_for, LATEST_RELEASE_ENDPOINT, MACOS_FEED_ENDPOINT, WINDOWS_FEED_ENDPOINT}; use crate::desktop::domain::channel::Channel; #[test] fn each_channel_asks_its_own_feed() { assert_eq!(endpoint_for(Channel::Windows), WINDOWS_FEED_ENDPOINT); + assert_eq!(endpoint_for(Channel::MacOs), MACOS_FEED_ENDPOINT); assert_eq!(endpoint_for(Channel::Native), LATEST_RELEASE_ENDPOINT); assert_eq!(endpoint_for(Channel::Flatpak), LATEST_RELEASE_ENDPOINT); assert_eq!( WINDOWS_FEED_ENDPOINT, "https://rfirma.sgomez.me/windows/latest.json" ); + assert_eq!( + MACOS_FEED_ENDPOINT, + "https://rfirma.sgomez.me/macos/latest.json" + ); } #[test] @@ -19,3 +24,37 @@ fn it_asks_github_for_the_latest_release_and_nobody_else() { "se le pregunta a GitHub, no a rfirma.sgomez.me (ADR-0015)" ); } + +const WINDOWS_CONFIG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../packaging/windows/tauri.windows.json" +)); +const MACOS_CONFIG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../packaging/macos/tauri.macos.json" +)); + +fn updater_of(config: &str) -> serde_json::Value { + let config: serde_json::Value = serde_json::from_str(config).expect("es JSON"); + config["plugins"]["updater"].clone() +} + +#[test] +fn the_installer_of_each_platform_asks_the_same_feed_as_the_version_check() { + assert_eq!( + updater_of(WINDOWS_CONFIG)["endpoints"], + serde_json::json!([WINDOWS_FEED_ENDPOINT]) + ); + assert_eq!( + updater_of(MACOS_CONFIG)["endpoints"], + serde_json::json!([MACOS_FEED_ENDPOINT]) + ); +} + +#[test] +fn windows_and_macos_trust_the_same_update_key() { + assert_eq!( + updater_of(MACOS_CONFIG)["pubkey"], + updater_of(WINDOWS_CONFIG)["pubkey"] + ); +} diff --git a/rfirma-app/src-tauri/src/desktop/application/status.rs b/rfirma-app/src-tauri/src/desktop/application/status.rs index 8a7b0c7e8..7b056f17e 100644 --- a/rfirma-app/src-tauri/src/desktop/application/status.rs +++ b/rfirma-app/src-tauri/src/desktop/application/status.rs @@ -22,7 +22,7 @@ const OUR_NAME: &str = "rFirma"; pub fn update_destination_for(channel: Channel) -> &'static str { match channel { Channel::Flatpak => REPOSITORY, - Channel::Native => RELEASES, + Channel::Native | Channel::MacOs => RELEASES, Channel::Windows => WINDOWS, } } diff --git a/rfirma-app/src-tauri/src/desktop/application/status/tests.rs b/rfirma-app/src-tauri/src/desktop/application/status/tests.rs index b818b5bc1..489f471d0 100644 --- a/rfirma-app/src-tauri/src/desktop/application/status/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/application/status/tests.rs @@ -102,6 +102,7 @@ fn checking_version_signal_has_no_value_nor_action() { fn each_channel_updates_from_its_own_destination() { assert_eq!(update_destination_for(Channel::Windows), "windows"); assert_eq!(update_destination_for(Channel::Native), "releases"); + assert_eq!(update_destination_for(Channel::MacOs), "releases"); assert_eq!(update_destination_for(Channel::Flatpak), "repository"); } diff --git a/rfirma-app/src-tauri/src/desktop/application/version.rs b/rfirma-app/src-tauri/src/desktop/application/version.rs index e94adb482..cc7aeeea8 100644 --- a/rfirma-app/src-tauri/src/desktop/application/version.rs +++ b/rfirma-app/src-tauri/src/desktop/application/version.rs @@ -111,13 +111,16 @@ pub(crate) fn ask_and_remember( Some(announced) } -/// Extrae la versión anunciada: `version` del `latest.json` en Windows, `tag_name` en GitHub. +/// Extrae la versión anunciada: `version` del `latest.json` con la entrada del canal, `tag_name` en GitHub. fn announced_version(body: &str, channel: Channel) -> Option { - let field = match channel { - Channel::Windows => "version", - Channel::Native | Channel::Flatpak => "tag_name", - }; let feed: serde_json::Value = serde_json::from_str(body).ok()?; + let field = match channel.updater_target() { + Some(target) => { + feed.get("platforms")?.get(target)?; + "version" + } + None => "tag_name", + }; Version::parse(feed.get(field)?.as_str()?) } diff --git a/rfirma-app/src-tauri/src/desktop/application/version/tests.rs b/rfirma-app/src-tauri/src/desktop/application/version/tests.rs index 8547a2bfa..0d265743a 100644 --- a/rfirma-app/src-tauri/src/desktop/application/version/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/application/version/tests.rs @@ -63,6 +63,62 @@ fn on_windows_the_feed_is_read_as_latest_json_and_the_version_is_installable() { ); } +fn a_macos_latest_json(version: &str) -> String { + format!( + r#"{{"version":"{version}","notes":"","pub_date":"2026-09-30T00:00:00Z","platforms":{{"darwin-aarch64":{{"signature":"sig","url":"https://rfirma.sgomez.me/macos/rfirma.app.tar.gz"}}}}}}"# + ) +} + +#[test] +fn on_macos_the_darwin_aarch64_entry_of_latest_json_is_announced_and_installable() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let memory = a_memory(home.path()); + + let newer = new_version( + Version::parse("0.3.1").expect("es una version"), + &memory, + &|| Some(a_macos_latest_json("0.4.0")), + Channel::MacOs, + at(1_756_000_000), + ); + + assert_eq!( + newer, + Some(NewVersion { + version: Version::parse("0.4.0").expect("es una version"), + installable: true, + }) + ); +} + +#[test] +fn a_latest_json_without_the_entry_of_this_platform_announces_nothing() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let memory = a_memory(home.path()); + let running = Version::parse("0.3.1").expect("es una version"); + + assert_eq!( + new_version( + running, + &memory, + &|| Some(a_latest_json("0.4.0")), + Channel::MacOs, + at(1) + ), + None + ); + assert_eq!( + new_version( + running, + &memory, + &|| Some(a_macos_latest_json("0.4.0")), + Channel::Windows, + at(2) + ), + None + ); +} + #[test] fn on_linux_the_version_is_announced_but_not_installable() { let home = tempfile::tempdir().expect("deberia haber directorio temporal"); diff --git a/rfirma-app/src-tauri/src/desktop/domain/channel.rs b/rfirma-app/src-tauri/src/desktop/domain/channel.rs index 4a238d75a..6f2fb78fb 100644 --- a/rfirma-app/src-tauri/src/desktop/domain/channel.rs +++ b/rfirma-app/src-tauri/src/desktop/domain/channel.rs @@ -9,6 +9,8 @@ pub enum Channel { Flatpak, /// Instalación de Windows, servida desde el servidor propio. Windows, + /// Instalación de macOS, del `.dmg` o del tap de Homebrew. + MacOs, } impl Channel { @@ -18,11 +20,21 @@ impl Channel { Self::Native => "nativo", Self::Flatpak => "flatpak", Self::Windows => "windows", + Self::MacOs => "macos", } } /// Si la versión anunciada se puede instalar desde la aplicación. pub fn installs_from_the_app(self) -> bool { - self == Self::Windows + matches!(self, Self::Windows | Self::MacOs) + } + + /// La entrada de `platforms` que el `latest.json` del canal trae para él, si lo sirve el servidor propio. + pub fn updater_target(self) -> Option<&'static str> { + match self { + Self::Windows => Some("windows-x86_64"), + Self::MacOs => Some("darwin-aarch64"), + Self::Native | Self::Flatpak => None, + } } } diff --git a/rfirma-app/src-tauri/src/site/application/trust.rs b/rfirma-app/src-tauri/src/site/application/trust.rs index e0f86c2a1..a9e57d292 100644 --- a/rfirma-app/src-tauri/src/site/application/trust.rs +++ b/rfirma-app/src-tauri/src/site/application/trust.rs @@ -85,7 +85,7 @@ pub fn narrate_startup_outcome(mut outcome: TrustOutcome, profiles: &[PathBuf]) /// La marca que lleva la CA local de cada canal de distribución. pub fn mark_of(channel: Channel) -> ChannelMark { match channel { - Channel::Native => ChannelMark::Native, + Channel::Native | Channel::MacOs => ChannelMark::Native, Channel::Flatpak => ChannelMark::Flatpak, Channel::Windows => ChannelMark::Windows, } diff --git a/rfirma-app/src-tauri/src/site/application/trust/tests/unmarked.rs b/rfirma-app/src-tauri/src/site/application/trust/tests/unmarked.rs index e5c6f0da7..e301b18cf 100644 --- a/rfirma-app/src-tauri/src/site/application/trust/tests/unmarked.rs +++ b/rfirma-app/src-tauri/src/site/application/trust/tests/unmarked.rs @@ -274,3 +274,8 @@ fn each_distribution_channel_marks_its_local_ca_with_its_own_value() { assert_eq!(mark_of(Channel::Flatpak).value(), "flatpak"); assert_eq!(mark_of(Channel::Windows).value(), "windows"); } + +#[test] +fn macos_keeps_marking_its_local_ca_as_native() { + assert_eq!(mark_of(Channel::MacOs).value(), "native"); +} diff --git a/scripts/packages-manifest.sh b/scripts/packages-manifest.sh index da32ed26b..17870d1c9 100755 --- a/scripts/packages-manifest.sh +++ b/scripts/packages-manifest.sh @@ -19,6 +19,8 @@ row_for() { *.rpm) echo "linux|rpm|true|" ;; *-setup.exe) echo "windows|nsis|false|Windows SmartScreen avisará de que el instalador no está firmado: pulsa «Más información» y «Ejecutar de todas formas»." ;; *-setup.exe.sig) echo "windows|minisign|false|" ;; + *.app.tar.gz) echo "macos|updater|false|" ;; + *.app.tar.gz.sig) echo "macos|minisign|false|" ;; *.dmg) echo "macos|dmg|false|Gatekeeper bloqueará la aplicación, que no está notarizada: pulsa «Abrir igualmente» en Ajustes del Sistema › Privacidad y seguridad." ;; *) return 1 ;; esac @@ -95,7 +97,7 @@ case "$command" in ;; packages) require_manifest "$dir" - jq -r '.[] | select(.format != "minisign") | .file' "$dir/$manifest_name" + jq -r '.[] | select(.format != "minisign" and .format != "updater") | .file' "$dir/$manifest_name" ;; signable) require_manifest "$dir" diff --git a/scripts/tests/packages_manifest_test.sh b/scripts/tests/packages_manifest_test.sh index d23883f00..896a11040 100755 --- a/scripts/tests/packages_manifest_test.sh +++ b/scripts/tests/packages_manifest_test.sh @@ -61,11 +61,27 @@ done "$script" write "$signed" [ "$(jq -r '.[] | select(.format == "minisign") | "\(.platform) \(.file) \(.signable)"' "$signed/paquetes.json")" \ = "windows rfirma_1.0.0_x64-setup.exe.sig false" ] || fail "firma de actualizacion: una fila de windows, no firmable" -[ "$("$script" platform-files "$signed" windows | wc -l)" = 2 ] || fail "firma de actualizacion: viaja con el instalador" -[ "$("$script" files "$signed" | wc -l)" = 3 ] || fail "files lista tambien la firma de actualizacion" +[ "$("$script" platform-files "$signed" windows | wc -l | tr -d ' ')" = 2 ] || fail "firma de actualizacion: viaja con el instalador" +[ "$("$script" files "$signed" | wc -l | tr -d ' ')" = 3 ] || fail "files lista tambien la firma de actualizacion" [ "$("$script" packages "$signed" | tr '\n' ' ')" = "me.sgomez.rfirma.flatpak rfirma_1.0.0_x64-setup.exe " ] \ || fail "packages: los paquetes sin la firma de actualizacion" +macos="$tmp/macos" +mkdir "$macos" +for f in rfirma_1.0.0_aarch64.dmg rfirma_1.0.0_aarch64.app.tar.gz rfirma_1.0.0_aarch64.app.tar.gz.sig; do + : > "$macos/$f" +done +"$script" write "$macos" +[ "$("$script" files "$macos" updater)" = rfirma_1.0.0_aarch64.app.tar.gz ] \ + || fail "actualizacion de macos: el .app.tar.gz es su propio formato" +[ "$("$script" files "$macos" minisign)" = rfirma_1.0.0_aarch64.app.tar.gz.sig ] \ + || fail "actualizacion de macos: su .sig es una firma de actualizacion" +[ "$("$script" platform-files "$macos" macos | wc -l | tr -d ' ')" = 3 ] \ + || fail "actualizacion de macos: viaja con el dmg" +[ -z "$("$script" signable "$macos")" ] || fail "actualizacion de macos: nada firmable con gpg" +[ "$("$script" packages "$macos")" = rfirma_1.0.0_aarch64.dmg ] \ + || fail "packages: el dmg, sin lo que solo baja el actualizador" + unknown="$tmp/unknown" mkdir "$unknown" : > "$unknown/me.sgomez.rfirma.flatpak"