## Summary Updates the Tauri JS packages and Rust crates to their latest 2.x releases, moves `zip` to 9, documents how the HTTP plugin's network transport differs from the WebView's `fetch`, and stops PR runs of Clippy from saving their own Rust cache. ## Changes **JS (`package.json`, `package-lock.json`)** | Package | From | To | | --- | --- | --- | | `@tauri-apps/api` | 2.11.1 | 2.12.3 | | `@tauri-apps/cli` | 2.11.4 | 2.12.2 | | `@tauri-apps/plugin-dialog` | 2.7.2 | 2.8.2 | | `@tauri-apps/plugin-fs` | 2.5.1 | 2.7.0 | | `@tauri-apps/plugin-http` | 2.5.9 | 2.8.2 | | `@tauri-apps/plugin-notification` | 2.3.3 | 2.6.0 | | `@tauri-apps/plugin-opener` | 2.5.4 | 2.7.1 | | `@tauri-apps/plugin-process` | 2.3.1 | 2.4.0 | | `@tauri-apps/plugin-sql` | 2.4.0 | 2.5.0 | | `@tauri-apps/plugin-updater` | 2.10.1 | 2.13.3 | **Rust (`src-tauri/Cargo.toml`, `src-tauri/Cargo.lock`)** - `Cargo.lock` is refreshed, bringing the matching Rust plugin crates to the same versions as the JS packages above, and `tauri` to 2.12.3. - `zip` 8 → 9 (still `default-features = false`, `deflate` only). - tokio's `macros` feature moves from the main dependency to `[dev-dependencies]`, so release builds no longer compile it. **Docs (`docs/architecture/overview.md`)** - New **Network transport** section: remote HTTP goes through `@tauri-apps/plugin-http` and which helper to use for each case, and what the plugin changes for the server on the other end (an `Origin` header that can't be dropped, webpki roots instead of the OS trust store, no response decompression). - Records a known, unverified limitation: stock Ollama doesn't allow the `http://tauri.localhost` origin, so Windows and Android requests may need `OLLAMA_ORIGINS=http://tauri.localhost`. **CI (`.github/workflows/rust-lint.yml`, `docs/development/release.md`)** - Rust Lint also runs on pushes to `master` that touch Rust sources or the Cargo manifest or lockfile. - The Clippy job's `rust-cache` step saves only on `master` (`save-if`), the same rule the build workflows use. PRs restore the master-scoped cache instead of saving a ~600 MiB entry that no other run can read and that pushes master's build and npm caches toward eviction. - `release.md` describes the new trigger and the cache rule. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Mp6GGHh2aY397TuZXyUXKX <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Updated the app’s underlying components for dialogs, file access, networking, notifications, database access, and software updates, along with related tooling. * Updated supporting components for asynchronous tasks and archive handling. * Improved Rust lint workflow triggers and cache handling. * No user-facing feature or behavior changes are noted. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
30 KiB
Release, CI and Updates
How a build reaches a user, and why publishing the draft is the step that ships it.
Git Hooks
Managed by lefthook (lefthook.yml):
- pre-commit: runs
scripts/check_migrations.jsagainst stagedsrc-tauri/migrations/*.sqlfiles to reject CRLF line endings. - pre-push: runs
lint:ts(ESLint),check(type-checking) andlint:rust(cargo fmt --checkandcargo clippy -D warnings) viascripts/pm.js. Each runs only when the commits being pushed touch a file itsglobmatches, and then runs over the whole project, not just those files.
lint runs lint:ts and lint:rust; lint:fix runs their :fix counterparts. Hooks, CI and
scripts/release.js call the per-language scripts, so a JS-only path never needs the Rust
toolchain and a release never rewrites Rust code.
scripts/pm.js picks the package manager at run time: aube when it did the install
(node_modules/.aube-state exists) and is still on PATH, npm otherwise. It also drives
Tauri's beforeDevCommand/beforeBuildCommand, the format/lint:ts:fix steps in
scripts/release.js, and the lint/lint:fix aggregates, so those work unchanged for npm users
and route through aube wherever aube did the install. scripts/release.js always bumps the
version with npm directly, so when it hands format/lint:ts:fix to aube it adds
--no-install --frozen-lockfile, keeping aube from re-resolving the package-lock.json npm just
wrote.
Continuous Integration
GitHub Actions workflows in .github/workflows/:
lint-and-typecheck.yml- runslint:ts,check,test, andbuildon every pull request targetingmaster, except one that touches onlysrc-tauri/,docs/or Markdown.rust-lint.yml("Rust Lint") - runscargo fmt --checkandcargo clippy -D warningson pull requests targetingmaster, and pushes tomaster, that touch Rust sources or the Cargo manifest or lockfile. Clippy runs on a pinned toolchain, so a new stable's lints cannot fail an unrelated PR. The version appears in bothrust-lint.ymland thelint:rust/lint:rust:fixscripts inpackage.json; bump them together and fix whatever the new lints report. Builds and rustfmt use latest stable.release.yml- triggered by pushing a stable version tag (vX.Y.Z). Leaves a draft GitHub release with auto-updater metadata; publishing it by hand is the step that ships it.pre-release.yml("Pre-release") - triggered by pushing a pre-release tag (vX.Y.Z-pre.N). Builds as a draft, without updater metadata, and its ownpublishjob turns it into a pre-release once every build has succeeded.build-desktop.yml,build-android.ymlandbuild-ios.yml- reusable workflows that hold the build jobs for all of the above, switched by a singleprereleaseinput. Desktop builds signed binaries for Linux, Windows and macOS (Intel + Apple Silicon) viatauri-apps/tauri-action; Android builds, lints and signs the APK; iOS builds an unsigned device.ipa(build-ios.ymlalso takes aprereleaseinput, but no signing inputs at all). All take apublishinput (defaulttrue);falseskips the GitHub Release upload, which is whatci.ymluses. Every workflow uploads its build output as a workflow-run Artifact regardless ofpublish— desktop viatauri-action'suploadWorkflowArtifacts, Android and iOS via their ownactions/upload-artifactsteps — so even a non-publishing run leaves every platform's build downloadable from the run summary.bootstrap-ios.yml- one-shot manual-dispatch workflow that regenerates the trackedsrc-tauri/gen/appleXcode scaffold (see Building iOS).ci.yml- buildsmasterwithpublish: falseso the Rust, Gradle and npm caches a release restores from are warm, and so every push/schedule leaves downloadable per-platform builds. See Build caching and speed and Build version.
Both release workflows expect TAURI_SIGNING_PRIVATE_KEY(_PASSWORD) and the ANDROID_KEYSTORE_* /
ANDROID_KEY_* secrets to be configured on the repository. The iOS build needs none — it is
unsigned by design.
Both release.yml and pre-release.yml run a create-release job before the build matrix, which creates (or
reuses) the GitHub release for the tag and passes its numeric ID to build-desktop.yml as releaseId.
Without this, each of the four desktop matrix legs asks tauri-action to find-or-create the release
for the same tag independently; two legs hitting "not found" within the same moment each create a
release, splitting the platform assets across duplicate drafts. Passing a known releaseId skips that
lookup entirely. build-android.yml doesn't take a releaseId — action-gh-release has no such
input — but its job depends on create-release too, so by the time it looks the release up by tag,
create-release has already guaranteed exactly one exists.
Nothing ever uploads to an already-published release. create-release starts with
.github/actions/release-guard, which refuses the run when the tag's release is already
published, or when a pushed tag doesn't match the version in tauri.conf.json. build-desktop.yml
and build-android.yml each run the same guard again before their own upload step — "Re-run failed
jobs" skips create-release, so a re-run after a release has been published would otherwise
overwrite live assets or latest.json under the old tag. The guard uses gh api graphql (its --jq
flag is built into gh, not a separate jq install) so it needs a current gh; the Linux desktop
leg installs one from GitHub's own apt repository, since ubuntu:22.04's packaged version is 2.4.0
and too old.
Both workflows now create their release as a draft — release.yml always did; pre-release.yml
used to publish immediately. A publish job in pre-release.yml, gated on the desktop and
Android builds succeeding (iOS is best-effort: its failure leaves the .ipa missing but does
not block publishing the other platforms), turns the draft into a pre-release with
gh release edit --draft=false. A stable
release is still published by hand, as before (see Cutting a New
Release). Every upload step (build-desktop.yml's releaseDraft,
build-android.yml's draft) sets draft: true to match, regardless of prerelease — only the
dedicated publish step, or a human, ever flips a release to published.
The release body is set once, in create-release, only when the draft is first created — softprops
keeps the existing body on an empty body input, so a re-run never overwrites notes already typed
into the draft.
The repository should also have release immutability turned on (Settings → General →
Releases): it locks a release's assets and Git tag once published, so GitHub itself refuses an
asset upload or tag move the guard above might have missed. It does not lock the title, release
notes, or the prerelease flag, all still editable after publishing; the guard above is still what
stops a stable release from being flipped to a pre-release. A deleted immutable release's tag name
cannot be reused.
Runner pinning
Release binaries must not silently start depending on a newer host than the one they were tested
against, so build-desktop.yml's matrix pins its runners rather than tracking -latest:
- Linux builds on the
ubuntu-latesthost inside anubuntu:22.04container, so the glibc baseline (2.35) stays fixed even after GitHub retires theubuntu-22.04runner image (deprecation begins 2026-09-17, removal 2027-04-17). The container supplies everything but the kernel and Docker itself, both provided by the host. - macOS builds pin
macos-15(Xcode 16.4). Tauri setsMACOSX_DEPLOYMENT_TARGET=10.13by default, and Xcode 26 (the default onmacos-latest) only supports macOS 11+ deployment targets — building there would silently raise the minimum supported macOS version. - Windows builds pin
windows-2025, the same imagewindows-latestcurrently resolves to, so a future move to a newer image is a deliberate version bump rather than a silent one.
Android and the lint job stay on ubuntu-latest: their output doesn't depend on the host OS.
iOS builds pin macos-15 like the macOS desktop legs: the archive must be produced by a
Xcode version the project is actually tested against.
Dependabot (.github/dependabot.yml) opens one grouped PR a month for github-actions updates, so
action versions don't drift the way the runner pins are meant to prevent.
Build caching and speed
GitHub Actions caches can only be restored from the current branch, the base branch of a PR, or the
default branch (master) — never across different tag names. Since nothing builds on master
by itself, every tag-triggered release would start every cache cold. ci.yml exists to
prevent that: it runs build-desktop.yml, build-android.yml and build-ios.yml with
publish: false on a weekly schedule (Fridays, the day after Rust's stable release day), on pushes
to master that touch dependency or workflow files, and on manual dispatch, so the caches those
jobs leave behind on master are the ones a release restores. It skips the push
scripts/release.js makes when it pushes a version bump to master: every cache key below
already ignores the app's own version, so that push can only rebuild for nothing.
- Rust (
swatinem/rust-cache) setssave-if: ${{ github.ref == 'refs/heads/master' }}in both build workflows, so onlyci.yml(or a run ofrelease.yml/pre-release.ymlif one is ever dispatched frommasterdirectly) writes it.rust-lint.yml's Clippy job does the same: itsmasterpush run saves the cache and PRs only restore it, so a PR's ~600 MiBtarget/never lands in a scope no other run can read. - Gradle, in
build-android.yml, usesgradle/actions/setup-gradlewithcache-provider: basic— the MIT-licensed provider, not the default proprietary one — which already defaults to read-only off the default branch. It also caches the Gradle wrapper distribution download, whichactions/setup-java'scache: gradleoption did not. - npm, across all three CI workflows, uses the
.github/actions/npm-installcomposite action instead ofactions/setup-node's built-in cache.scripts/release.jsbumps the version inpackage.json/package-lock.jsonon every release, andsetup-node's cache key is a plain hash ofpackage-lock.jsonwith no fallback — so a release always missed that cache and then saved a fresh entry nothing else could restore. The action keys onscripts/ci/lockfile-hash.js, which hashes the lockfile with the version fields removed, and falls back to the newest same-OS/arch entry on a miss; it also only saves onmaster. - Android's Rust cache also persists the generated Kotlin. The build scripts of
tauriandwrywrite Kotlin into the gitignoredsrc-tauri/gen/android/app/src/main/java/com/karelian/aventura/generated/and declarererun-if-changedon it, so on a fresh checkout cargo marks both crates dirty ("the file ... is missing") and rebuilds every plugin andtauri-runtime-wrybehind them, in each of the four cargo builds.cache-directoriesstores that directory in the same entry astarget/, so the files come back with the mtimes the cached fingerprints expect and only the app crate compiles. The option is not part of rust-cache's key, so an exact hit never re-saves: add a path, or change what is persisted, by bumping the key suffix (android-v2), which makes the nextmasterrun cold. The saving is small beside the app crate's release compile (about 70-95s per build) and disappears in run-to-run noise. The secondaarch64build still recompiles the app crate, because the CLI rewritesgen/android/tauri.settings.gradlebetween the two builds. Cargo prints dirty reasons only under-v: setCARGO_TERM_VERBOSE: trueon the build step to see them. iOS cargo output stays hidden underxcodebuildeven then, but neither crate generates files on iOS.
The Windows desktop leg builds on a ReFS Dev Drive
created by samypr100/setup-dev-drive (a dynamic VHDX, recreated every run — the drive itself is
not cached). The action copies the checkout onto it and exports DEV_DRIVE_WORKSPACE; the npm
install, build-version, rust-cache and tauri-action steps all take that directory (falling
back to . on the other legs, where it is unset), so node_modules, build/ and target/ live on
the Dev Drive. CARGO_HOME and npm_config_cache are pointed there too, so the cache restores land
on it. The rustup toolchain stays on C:. Local actions (uses: ./.github/actions/...) and
release-guard still resolve against the original checkout, which has the same contents. The
Dev Drive is not marked trusted or added to Defender's exclusions: the runner image
turns off Defender's real-time monitoring
machine-wide, not just for its C:\ and D:\ exclusions, and a run that logged
Get-MpComputerStatus on the Dev Drive leg reported RealTimeProtectionEnabled: False.
build-android.yml also builds --apk only (the AAB was built and discarded on every run) and
targets aarch64, armv7 and x86_64 (32-bit x86 served only old emulators). Both build
workflows pass --config src-tauri/tauri.release.conf.json, which sets build.features to [] for
that build: tauri.conf.json's own features: ["devtools"] is only meant for tauri dev (the
plugin is registered under debug_assertions in src-tauri/src/lib.rs), and on Android the CLI's
own plugin-init build and Gradle's build used to end up on different feature sets for the same
target, forcing one target to compile twice.
The .rpm payload is compressed with xz level 6 (bundle.linux.rpm.compression in
tauri.conf.json) instead of Tauri's default gzip level 6. xz produces the smaller archive, as
expected, but it also bundles faster on this runner: the Bundling ... .rpm step dropped from
~60s (gzip, run 35175189321) to ~14-18s (xz, runs 35229623700 and 35230697779), so this isn't a
size-for-time tradeoff. flate2's pure-Rust deflate is apparently the slower path here, not xz's
liblzma binding. Any rpm from 4.8 on reads xz payloads. The other formats have no compression
setting in Tauri's config except NSIS, whose default is already lzma.
Build version
A publish: false run (ci.yml) never bumped tauri.conf.json's version, so without
intervention every build-validation run would reuse whatever version master last shipped —
indistinguishable bundle filenames and in-app "About" text across every commit since. Both
build workflows call .github/actions/build-version, which on a non-publishing run writes
ci-version.conf.json (a {"version": "<base>-sha<short-sha>"} override, gitignored, never
committed) and emits the --config list that carries it to tauri build/tauri android build, merged on top of tauri.release.conf.json. Both workflows take that list from the
action's config-args output, so the rule lives in one place.
tauri-action re-resolves the same --config list itself to name
workflow artifacts and set its appVersion output, so the desktop and Android legs, the
bundle filenames, and getVersion() inside the running app all agree on one
<base>-sha<short-sha> string.
The suffix is appended, not substituted, for two reasons that both require a valid X.Y.Z
prefix: Tauri's config deserializer validates version as semver (a bare SHA fails to
parse), and src/lib/utils/version.ts's isNewerVersion — the Android update check — expects
the same major.minor.patch shape and silently refuses to compare anything else. A useful
side effect: since a CI build's version is a semver pre-release of the last release, its
own update check correctly reports the real release as newer, instead of "up to date".
The suffix is prefixed with sha rather than left bare because a short SHA that happens to
be all digits with a leading zero (about 0.4% of commits) is not valid semver on its own —
sha makes the identifier alphanumeric, so it always parses.
A real release (release.yml/pre-release.yml, publish: true) never takes this path —
its version is the one scripts/release.js bumped, and it must stay exactly what the pushed
tag names.
Deferred: per-ABI parallel Android builds. The four (now three) Android ABIs are still built one
after another by a single tauri android build invocation. A matrix job per target — each building
its Rust .so and uploading it plus the tauri/wry-generated Gradle sources, followed by a packaging
job that runs Gradle with the rustBuild*Release tasks excluded — would let them build in parallel.
It is deferred: it bypasses the CLI's supported build flow, and with a warm cache each target's Rust
build is already only 1-1.5 minutes, so the likely saving is a few minutes, not worth the added
maintenance surface yet.
The Updater
src/lib/services/updater.ts answers one question on two platforms that share no machinery
for it. UpdateInfo.canInstallInApp is the flag that tells them apart, and the dialog
(src/lib/components/updater/UpdateDialog.svelte) branches on it rather than on the platform.
Desktop uses @tauri-apps/plugin-updater: it fetches the latest.json named by the
updater.endpoints entry in tauri.conf.json, verifies its signature against the pubkey
there, and installs the new build itself.
Mobile has no updater at all, and this is not a configuration problem.
tauri-plugin-updater declares Android support level none and has no iOS support either
(its updater_os() has branches for linux/macos/windows only), so check() fails with
UnsupportedOs before a single request is sent. There is no install path either: an APK/IPA
is installed by the system (package installer, sideloading tool), not by the app it replaces.
The mobile path therefore calls the GitHub Releases API directly, compares the tag against
getVersion() using src/lib/utils/version.ts, and opens the platform's own package asset
(.apk on Android, .ipa on iOS) in the browser. String comparison is not adequate for
that — '0.10.0' > '0.9.0' is false lexically — which is why the comparison is a tested
module of its own.
Two things must stay in step, or the platforms will offer different versions to their users:
the RELEASE_REPO constant in updater.ts and the updater.endpoints URL in
tauri.conf.json.
A draft release is invisible to the updater. release.yml publishes a draft (a
non-pre-release run of build-desktop.yml sets releaseDraft), and both paths resolve /releases/latest, which GitHub defines as the
latest published, non-pre-release release. Until the draft is published by hand, the
desktop endpoint 404s and the API returns the previous release — so the last step of every
release is publishing the draft on GitHub. Nothing reaches users before that.
The desktop check surfaces that state honestly rather than as a generic failure: a 404 becomes
the no-release kind ("it may still be a draft"), distinct from network and unsupported.
The release notes users read are the GitHub release body, on both platforms. They are not
taken from latest.json, whose notes field is written by tauri-action at build time from
the fixed releaseBody string in build-desktop.yml — which is a placeholder, not a changelog, and
cannot be otherwise, since the notes are written after the build. releaseNotesFor therefore
fetches the release from the API and uses its body, falling back to latest.json if the call
fails; the update installs either way. Two consequences:
- Editing a published release's text on GitHub changes what every client shows, with no rebuild and no new version.
- The notes must be written before the draft is published, because publishing is what makes the release visible to the check. A release published with the placeholder still in it will show that placeholder.
The fetched notes are used only when the release tag matches the version being offered — notes belonging to a different release are worse than none.
A .deb install is deliberately not updated in place. The plugin would attempt it —
install_deb writes the package to a temp dir and runs dpkg -i through pkexec, falling
back to zenity/kdialog and finally to a terminal sudo that a windowed app has no terminal
for — but that chain has too many ways to end half-finished for something the user starts
with one click, and the package manager is the thing that owns that install anyway. So the
check reports canInstallInApp: false with manualInstallReason: 'deb-package' and the
dialog opens the releases page instead.
An unpackaged build never installs either, and this one is a safety guard. On Linux the
plugin's extract_path is the running executable, so in tauri dev "Download and install"
moves the dev binary into a TempDir, writes the release AppImage over it, then drops the
TempDir — deleting the backup — and reports success. The developer is left with a 100 MB
AppImage where their build was. getBundleType() returns null for a build the bundler never
touched, which is exactly that case, so it is routed to the browser with
manualInstallReason: 'unpackaged'.
Note that on macOS bundle_type() falls back to App rather than None, so this guard does
not fire there.
.rpm currently still installs in place, through the same privileged-helper chain.
One more limit: the mobile check is unauthenticated, so it shares GitHub's per-IP rate limit. A 403 is reported as a network-kind error.
Building Release Binaries
Cutting a New Release
npm run release -- <patch|minor|major|prerelease|x.y.z> [--dry-run]
(wraps scripts/release.js) automates version bumps:
- Bumps the version in
package.json,package-lock.json,src-tauri/tauri.conf.json,Cargo.toml, andCargo.lock. - Commits the bump on
masterand tags itvX.Y.Z. It refuses to run from any other branch. - Pushes the branch and the tag in one atomic push, so the version on
masteris the version released and the tag can never point at a commitmasterlacks.
Note the --: without it npm consumes the flags before the script sees them.
Every precondition — a clean tree, a version that moves forward, a tag that does not already exist
locally or on the remote, and a remote branch that is an ancestor of the local one — is checked
before anything is written. A failure after that point resets the branch to where it started and
deletes the tag. Use --dry-run to run the checks and stop.
Only X.Y.Z and X.Y.Z-pre.N are accepted. Other pre-release spellings are valid semver but match
neither workflow trigger, so they would tag and build nothing.
Pushing a stable tag (vX.Y.Z) triggers release.yml; pushing a pre-release tag (vX.Y.Z-pre.N, via the
prerelease bump type) triggers pre-release.yml. See Continuous Integration.
The script does not finish the release. release.yml publishes a draft, and a draft is
invisible to /releases/latest — which is where the desktop updater and the mobile check
look. Publishing the draft on GitHub is the step that actually ships it; until then no existing
install will see the new version. See The Updater.
scripts/version.js holds the version arithmetic and scripts/version.test.js covers it
(vitest.config.ts includes scripts/**/*.test.js for this).
Building Desktop
npx tauri build
Building iOS
The iOS build produces an unsigned ARM64 device .ipa: no Apple certificates,
provisioning profiles or signing secrets are involved at any step, and none are configured
in CI. An unsigned .ipa cannot be installed with Xcode or Finder — it targets
sideloading tools (AltStore, Sideloadly, TrollStore) or a future signing step.
The Xcode scaffold (src-tauri/gen/apple/) is tracked in git, like gen/android/.
Do NOT run npx tauri ios init on a working tree that already has it. To (re)generate it —
first setup, or after a major Tauri CLI upgrade — run the "Bootstrap iOS scaffold"
workflow (.github/workflows/bootstrap-ios.yml, manual dispatch): it runs tauri ios init
on macos-15, smoke-tests a full unsigned build, and uploads the scaffold plus the test
.ipa as workflow artifacts for a maintainer to commit.
The iOS-specific Info.plist keys (camera permission for QR pairing, local-network
permission and the NSAllowsLocalNetworking ATS exception for LAN sync and local LLM
servers, export-compliance) live in src-tauri/Info.ios.plist, which the Tauri CLI
merges into every iOS build's Info.plist — tauri ios dev, Xcode builds and the CI
script alike — so the keys are not tied to any one build path.
# macOS only, from the repo root
npx tauri ios init --skip-targets-install # first time only (or bootstrap workflow)
# Unsigned release .ipa (any --config args are forwarded, e.g. the CI version rule)
scripts/build-ios-unsigned.sh --config src-tauri/tauri.release.conf.json
The .ipa is written to the repo root as
Aventuras_v<version>_ios-arm64-unsigned.ipa and verified (arm64 binary, unsigned,
Payload/ layout) before the script exits 0.
Mechanics worth knowing:
- The script temporarily patches
project.ymlwith the four signing-disabled settings and runstauri ios build --target aarch64 --archive-only, which stops afterxcodebuild archiveand skips the CLI's IPA-export phase (the part that requires signing). Do not rely on the CLI's ownCODE_SIGNING_*defaults or xcodebuild runner args for this: neither reaches the signing validation insidexcodebuild archive— the patch is what disables signing, and it is restored on exit. - The archive lands at
src-tauri/gen/apple/build/<target>_iOS.xcarchive, and the app bundle atProducts/Applications/Aventuras.appinside it. - The app icon comes from
src-tauri/icons/ios/.tauri ios initfillsgen/apple/Assets.xcassets/AppIcon.appiconsetwith Tauri's placeholder logo, soscripts/sync-ios-icons.shcopies the Aventuras set over it; the build script and the bootstrap workflow both run it. Run it yourself before a localtauri ios devor Xcode build on a freshly initialised scaffold. tauri ios devdrops the desktop-onlydevtoolsfeature automatically throughsrc-tauri/tauri.ios.conf.json, which Tauri merges into everytauri ioscommand.
CI builds iOS in .github/workflows/build-ios.yml (reusable, on macos-15), wired into
release.yml, pre-release.yml and ci.yml exactly like the Android leg. If gen/apple
is not committed, that workflow runs tauri ios init on the runner itself — builds work
from a bare checkout — but a committed scaffold (via the bootstrap workflow) is the
preferred, reviewed state. Both workflows install XcodeGen (brew install xcodegen) when
missing: the Tauri CLI only auto-installs it interactively, which a runner cannot do.
Building Android
IMPORTANT: The Android project scaffold (src-tauri/gen/android/) is tracked in git.
Do NOT run npx tauri android init as it will overwrite customizations.
# One-time: detect/export ANDROID_HOME and NDK_HOME
source scripts/android-setup.sh
# Dev build + deploy to device/emulator
npx tauri android dev
# Release build (unsigned APK)
npx tauri android build
# Or: quick local debug APK build (auto-detects SDK/NDK/JDK)
./compileApk.sh
The unsigned release APK will be at:
src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk
Injecting Kotlin into the generated WebView. wry generates
src-tauri/gen/android/app/src/main/java/com/karelian/aventura/generated/RustWebView.kt fresh on
every build — it's gitignored and must never be hand-edited. wry's build.rs substitutes an
env var named WRY_<FILE_STEM>_CLASS_EXTENSION (file stem uppercased, e.g.
WRY_RUSTWEBVIEW_CLASS_EXTENSION for RustWebView.kt) into a {{class-extension}} placeholder in
that file. The repo-root .cargo/config.toml sets this to inject a one-line
onCreateInputConnection override (used for the incognito-keyboard setting) that delegates to a
normal, git-tracked Kotlin class. cargo's config discovery walks up from the build's working
directory, so this only works because both npx tauri android ... and ./compileApk.sh run with
the repo root as their working directory — confirmed by tracing BuildTask.kt's workingDir.
This substitution is undocumented wry internals, not a public API — verified by reading
wry-0.55.1's build.rs directly (search the vendored crate's registry checkout for
CLASS_EXTENSION if this ever needs re-verifying after a wry bump). If the override silently
stops appearing in the generated RustWebView.kt, that build script — not any docs page — is
where the renamed placeholder or env var will be found.
scripts/check_wry_injection.js runs after tauri android build in build-android.yml and
fails the build if the lines .cargo/config.toml injects are absent from the generated
RustWebView.kt, so a wry bump that breaks the substitution stops the release instead of
shipping a dead setting.
Signing APK
# Create keystore (first time only)
keytool -genkey -v -keystore release.keystore -alias myalias -keyalg RSA -keysize 2048 -validity 10000
# Align APK
zipalign -v 4 app-universal-release-unsigned.apk app-aligned.apk
# Sign APK
apksigner sign --ks release.keystore --ks-key-alias myalias --out app-release.apk app-aligned.apk