Compare commits

..

No commits in common. "main" and "relm4" have entirely different histories.
main ... relm4

26 changed files with 744 additions and 6391 deletions

View file

@ -1,95 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
macos:
runs-on: macos-arm64
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
with:
path: carrier
# Cargo.toml depends on fumi-core via a relative sibling path
# (../fumi/core), matching how it's checked out locally. Fetched as
# its own sibling checkout here so that path resolves the same way.
- uses: https://code.forgejo.org/actions/checkout@v4
with:
repository: randogoth/fumi
ref: main
path: fumi
# Cache key includes the OS explicitly: target/ artifacts are
# platform-specific, so macOS and Linux must never share a cache key
# or they'd clobber each other's compiled objects.
- uses: https://code.forgejo.org/actions/cache@v4
with:
path: |
~/.cargo/registry
carrier/target
fumi/target
key: cargo-macos-${{ hashFiles('carrier/Cargo.lock') }}
restore-keys: cargo-macos-
# Forgejo auto-injects GITHUB_TOKEN (GH Actions compatibility), scoped
# to this Forgejo instance. Nix auto-detects that env var and assumes
# it's a github.com credential, so it sends it to api.github.com when
# fetching nixpkgs tarballs for devbox - which rejects it with 401.
# Clearing it here falls back to unauthenticated (fine for public
# nixpkgs fetches, same as this succeeds locally with no token set).
- run: devbox run check
working-directory: carrier
env:
GITHUB_TOKEN: ""
linux:
runs-on: linux-x86_64
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
with:
path: carrier
- uses: https://code.forgejo.org/actions/checkout@v4
with:
repository: randogoth/fumi
ref: main
path: fumi
- uses: https://code.forgejo.org/actions/cache@v4
with:
path: |
~/.cargo/registry
carrier/target
fumi/target
key: cargo-linux-${{ hashFiles('carrier/Cargo.lock') }}
restore-keys: cargo-linux-
- run: devbox run check
working-directory: carrier
env:
GITHUB_TOKEN: ""
windows:
runs-on: windows-x86_64
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
with:
path: carrier
- uses: https://code.forgejo.org/actions/checkout@v4
with:
repository: randogoth/fumi
ref: main
path: fumi
- uses: https://code.forgejo.org/actions/cache@v4
with:
path: |
~/.cargo/registry
carrier/target
fumi/target
key: cargo-windows-${{ hashFiles('carrier/Cargo.lock') }}
restore-keys: cargo-windows-
# No devbox on Windows: the runner host itself is the MSYS2 MINGW64
# toolchain that windows/setup.sh installs, so this runs the same
# commands devbox's `check` script runs on the other two platforms
# straight from cargo.
- run: cargo fmt --check && cargo build && cargo test
working-directory: carrier
shell: bash

View file

@ -1,229 +0,0 @@
name: Release
on:
push:
tags: ["v*"]
jobs:
macos:
runs-on: macos-arm64
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
with:
path: carrier
- uses: https://code.forgejo.org/actions/checkout@v4
with:
repository: randogoth/fumi
ref: main
path: fumi
- uses: https://code.forgejo.org/actions/cache@v4
with:
path: |
~/.cargo/registry
carrier/target
fumi/target
key: cargo-macos-${{ hashFiles('carrier/Cargo.lock') }}
restore-keys: cargo-macos-
- run: devbox run build
working-directory: carrier
env:
GITHUB_TOKEN: ""
- run: mv target/release/carrier target/release/carrier-macos-arm64
working-directory: carrier
# This runner is a laptop that's frequently asleep/offline, so this
# job must not gate the release (and must not be gated by it either).
# Instead of going through actions/forgejo-release - which only knows
# how to blind-create a release, not append to one that may already
# exist - this polls for the release the linux+flatpak job creates
# and appends its own asset directly via the Forgejo API, landing
# next to whatever binaries are already there. grep (not jq, whose
# presence here isn't guaranteed) is safe for pulling the id out: per
# Forgejo's structs.Release, ID is the first declared field and Go's
# encoding/json marshals in declaration order, ahead of the nested
# per-asset "id" fields in the "assets" array.
- run: |
REPO="${{ forge.repository }}"
TAG="${{ forge.ref_name }}"
FORGEJO="${{ env.FORGEJO_SERVER_URL }}"
FORGEJO="${FORGEJO%%/}"
TOKEN="${{ forge.token }}"
FILE="target/release/carrier-macos-arm64"
NAME="carrier-macos-arm64"
release_id=""
for i in $(seq 1 40); do
body=$(curl --retry 5 -sS -H "Authorization: token $TOKEN" "$FORGEJO/api/v1/repos/$REPO/releases/tags/$TAG")
release_id=$(echo "$body" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
if [ -n "$release_id" ]; then
break
fi
echo "release for $TAG not ready yet, waiting 15s ($i/40)"
sleep 15
done
if [ -z "$release_id" ]; then
echo "release for $TAG never appeared after 10 minutes" >&2
exit 1
fi
curl --retry 5 --fail -sS -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: multipart/form-data" \
-F "attachment=@$FILE" \
"$FORGEJO/api/v1/repos/$REPO/releases/$release_id/assets?name=$NAME"
working-directory: carrier
# Same non-gating strategy as the macos job above: this runner is a
# desktop machine that isn't guaranteed to be on when a tag is pushed,
# so it must never gate the release (and must not be gated by it
# either). It builds and bundles via windows/bundle.sh - the same
# script windows/setup.sh runs for local one-command builds - wraps
# the bundle in an Inno Setup installer (windows/installer.sh; Inno
# lives in this runner host's user profile), then appends both the
# portable zip and the setup.exe to the release the linux+flatpak jobs
# create, with the same poll-for-the-release the macos job does.
windows:
runs-on: windows-x86_64
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
with:
path: carrier
- uses: https://code.forgejo.org/actions/checkout@v4
with:
repository: randogoth/fumi
ref: main
path: fumi
- uses: https://code.forgejo.org/actions/cache@v4
with:
path: |
~/.cargo/registry
carrier/target
fumi/target
key: cargo-windows-${{ hashFiles('carrier/Cargo.lock') }}
restore-keys: cargo-windows-
- run: bash windows/bundle.sh
working-directory: carrier
shell: bash
- run: bash windows/installer.sh
working-directory: carrier
shell: bash
- run: |
REPO="${{ forge.repository }}"
TAG="${{ forge.ref_name }}"
FORGEJO="${{ env.FORGEJO_SERVER_URL }}"
FORGEJO="${FORGEJO%%/}"
TOKEN="${{ forge.token }}"
VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)
FILES="carrier-windows-$VERSION.zip carrier-windows-$VERSION-setup.exe"
release_id=""
for i in $(seq 1 40); do
body=$(curl --retry 5 -sS -H "Authorization: token $TOKEN" "$FORGEJO/api/v1/repos/$REPO/releases/tags/$TAG")
release_id=$(echo "$body" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
if [ -n "$release_id" ]; then
break
fi
echo "release for $TAG not ready yet, waiting 15s ($i/40)"
sleep 15
done
if [ -z "$release_id" ]; then
echo "release for $TAG never appeared after 10 minutes" >&2
exit 1
fi
for FILE in $FILES; do
curl --retry 5 --fail -sS -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: multipart/form-data" \
-F "attachment=@$FILE" \
"$FORGEJO/api/v1/repos/$REPO/releases/$release_id/assets?name=$FILE"
done
working-directory: carrier
shell: bash
linux:
runs-on: linux-x86_64
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
with:
path: carrier
- uses: https://code.forgejo.org/actions/checkout@v4
with:
repository: randogoth/fumi
ref: main
path: fumi
- uses: https://code.forgejo.org/actions/cache@v4
with:
path: |
~/.cargo/registry
carrier/target
fumi/target
key: cargo-linux-${{ hashFiles('carrier/Cargo.lock') }}
restore-keys: cargo-linux-
- run: devbox run build
working-directory: carrier
env:
GITHUB_TOKEN: ""
- run: mv target/release/carrier target/release/carrier-linux-x86_64
working-directory: carrier
- uses: https://code.forgejo.org/forgejo/upload-artifact@v5
with:
name: carrier-linux-x86_64
path: carrier/target/release/carrier-linux-x86_64
# Builds from source inside flatpak-builder's own sandbox, via the
# rust-stable SDK extension (same as com.randogoth.carrier.json already
# does for local `devbox run package`). Reusing the linux job's binary
# was tried and reverted: that binary is dynamically linked against ~100
# libraries at absolute /nix/store paths, including its own ELF
# interpreter - none of which exist inside the Flatpak sandbox, so it
# can never execute there regardless of how it's packaged in.
flatpak:
runs-on: linux-x86_64
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
with:
path: carrier
- uses: https://code.forgejo.org/actions/checkout@v4
with:
repository: randogoth/fumi
ref: main
path: fumi
- run: devbox run bundle-flatpak
working-directory: carrier
env:
GITHUB_TOKEN: ""
- uses: https://code.forgejo.org/forgejo/upload-artifact@v5
with:
name: carrier-x86_64.flatpak
path: carrier/carrier-x86_64.flatpak
# forgejo-release.sh (the engine behind actions/forgejo-release) always
# does a blind create-then-upload of the release for the tag, with no
# check for one already existing - running it from two parallel jobs
# against the same tag either hard-fails the second create (override:
# false) or races a delete-and-recreate that can wipe out the first job's
# just-uploaded assets (override: true). So platform jobs only ever
# produce artifacts; exactly one job, gated on both finishing, does the
# single release create + upload. macos is deliberately left out of
# needs (see that job): re-running this job after macos has appended its
# asset will wipe that asset out via override - rerun macos again after
# to restore it.
release:
needs: [linux, flatpak]
runs-on: linux-x86_64
steps:
- uses: https://code.forgejo.org/forgejo/download-artifact@v7
with:
path: dist/release
merge-multiple: true
- uses: https://code.forgejo.org/actions/forgejo-release@v2.13.4
with:
direction: upload
release-dir: dist/release
# Safe here specifically because this is the only job that ever
# calls forgejo-release for a given tag: re-running this same job
# for the same tag (e.g. a manual rerun) replaces its release
# cleanly instead of failing on "already exists".
override: true

7
.gitignore vendored
View file

@ -1,16 +1,9 @@
/target
.flatpak
.flatpak-builder
.flatpak-repo
.flatpak-build
*.flatpak
carrier-linux-x86_64
result
result-*
*.key
*.db
*.db-wal
*.db-shm
/dist
carrier-windows-*.zip
carrier-windows-*-setup.exe

View file

@ -1,12 +0,0 @@
---
version: "0.1.2"
level: copilot
---
This format is based on [AI-DECLARATION.md](https://ai-declaration.md/en/0.1.2).
## Notes
- I directed the work step by step, reviewed and committed the results, and verified the UI live; build, packaging and cross-platform scripts.
- Mistral Vibe acted on a migration from Iced to Replm4, the follow-up refinement sessions, and the Flatpak and MSYS2 packaging.
- Claude Code was used throughout, prompted and reviewed by me at each step. Its main task was GTK4/adwaita refinements.

168
Cargo.lock generated
View file

@ -116,9 +116,8 @@ dependencies = [
[[package]]
name = "carrier"
version = "0.3.1"
version = "0.1.0"
dependencies = [
"fluent-bundle",
"fumi-core",
"gtk4",
"libadwaita",
@ -126,8 +125,6 @@ dependencies = [
"rand_core",
"relm4",
"tempfile",
"unic-langid",
"winresource",
]
[[package]]
@ -280,17 +277,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "ed25519"
version = "2.2.3"
@ -372,41 +358,6 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fluent-bundle"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4"
dependencies = [
"fluent-langneg",
"fluent-syntax",
"intl-memoizer",
"intl_pluralrules",
"rustc-hash",
"self_cell",
"smallvec",
"unic-langid",
]
[[package]]
name = "fluent-langneg"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0"
dependencies = [
"unic-langid",
]
[[package]]
name = "fluent-syntax"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198"
dependencies = [
"memchr",
"thiserror",
]
[[package]]
name = "flume"
version = "0.11.1"
@ -430,7 +381,7 @@ dependencies = [
[[package]]
name = "fumi-core"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"chacha20poly1305",
"data-encoding",
@ -893,25 +844,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "intl-memoizer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f"
dependencies = [
"type-map",
"unic-langid",
]
[[package]]
name = "intl_pluralrules"
version = "7.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
dependencies = [
"unic-langid",
]
[[package]]
name = "itoa"
version = "1.0.18"
@ -1194,12 +1126,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@ -1234,12 +1160,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "self_cell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
[[package]]
name = "semver"
version = "1.0.28"
@ -1431,37 +1351,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
[[package]]
name = "tokio"
version = "1.53.1"
@ -1553,39 +1442,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "type-map"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
dependencies = [
"rustc-hash",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unic-langid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
dependencies = [
"unic-langid-impl",
]
[[package]]
name = "unic-langid-impl"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
dependencies = [
"tinystr",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
@ -1768,16 +1630,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "winresource"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"toml",
"version_check",
]
[[package]]
name = "x25519-dalek"
version = "2.0.1"
@ -1810,12 +1662,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
[[package]]
name = "zeroize"
version = "1.9.0"
@ -1836,16 +1682,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"serde",
"zerofrom",
]
[[package]]
name = "zmij"
version = "1.0.23"

View file

@ -1,25 +1,19 @@
[package]
name = "carrier"
version = "0.3.1"
version = "0.1.0"
edition = "2021"
description = "A minimalist smolmail client built on fumi-core, with a Relm4 UI"
license = "Apache-2.0"
[dependencies]
fumi-core = { path = "../fumi/core" }
# v4_10 unlocks gtk::FileDialog, used for the backup export/import pickers.
gtk = { version = "0.9", package = "gtk4", features = ["v4_10"] }
gtk = { version = "0.9", package = "gtk4" }
# Relm4 pulls libadwaita in; the direct entry only raises the API level,
# since Cargo unions features and the rows and dialogs used here are 1.5.
libadwaita = { version = "0.7", features = ["v1_5"] }
relm4 = { version = "0.9", features = ["libadwaita"] }
rand_core = { version = "0.6", features = ["getrandom"] }
qrcodegen = "1"
fluent-bundle = "0.16.0"
unic-langid = "0.9.6"
[build-dependencies]
winresource = "0.1"
[dev-dependencies]
tempfile = "3"

201
LICENSE
View file

@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/LICENSE-2.0
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -1,39 +1,31 @@
# carrier
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Built with Devbox](https://www.jetify.com/img/devbox/shield_galaxy.svg)](https://github.com/jetify-com/devbox/) [![AI-DECLARATION: copilot](https://img.shields.io/badge/䷼%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md)
A simple desktop client for [Smol Mail](../smolmail). One compact window: the message list above, the article below. It's built on [fumi-core](../fumi), the reference CLI's own library, with a [Relm4](https://relm4.org) (GTK4) UI made from libadwaita's own widgets, so it follows your theme and accent colour. One 32-byte seed is the only secret; mail stays sealed at rest and opens on demand.
![carrier showing an inbox and an open message](docs/screenshots/carrier.png)
A minimalist desktop client for [Smol Mail](../smolmail): one compact window — the list above, the article below — built on [fumi-core](../fumi), the reference CLI's own library, with a [Relm4](https://relm4.org) (GTK4) UI built from libadwaita's own widgets, so it follows the user's theme and accent colour. One 32-byte seed is the only secret; mail stays sealed at rest and opens on demand.
## What it does
- Create an identity from a seed, or restore one you already have. The seed shows once, at creation.
- Pin a server's key, either by pasting the operator's key or connecting and trusting what the server presents. Then register a username, or recall an address you already own.
- Fetch, read, reply to and send sealed mail. Your inbox, requests and sent mail each live in their own folder.
- Close a compose window from the sidebar and carrier saves it as a draft automatically. Drafts live beside your mail store, in a folder that shows up once you have one; Cancel is the only way to throw one away.
- Add your own `Key: value` headers when writing mail, one per line under the subject. carrier checks each line against the frontmatter rules and won't let you override its own fields (subject, reply-to, in-reply-to, accept). When you open mail with custom headers, they show up between the meta line and the body, and replying offers to carry them into your draft, where you can still edit them.
- Accept or block someone who writes to you with a self-certifying reply address. Accepting also attaches an accept token to that contact automatically.
- Import contacts from a smol:// link and see whether they're verified or just trust-on-first-use. Each contact's detail view names the server their address lives on, whether you've pinned that server's key, and a button to pin it if you haven't. Your own identity opens the same way, with a QR code next to your address in the header bar and your key, fingerprint and URI below.
- Turn on strict first contact to make carrier pause before writing to someone new whose address carries no key. With it on, carrier offers to fetch and pin the server's key first, so you can check it against what the operator published, before the send goes out. A smol:// address with a key that differs from the one on file asks before replacing it, strict mode or not.
- Rotate your key with a confirmation step and a network timeout. Export and import gsmol backups that never contain the seed. Log out to erase your local files and return to onboarding. Your preferences live in a small sidecar file next to the store, so they survive a restart.
- Create or restore an identity from a seed, shown once at creation time.
- Pin a server's key — paste the operator's, or connect and pin what it presents — then register a username or recall an existing address.
- Fetch (with cancel), read, reply and send sealed mail; the inbox, the requests tier and sent copies each keep their own folder.
- Composes closed by any sidebar trip are stashed as drafts — kept beside the store, reopened from a folder that appears only once one exists; Cancel is the only way to throw one away.
- Accept or block a requester when their mail carried a self-certifying reply address; accept tokens attach to accepted contacts automatically.
- Contacts with verified/tofu marks and smol:// import; your own identity opens in the same detail view, its address in the header bar with a QR share beside it, key, fingerprint and URI below.
- Key rotation with a confirm-first step, server pin management, a network timeout, and gsmol backup export/import that never contains the seed.
## Download
## Layout
Each release carries its binaries on the forge: [downloads for carrier](https://code.randogoth.com/randogoth/carrier/releases).
### Flatpak
```bash
flatpak install --user carrier-x86_64.flatpak
flatpak run com.randogoth.carrier
```
src/main.rs config directory, demo seeding, launch
src/app.rs state and update; blocking calls run on the worker pool
src/view.rs the Relm4 component: widgets, the op adapter, the re-render
src/ops.rs fumi-core calls, key/read-mark files, and the offline tests
```
### Windows
## State
Download the [installer](https://code.randogoth.com/randogoth/carrier/releases/download/v0.2.0/carrier-windows-0.2.0-setup.exe) and run it
Everything lives under `$XDG_CONFIG_HOME/carrier` (default `~/.config/carrier`): `carrier.key` is the master seed, written 0600; `carrier.db` is fumi-core's sealed store; `read.txt` is carrier's own read marks, one hex id per line.
## Build
## Run and verify
```
devbox run run # build release and launch
@ -41,21 +33,23 @@ devbox run check # fmt --check, build, tests
devbox run test # tests only
```
Tests run offline against an in-memory store and cover seed and key parsing, listing and opening a sealed envelope end to end, importing a contact, a full backup round-trip, pin labels and the date formatter. `scripts/devbox-libs.sh` adds the GUI stack's runtime libraries (wayland, xkbcommon, EGL, vulkan) to the library path inside the devbox shell, since they're loaded at runtime and nothing that links the binary can provide them.
The tests run offline against an in-memory store: seed and key parsing, a sealed envelope listed and opened end to end, contact import, a full backup round-trip, pin labels and the date formatter. `scripts/devbox-libs.sh` puts the GUI stack's runtime libraries (wayland, xkbcommon, EGL, vulkan) on the library path inside the devbox shell — they are dlopened, so nothing that links the binary can provide them.
### Packaging
## Status
Run `devbox run package` to build carrier and install it as a Flatpak, using `com.randogoth.carrier.json`. It picks up the binary, desktop file, metainfo and icon from the repo, and devbox provides the tooling (`flatpak`, `flatpak-builder`, `appstreamcli`). The one thing outside devbox is the flatpak runtime itself; install it once:
Work in progress: the UI is unexercised against a live server — the store, crypto and address paths are covered by tests, the network flows (register, fetch, send, rotate) are wired to fumi-core's operations but only smoke-tested as far as launching the window.
## Packaging
`devbox run package` builds and installs the app as a Flatpak from
`com.randogoth.carrier.json` — the binary, the desktop file, the metainfo and
the icon all come from the repo, and the tooling (`flatpak`, `flatpak-builder`,
`appstreamcli`) lives in the devbox environment. The flatpak runtime is the one
dependency outside devbox; install it once:
flatpak install --user flathub org.gnome.Sdk//51 org.freedesktop.Sdk.Extension.rust-stable//26.08
After that, carrier lives in your user flatpak and runs with `flatpak run com.randogoth.carrier`. Inside the sandbox it keeps its config under `~/.var/app/com.randogoth.carrier/config/carrier`, so a flatpak install starts with a fresh identity rather than your dev build's account.
### Windows
Install MSYS2 (`winget install MSYS2.MSYS2`), open the **MSYS2 MINGW64** shell, and run the setup script from your carrier checkout:
git clone ssh://git@code.randogoth.com:2222/randogoth/carrier.git
cd carrier && windows/setup.sh
`windows/setup.sh` installs the GTK and Rust stack into MSYS2, clones fumi next to carrier if you don't already have it, builds carrier, and bundles carrier.exe with the GTK runtime into `dist/` and a zip, a complete Windows distribution. carrier keeps its config in `%APPDATA%\carrier`. If MSYS2 updates its core mid-run, the shell may close; just rerun the script and it picks up where it left off. Cloning over SSH needs your key in `~/.ssh` inside the MSYS2 home.
The result is installed into the user flatpak and runs as
`flatpak run com.randogoth.carrier`. Inside the sandbox the app keeps its
config under `~/.var/app/com.randogoth.carrier/config/carrier`, so a flatpak
install starts with a fresh identity rather than the dev build's account.

File diff suppressed because it is too large Load diff

View file

@ -1,10 +0,0 @@
// Windows reads the taskbar and Explorer icon from the exe's resource
// section; desktop environments on other platforms use the desktop file.
fn main() {
println!("cargo:rerun-if-changed=data/icons/com.randogoth.carrier.ico");
if std::env::var("CARGO_CFG_WINDOWS").is_ok() {
let mut res = winresource::WindowsResource::new();
res.set_icon("data/icons/com.randogoth.carrier.ico");
res.compile().expect("failed to embed Windows icon");
}
}

View file

@ -14,9 +14,6 @@
</description>
<launchable type="desktop-id">com.randogoth.carrier.desktop</launchable>
<releases>
<release version="0.3.1" date="2026-10-15"/>
<release version="0.3.0" date="2026-10-14"/>
<release version="0.2.0" date="2026-10-08"/>
<release version="0.1.0" date="2026-10-03"/>
</releases>
<content_rating type="oars-1.1"/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View file

@ -1,32 +1,32 @@
{
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.2/.schema/devbox.schema.json",
"packages": {
"rustc": "latest",
"cargo": "latest",
"pkg-config": "latest",
"rustfmt": "latest",
"gtk4": {
"version": "latest",
"platforms": ["x86_64-linux", "aarch64-linux"],
"outputs": ["out", "dev"]
},
"libadwaita": {
"version": "latest",
"platforms": ["x86_64-linux", "aarch64-linux"],
"outputs": ["out", "dev"]
},
"appstream": {
"version": "latest",
"platforms": ["x86_64-linux", "aarch64-linux"]
}
},
"env": {
"PATH": "/opt/homebrew/bin:/opt/homebrew/sbin:$PATH"
},
"packages": [
"rustc@latest",
"cargo@latest",
"pkg-config@latest",
"rustfmt@latest",
"nixpkgs#wayland",
"nixpkgs#libxkbcommon",
"nixpkgs#mesa",
"nixpkgs#libGL",
"nixpkgs#vulkan-loader",
"nixpkgs#gcc",
"nixpkgs#gtk4",
"nixpkgs#glib",
"nixpkgs#pango",
"nixpkgs#cairo",
"nixpkgs#graphene",
"nixpkgs#gdk-pixbuf",
"nixpkgs#harfbuzz",
"nixpkgs#libadwaita",
"nixpkgs#adwaita-icon-theme",
"nixpkgs#flatpak",
"nixpkgs#flatpak-builder",
"nixpkgs#appstream"
],
"shell": {
"init_hook": [
"export PKG_CONFIG_PATH=\"$DEVBOX_PACKAGES_DIR/lib/pkgconfig:$PKG_CONFIG_PATH\"",
"if [[ \"$(uname)\" == \"Darwin\" ]]; then export PKG_CONFIG_PATH=\"/opt/homebrew/lib/pkgconfig:$PKG_CONFIG_PATH\"; export PKG_CONFIG_PATH_arm64_apple_darwin=\"/opt/homebrew/lib/pkgconfig\"; fi"
". \"$PWD/scripts/devbox-libs.sh\""
],
"scripts": {
"build": [
@ -46,10 +46,6 @@
],
"package": [
"flatpak-builder --force-clean --user --install-deps-from=flathub --install .flatpak com.randogoth.carrier.json"
],
"bundle-flatpak": [
"flatpak-builder --force-clean --user --disable-rofiles-fuse --repo=.flatpak-repo --install-deps-from=flathub .flatpak-build com.randogoth.carrier.json",
"flatpak build-bundle .flatpak-repo carrier-x86_64.flatpak com.randogoth.carrier"
]
}
}

View file

@ -1,68 +1,6 @@
{
"lockfile_version": "1",
"packages": {
"appstream@latest": {
"last_modified": "2026-08-27T23:47:58Z",
"resolved": "github:NixOS/nixpkgs/7d1a7c21a4c00ea653fc7ed5c083d261340f185f#appstream",
"source": "devbox-search",
"version": "1.1.3",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/zrpxip93379i7iphgi9r12vfkmjpln8k-appstream-1.1.3",
"default": true
},
{
"name": "dev",
"path": "/nix/store/c3svyp4qcd4kwscjjhba6r5ajm2kfr0q-appstream-1.1.3-dev"
},
{
"name": "installedTests",
"path": "/nix/store/2b6x9xz39605jg62jjvcc2d6iszs5ylj-appstream-1.1.3-installedTests"
}
],
"store_path": "/nix/store/zrpxip93379i7iphgi9r12vfkmjpln8k-appstream-1.1.3"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/66jhy5isw71z0xmwsva5wbj0cl65b04j-appstream-1.1.3",
"default": true
},
{
"name": "dev",
"path": "/nix/store/akhbgsjmqkwcfp219bv3mszmfhjvv3gl-appstream-1.1.3-dev"
},
{
"name": "installedTests",
"path": "/nix/store/xvr3ldwd4vk8fiky1lbbq8fp3a6kyzms-appstream-1.1.3-installedTests"
}
],
"store_path": "/nix/store/66jhy5isw71z0xmwsva5wbj0cl65b04j-appstream-1.1.3"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/i2azliw8fyn0dv751hkwcjp25w0k3raq-appstream-1.1.3",
"default": true
},
{
"name": "dev",
"path": "/nix/store/xxw2gcxw4fjhrz7flkrmsc9n75fnadcy-appstream-1.1.3-dev"
},
{
"name": "installedTests",
"path": "/nix/store/4gbbz6ip5lmblfsl33xdxhrdpj9mgzk7-appstream-1.1.3-installedTests"
}
],
"store_path": "/nix/store/i2azliw8fyn0dv751hkwcjp25w0k3raq-appstream-1.1.3"
}
}
},
"cargo@latest": {
"last_modified": "2026-08-27T07:16:00Z",
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#cargo",
@ -105,159 +43,81 @@
"last_modified": "2026-09-29T03:29:29Z",
"resolved": "github:NixOS/nixpkgs/b6c8664de9b6cc07fe5666a29f91884ba81197c4?lastModified=1790652569&narHash=sha256-641r7xrlSOHYoktL9%2FaArPIYNgZJ9eM5URzSEOtPJDY%3D"
},
"gtk4@latest": {
"last_modified": "2026-08-28T08:26:42Z",
"resolved": "github:NixOS/nixpkgs/0e7911a191ea0fb9d6e8e771d24736d3618f8b1f#gtk4",
"source": "devbox-search",
"version": "4.22.4",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/zip9cgwsmacrd8rv9x8j6v5d93fyxb6d-gtk4-4.22.4",
"default": true
},
{
"name": "dev",
"path": "/nix/store/34wj0s7jwk37sfxaw1vljl8aq1v43mxg-gtk4-4.22.4-dev"
}
],
"store_path": "/nix/store/zip9cgwsmacrd8rv9x8j6v5d93fyxb6d-gtk4-4.22.4"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/x2y5vykblarp5jzs7n85sh5gixi4r7hx-gtk4-4.22.4",
"default": true
},
{
"name": "dev",
"path": "/nix/store/cgr4b28yx7zzdxkjifwba2v5f9zprh8p-gtk4-4.22.4-dev"
},
{
"name": "devdoc",
"path": "/nix/store/qpm15s7r2g7qwrr0fnjpgl4jkvh820x9-gtk4-4.22.4-devdoc"
},
{
"name": "debug",
"path": "/nix/store/hkpjzp8nah9cj5gmp51ydh658rbnh9s8-gtk4-4.22.4-debug"
}
],
"store_path": "/nix/store/x2y5vykblarp5jzs7n85sh5gixi4r7hx-gtk4-4.22.4"
},
"x86_64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/3la8zml5yi34lssl8j3g1w7n2qs2j40w-gtk4-4.22.4",
"default": true
},
{
"name": "dev",
"path": "/nix/store/8vgk08hbgzswqrf6nbm0m7w5qxzi5j7i-gtk4-4.22.4-dev"
}
],
"store_path": "/nix/store/3la8zml5yi34lssl8j3g1w7n2qs2j40w-gtk4-4.22.4"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/4j905cjgk8idkzg4szhyryzjx2q1b6br-gtk4-4.22.4",
"default": true
},
{
"name": "debug",
"path": "/nix/store/r7hhl6pbzld4m34qza3xr08xmm13ssmk-gtk4-4.22.4-debug"
},
{
"name": "dev",
"path": "/nix/store/dczin2m9wvwsfjyfbnksm983ljgmibfk-gtk4-4.22.4-dev"
},
{
"name": "devdoc",
"path": "/nix/store/7jwx0qmwvx9w2z5b5m5fw2d9k2d1a3wd-gtk4-4.22.4-devdoc"
}
],
"store_path": "/nix/store/4j905cjgk8idkzg4szhyryzjx2q1b6br-gtk4-4.22.4"
}
}
"nixpkgs#adwaita-icon-theme": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#adwaita-icon-theme"
},
"libadwaita@latest": {
"last_modified": "2026-08-29T00:50:12Z",
"resolved": "github:NixOS/nixpkgs/e8be7818e19ada32105a8af937a6a473b38167ca#libadwaita",
"source": "devbox-search",
"version": "1.9.3",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/dcz2cyp4bzlbbwrq0z9rpaijrhbm1nv2-libadwaita-1.9.3",
"default": true
},
{
"name": "devdoc",
"path": "/nix/store/sqp0z2gcbgpzs9gcfrs7jzpy1v1k5qv0-libadwaita-1.9.3-devdoc"
},
{
"name": "dev",
"path": "/nix/store/a39iqc19qfbkki6ibsylh8kard0yajhy-libadwaita-1.9.3-dev"
}
],
"store_path": "/nix/store/dcz2cyp4bzlbbwrq0z9rpaijrhbm1nv2-libadwaita-1.9.3"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/czi84wxq4v7vl7k4dr59nrwwk91pc5d4-libadwaita-1.9.3",
"default": true
},
{
"name": "debug",
"path": "/nix/store/yg7bgsvyyp8ky0mbgr8hm6szixcw71bs-libadwaita-1.9.3-debug"
},
{
"name": "dev",
"path": "/nix/store/hss38kikfq0v5cd5hi353qj5smnhwiga-libadwaita-1.9.3-dev"
},
{
"name": "devdoc",
"path": "/nix/store/rrkay1bb7zyicqb76n9ynk695bzra7l0-libadwaita-1.9.3-devdoc"
}
],
"store_path": "/nix/store/czi84wxq4v7vl7k4dr59nrwwk91pc5d4-libadwaita-1.9.3"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/x8v08wl57vkyk6qfcblx32a0w6cphnpy-libadwaita-1.9.3",
"default": true
},
{
"name": "devdoc",
"path": "/nix/store/4bi0mlap06fcw0ycsd0yslyzk57lir9r-libadwaita-1.9.3-devdoc"
},
{
"name": "debug",
"path": "/nix/store/l4h0wjg04i4bdz5xvafchxw9hh1a9dc1-libadwaita-1.9.3-debug"
},
{
"name": "dev",
"path": "/nix/store/0gvpb9gszkl274wgb3zgcwlclbwj6cch-libadwaita-1.9.3-dev"
}
],
"store_path": "/nix/store/x8v08wl57vkyk6qfcblx32a0w6cphnpy-libadwaita-1.9.3"
}
}
"nixpkgs#appstream": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#appstream"
},
"nixpkgs#cairo": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#cairo"
},
"nixpkgs#flatpak": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#flatpak"
},
"nixpkgs#flatpak-builder": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#flatpak-builder"
},
"nixpkgs#gcc": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#gcc"
},
"nixpkgs#gdk-pixbuf": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#gdk-pixbuf"
},
"nixpkgs#glib": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#glib"
},
"nixpkgs#graphene": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#graphene"
},
"nixpkgs#gtk4": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#gtk4"
},
"nixpkgs#harfbuzz": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#harfbuzz"
},
"nixpkgs#libGL": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libGL"
},
"nixpkgs#libadwaita": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libadwaita"
},
"nixpkgs#libxkbcommon": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libxkbcommon"
},
"nixpkgs#mesa": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#mesa"
},
"nixpkgs#pango": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#pango"
},
"nixpkgs#vulkan-loader": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#vulkan-loader"
},
"nixpkgs#wayland": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#wayland"
},
"pkg-config@latest": {
"last_modified": "2026-10-05T09:07:57Z",
"resolved": "github:NixOS/nixpkgs/aa48d347080940b8a2b8d2f48228674e280a3514#pkg-config",
"last_modified": "2025-11-23T21:50:36Z",
"resolved": "github:NixOS/nixpkgs/ee09932cedcef15aaf476f9343d1dea2cb77e261#pkg-config",
"source": "devbox-search",
"version": "0.29.2",
"systems": {
@ -265,39 +125,39 @@
"outputs": [
{
"name": "out",
"path": "/nix/store/nhgylvj98klqggy54h7gbc3b2ia4a00y-pkg-config-wrapper-0.29.2",
"path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2",
"default": true
},
{
"name": "man",
"path": "/nix/store/rk4dw0mj2lakbw5hml11mfpmdx9xjzfa-pkg-config-wrapper-0.29.2-man",
"path": "/nix/store/9px0sji43x3r2w4zxl3j3idwsql7lwxx-pkg-config-wrapper-0.29.2-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/xzlcs9f8xl9dvnbwifmlfbxy15jq53j1-pkg-config-wrapper-0.29.2-doc"
"path": "/nix/store/hqk44ra6qxw7iixardl6c3hdgb9kq6ns-pkg-config-wrapper-0.29.2-doc"
}
],
"store_path": "/nix/store/nhgylvj98klqggy54h7gbc3b2ia4a00y-pkg-config-wrapper-0.29.2"
"store_path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/h82n3d0wdrgskdc3yvmz5l3c50h2snvp-pkg-config-wrapper-0.29.2",
"path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2",
"default": true
},
{
"name": "man",
"path": "/nix/store/8960pp9vb5hx49q3h315hwr33gb0k2k0-pkg-config-wrapper-0.29.2-man",
"path": "/nix/store/10060k24qggqyzlwdsfmni9y32zxcg0j-pkg-config-wrapper-0.29.2-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/dxpxspf67kl1gydpl3v3f7pxa3l9wyvq-pkg-config-wrapper-0.29.2-doc"
"path": "/nix/store/0y4v51ndpyvkj09hwlfqkz0c3h17zfmc-pkg-config-wrapper-0.29.2-doc"
}
],
"store_path": "/nix/store/h82n3d0wdrgskdc3yvmz5l3c50h2snvp-pkg-config-wrapper-0.29.2"
"store_path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2"
},
"x86_64-darwin": {
"outputs": [
@ -322,20 +182,20 @@
"outputs": [
{
"name": "out",
"path": "/nix/store/b9izmrfjp0qnnyahdwwrzdwi87s1h179-pkg-config-wrapper-0.29.2",
"path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2",
"default": true
},
{
"name": "man",
"path": "/nix/store/c8asqjndyv84qh5y2z84w9np0c369ill-pkg-config-wrapper-0.29.2-man",
"path": "/nix/store/j9xfpnrygg3v37svc5pfin9q5bm49r94-pkg-config-wrapper-0.29.2-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/8fsw8ybdnhcif89dmk8pp85ykjpvh3pr-pkg-config-wrapper-0.29.2-doc"
"path": "/nix/store/x3bypxdxaq20kykybhkf21x4jczsiy8y-pkg-config-wrapper-0.29.2-doc"
}
],
"store_path": "/nix/store/b9izmrfjp0qnnyahdwwrzdwi87s1h179-pkg-config-wrapper-0.29.2"
"store_path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2"
}
}
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

View file

@ -1,461 +0,0 @@
## Loaded once at startup by `src/i18n.rs`; every user-facing string in
## the app lives here, grouped by the feature it belongs to.
## Seed parsing — restoring an identity from a pasted seed
seed-bad-hex = That's not valid hex. Check for typos or extra characters.
seed-wrong-length = A seed is 32 bytes. Check you copied the whole thing.
## Local files — errors reading/writing the key and its sidecars
file-error = { $path }: { $reason }
key-file-wrong-length = { $path }: not a 32-byte secret
## Compose — validating a typed `Key: value` header row
compose-header-colon-missing = Headers need a colon, like `Key: value`.
compose-header-reserved-key = { $key } is set automatically and can't be changed.
compose-header-invalid-key = Keys can be 1 to 64 letters, digits, and dashes.
compose-header-value-multiline = Values need to stay on one line.
compose-header-duplicate-key = { $key } is set twice. Remove one of them.
compose-headers-too-large = Your headers take up too much space. Remove some and try again.
compose-headers-too-many = You have too many headers. Remove some and try again.
## Contacts — importing a smol:// address
contacts-import-needs-key = Import needs a smol:// address that includes a key.
## Mail — opening a message by id
mail-message-id-wrong-length = Message IDs are 64 hex characters.
mail-message-id-wrong-bytes = Message IDs are 32 bytes.
mail-message-not-found = Message not found.
## Fetch — the status note after a fetch completes
##
## Split from one selector into one message per combination of "N
## rejected" and "(cancelled)" that can apply; the caller in ops.rs
## picks the id to match, since a flat message can't branch on a
## variable itself.
fetch-result-note-rejected = { $total } fetched, { $stored } stored, { $rejected } rejected
fetch-result-note-cancelled = { $total } fetched, { $stored } stored (cancelled)
fetch-result-note-rejected-cancelled = { $total } fetched, { $stored } stored, { $rejected } rejected (cancelled)
fetch-result-note-plain = { $total } fetched, { $stored } stored
mail-envelope-id-mismatch-error = id does not match the envelope
mail-fetch-summary-counts = { $stored } new, { $rejected } rejected
mail-fetch-summary-verified-one = { $verified } sender verified from a signed Reply-To
mail-fetch-summary-verified-other = { $verified } senders verified from a signed Reply-To
mail-fetch-summary-left-on-server = left on the server
## Send — the status note after a send completes
##
## Split from one selector into one message per combination of
## trust-change clause and accept-token clause that can apply; the
## caller in ops.rs picks the id to match. Any warning fumi itself
## reports is appended separately in code, since that text is fumi's
## own and not part of this catalogue.
send-result-note-token = Sent to { $address } ({ $bytes } bytes), accept token attached
send-result-note-unverified = Sent to { $address } ({ $bytes } bytes); new contact learned from an unpinned server (unverified)
send-result-note-unverified-token = Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned from an unpinned server (unverified)
send-result-note-tofu = Sent to { $address } ({ $bytes } bytes); new contact learned (trust on first use)
send-result-note-tofu-token = Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned (trust on first use)
send-result-note-rotated = Sent to { $address } ({ $bytes } bytes); contact rotated to a new key (chain verified)
send-result-note-rotated-token = Sent to { $address } ({ $bytes } bytes), accept token attached; contact rotated to a new key (chain verified)
send-result-note-plain = Sent to { $address } ({ $bytes } bytes)
mail-sent-note = sent to { $address }
mail-sent-note-accepted = sent to { $address } (accepted)
## Background-operation status notes
##
## Built in `spawn_op` (src/view.rs) rather than ops.rs, since that is
## where each `PendingOp` result is assembled for the worker thread.
status-registered-note = Registered
status-restored-note = Restored at rotation { $index }
status-deleted-note = Deleted
status-accepted-note = Accepted { $address }
status-blocked-note = Blocked { $address }
status-not-blocked-note = { $address } was not blocked
contacts-no-key-error = no key for { $address } yet
# Split from one selector into flat siblings for the token count, matching
# this file's existing convention (see fetch-result-note-* above).
contacts-accepted-note-one = { $address } accepted; server now holds { $held } accept token
contacts-accepted-note-other = { $address } accepted; server now holds { $held } accept tokens
contacts-blocked-note-one = { $address } blocked; server now holds { $held } accept token
contacts-blocked-note-other = { $address } blocked; server now holds { $held } accept tokens
status-imported-note = Imported { $address } (verified)
status-rotated-note = Rotated to index { $index }; new key { $key_prefix }…
## Backup
backup-written-note = Backup written to { $path }
## Drafts
draft-saved-note = Draft saved
## Log out
logged-out-note = Signed out. Create a new identity or restore a seed.
logged-out-switched-note = Signed out. You're now using { $address }.
## Onboarding
identity-created-note = Identity created. Write the seed down. It's the only secret.
seed-restored-note = Seed restored. Bind a username or recall an address.
seed-reused-note = Secret copied. Bind a username or recall an address.
## Signup
signup-enter-host-error = Enter the server host first.
signup-enter-user-host-error = Enter the user and host.
# A pin is keyed by bare host, so these two catch the mistakes that would
# otherwise silently pin a string nothing looks up again: a whole address,
# or a host with its port still attached.
signup-host-looks-like-address-error = "{ $host }" looks like an address, not a server — the part after the @ goes here
signup-host-has-port-error = "{ $host }": a pin is by host only, no port — just the part before the colon
## Compose
compose-enter-recipient-error = Enter a recipient.
compose-header-row-error = Header { $index }: { $reason }
reply-no-address-error = This sender left no address to reply to.
reply-subject-prefix = Re: { $subject }
address-invalid-error = That address isn't in the right format.
## Contacts
contacts-reply-address-invalid-error = The sender's reply address isn't in the right format.
contacts-sender-key-invalid-error = The sender's key isn't in the right format.
contacts-accept-no-address-error = This sender left no address to accept.
contacts-block-no-address-error = This sender left no address to block.
contacts-sender-key-mismatch-error = that address carries a different key than this message's sender
# A pinned, conflicting Reply-To claim is refused outright rather than asked
# about — see saveContactChecked's dialog for the softer self-certifying case.
contacts-reply-key-conflict-error = { $address } is already known with a different key — verify out of band before replying
# Shown when a typed address or a smol:// link would overwrite an address
# already saved under a different key, and the user declined the prompt
# that showed both fingerprints and asked.
contacts-key-kept-note = kept the existing key for { $address }
contacts-sender-saved-note-verified = { $address } saved for this sender (verified key)
contacts-sender-saved-note-unverified = { $address } saved for this sender
# Richer than status-imported-note: this client shows the imported key and
# its fingerprint alongside the confirmation.
contacts-import-detail-note = imported { $address } { $key } (verified){"\u000A"}fingerprint: { $fingerprint }
## Common — short words reused verbatim across several buttons/dialogs
action-cancel = Cancel
action-back = Back
action-block = Block
action-unblock = Unblock
action-accept = Accept
action-reply = Reply
action-delete = Delete
# Shown wherever a fetched server key is on screen before it's pinned:
# the compose's pin card, the signup server step, and the contact detail
# body when a key was fetched for that contact.
trust-fingerprint-hint = If the server operator published a key or fingerprint, make sure it matches before trusting it.
# Reused wherever an action needs an identity that doesn't exist yet
# (sending, fetching, pushing accept tokens).
identity-missing-error = no identity yet
account-not-registered-fetch-warning = not registered yet — claim an address in settings
account-not-registered-tokens-note = not registered; the set will be pushed with your first fetch
## Sidebar
mail-fetch-button = Fetch
mail-write-button = Write
folder-inbox = Inbox
folder-requests = Requests
folder-sent = Sent
folder-drafts = Drafts
settings-nav-label = Settings
settings-close-aria-label = close settings
settings-close-tooltip = close
identity-you-caption = you
contacts-caption = contacts
contacts-import-placeholder = Import a smol:// address
contacts-import-button = Import
# "Follow the system" is a real preference, not the absence of one, so it
# gets its own label rather than defaulting silently to one of the other two.
theme-auto-label = theme: following the system
theme-light-label = theme: light
theme-dark-label = theme: dark
account-not-registered-value = not registered
mail-search-placeholder = search { $folder }
## Mail — the open-message meta line
##
## Split from one selector into one message per variant, each a full
## sentence; the caller in view.rs picks `-yes`/`-no` to match `$kept`.
mail-meta-to = to
mail-meta-from = from
mail-meta-line-no = { $label } { $from } · { $time } UTC · id { $id }…
mail-meta-line-yes = { $label } { $from } · { $time } UTC · id { $id }… · a server copy still exists
mail-no-subject = (no subject)
mail-no-reply-address = no reply address: can't accept or block
contacts-empty-hint = no contacts
# The §4/§8 trust chip on an open message: a signature always verifies, or
# unseal() would have thrown already — what varies is the binding between
# the key that signed it and an address.
mail-back-to-list-button = messages
mail-meta-sent = to: { $recipient }{"\u000A"}id: { $id }{"\u000A"}sent: { $time }
mail-meta-received = from: { $from }{"\u000A"}id: { $id }{"\u000A"}date: { $time }
mail-trust-own-copy = your own sealed copy
mail-trust-verified-sender = verified sender key
mail-trust-tofu-sender = key pinned on first use
mail-trust-unbound-sender = key bound to no address
mail-sender-unbound-tooltip = sender key not bound to an address
mail-signed-reply-hint = this message carries the sender's signed address: { $address }
mail-save-sender-reply-button = save sender & reply
mail-name-sender-button = name sender & reply
mail-unknown-sender-hint = The wire carries no sender name, only this signed key. To reply, give the sender's address — it is checked against this key:
mail-delete-not-registered-error = not registered; cannot reach the server to delete this message
mail-request-unaccepted-hint = unsolicited mail from { $address }, not yet accepted into your main tier
mail-accept-button = accept — future mail lands in your main tier
## Mail list — empty-folder messages, and the own-identity detail
mail-inbox-empty-title = Inbox is empty
mail-inbox-empty-description = Fetch to check the mailbox.
mail-requests-empty-title = No requests
mail-requests-empty-description = Mail from senders you haven't accepted yet arrives here.
mail-sent-empty-title = Nothing sent yet
mail-sent-empty-description = Messages you send keep a sealed copy here.
mail-drafts-empty-title = No drafts
mail-drafts-empty-description = Closing a compose from the sidebar keeps it here.
own-identity-meta = you · rotation { $rotations }
contacts-pin-button-fetched = Pin this key
contacts-pin-button-unfetched = Pin server
mail-empty-title = No message selected
mail-empty-description = Pick a message from the list above to read it.
# The two sentences shown under Fetch when the inbox is empty.
mail-empty-hint = Fetching removes mail from the server once it's stored here. Turn on "Leave mail on server" in Settings to keep it there instead.
detail-qr-tooltip = Show the address as a QR code
detail-copy-tooltip = Copy the address
mail-row-unreadable-error = <unreadable: { $error }>
mail-contacts-reader-hint = contacts and their keys are listed alongside
mail-list-count-one = { $count } message
mail-list-count-other = { $count } messages
mail-list-shown-of-total = { $shown } of { $total }
## Compose
compose-to-label = To
compose-subject-label = Subject
compose-add-header-button = + header
compose-send-button = Send
compose-header-placeholder = Key: value
compose-header-remove-tooltip = Remove this header
compose-body-placeholder = message
compose-anonymous-label = anonymous — omit my address
## Contacts — trust state shown in the sidebar and the detail pane
contact-state-accepted = accepted
contact-state-blocked = blocked
contact-state-verified = verified
contact-state-tofu = trust on first use
# `$state` is one of the four words above, already resolved by the caller
# through `contact_state`. Split from one selector into one message per
# pin state; the caller in view.rs picks `-yes`/`-no` to match `$pinned`.
contact-meta-line-yes = { $state } · server pinned
contact-meta-line-no = { $state } · server not pinned
## Detail pane — the monospace key/value block shared by contacts and the
## own identity view. Column widths are padded to these labels in code
## (14 characters), so a much longer translation would throw the
## alignment off; flagged in the extraction report.
detail-key-label = key
detail-fingerprint-label = fingerprint
detail-address-label = address
detail-server-label = server
contact-server-key-offered = this server presents
contacts-server-pinned-badge = pinned
contacts-server-not-pinned-badge = not pinned
contacts-history-badge = { $count } previous
contacts-import-empty-hint = no contacts yet — import a smol:// address above, or write to someone
contacts-refresh-button = re-resolve
contacts-accept-token-title = accept token
contacts-accepted-hint = This contact's mail lands in your main tier.
contacts-unaccepted-hint = This contact's mail lands in requests until accepted.
contacts-history-title = previous keys ({ $count })
contacts-history-hint = Replaced by a signed rotation chain. Mail signed with these was sent before the change; rotation is not revocation, so a stolen key can still rotate onward.
contacts-history-replaced = replaced { $time }
## Settings — tab switcher labels
settings-tab-identity = Identity
settings-tab-server = Server
settings-tab-backup = Backup
settings-tab-advanced = Advanced
## Settings — Identity tab
settings-field-address = Address
settings-field-key = Key
settings-field-fingerprint = Fingerprint
settings-field-uri = URI
property-row-copy-tooltip = Copy the { $field }
settings-recovery-secret-title = Recovery secret
settings-secret-reveal-button = reveal
settings-secret-hide-button = hide
settings-secret-reveal-tooltip = Reveal the recovery secret
settings-secret-hide-tooltip = Hide the recovery secret
settings-secret-copy-tooltip = Copy the recovery secret
settings-secret-warning = Anyone who holds this can read your mail and write as you, and losing it loses the address with it — there is no reset. Keep it in a password manager.
# A copy button's own label, flashed on click instead of a toast.
copy-button-idle = copy
copy-button-success = copied
copy-button-failed = failed
settings-not-registered-value = not registered yet
settings-uri-claim-hint = claim an address to get one
## Settings — Server tab
settings-field-host = Host
settings-field-public-key = Public key
settings-public-key-not-pinned = not pinned yet
server-connection-title = Connection
server-timeout-title = Timeout
server-timeout-subtitle = Seconds before a network call gives up
server-fetching-title = Fetching
server-leave-mail-title = Leave mail on server
server-leave-mail-subtitle = Keep a copy on the server after fetching, so you can fetch it again from another app or device. When this is on, deleting a message removes it here and on the server.
## Settings — Backup tab
backup-page-description = One file with your mail, contacts, and server info, for moving to another app or keeping what the server no longer has. It's sealed to this identity and never includes the secret itself.
backup-export-title = Export
backup-export-subtitle = Write a backup file
backup-import-title = Import
backup-import-subtitle = Restore from a backup file
backup-export-dialog-title = Export backup
backup-import-dialog-title = Import backup
## Settings — Advanced tab
advanced-trust-section-title = trust
advanced-rotations-title = Rotations
advanced-rotations-description = Issues a fresh key from your secret with a signed certificate proving it replaces this one. Contacts accept the change from that chain, and the old key stays readable. Rotation is not revocation.
advanced-rotations-completed = Completed
advanced-rotate-button = Rotate identity
advanced-rotate-needs-account-error = rotate needs a registered account
advanced-first-contact-title = First contact
advanced-first-contact-description = A recipient who isn't a contact yet, and whose address carries no key, has to be looked up on their own server. That answer is only as good as the server: one with no pinned key can return a key of its own and read what you send.
advanced-strict-pinning-title = Require a pinned server
advanced-strict-pinning-subtitle = Stop that send and offer to pin the server's key first, so you can check it against what the operator published. With this off, the key's taken on trust, and the send is only reported as unverified afterwards.
advanced-logout-title = Sign out
advanced-logout-description = Removes this account from this app: the secret, pins, contacts, and every cached message. Other accounts stay as they are. Without a backup of the secret, you can't get back in. Mail already fetched here may have no other copy anywhere, unless "Leave mail on server" was on. Whatever's still on the server comes back on the first fetch after you sign back in.
advanced-logout-button = Sign out and delete this account's data
## Onboarding
##
## The app name itself ("carrier") is a brand, not translated prose, and
## is left as a literal in code — see the extraction report.
onboard-welcome-title = welcome
onboard-welcome-description = Encrypted mail with no password. Each identity is one secret, stored only on this device.
onboard-tagline = smolmail: one seed, sealed mail, five operations
onboard-create-button = Create a new identity
onboard-restore-button = Restore an existing identity from a seed
onboard-created-title = Write this seed down
onboard-created-description = It's the only secret, and it's shown once.
onboard-saved-button = I saved it
onboard-restore-title = Restore from a seed
onboard-restore-description = Paste the secret you saved. Your keys come back from it — it is all this client needs.
onboard-seed-entry-title = Seed: 32 bytes, base32 or hex
onboard-restore-go-button = Restore
## Accounts — the switcher on the sidebar identity row, and adding one
accounts-menu-tooltip = Switch account
accounts-add-button = Add account
# Shown when signup is about to bind an address another account in this app already uses.
accounts-duplicate-error = { $address } is already in carrier. Switch to it from the account menu.
accounts-add-cancel-button = Cancel adding account
onboard-reuse-title = Use the secret from another account
onboard-reuse-description = Both addresses will share one public key, so anyone who sees both can tell they belong together.
## Signup
signup-server-title = Set up the server
signup-host-label = host
signup-fetch-key-button = Fetch the server's key
signup-continue-button = Continue
signup-start-over-button = Start over
signup-account-title = Choose your address
signup-mode-new-label = Create a new address
signup-mode-existing-label = I already have this address
signup-user-label = User
signup-invite-label = Invite token (if the server requires one)
signup-register-button = Register
signup-recall-button = Restore access
## Compose — the inline pin card shown after a blocked send
compose-pin-title = { $host } isn't pinned
compose-pin-body = { $address } isn't a contact yet, so carrier would have to ask this server for its key. A server with no pinned key can answer with one of its own, then read the message.
compose-pin-button-fetch = Fetch their server's key
compose-pin-button-send = Pin and send
## Dialogs
delete-dialog-title = Delete this message?
delete-dialog-body-kept = Deleting it here leaves the server's copy in place. Deleting it everywhere asks the server to drop that copy too.
delete-dialog-body-gone = This removes the only copy, which is sealed and stored on this device.
delete-dialog-everywhere-button = Delete everywhere
rotate-dialog-title = Rotate the identity?
rotate-dialog-confirm-button = Rotate
reset-dialog-title = Sign out and delete this account's data?
reset-dialog-confirm-button = Sign Out
# `$list` is the sender's own frontmatter rows, already typed by a human
# and reproduced verbatim — not translatable prose itself, just data
# interpolated into this one sentence.
reply-dialog-title = Carry the sender's fields into the reply?
reply-dialog-body = This message has its own headers:{"\u000A"}{"\u000A"}{ $list }{"\u000A"}{"\u000A"}Reply with copies of these fields, or start with none. The fields stay editable either way.
reply-dialog-cancel-button = Cancel reply
reply-dialog-without-button = Reply without
reply-dialog-copy-button = Copy fields
key-change-dialog-title = Replace this contact's key?
key-change-dialog-body = { $address } is already on file with a different key.{"\u000A"}{"\u000A"}known { $known_fingerprint }{"\u000A"}new { $offered_fingerprint }{"\u000A"}{"\u000A"}The address you were given carries the new key and would replace the one on file. If this contact didn't hand it to you themselves, stop: a key swapped in on the way reads everything you send them.
key-change-dialog-replace-button = Replace key
## Status bar
##
## `model.busy` also doubles as an internal state-machine key compared
## against literal English in app.rs (e.g. `self.busy.as_deref() ==
## Some("fetching")`), so that value itself is not translated — only its
## display here is, via this lookup from the internal label to a phrase.
## See the extraction report for the coupling this implies.
status-busy-fetching = fetching…
status-busy-refreshing = refreshing…
status-busy-registering = registering…
status-busy-restoring = restoring…
status-busy-rotating = rotating…
status-fetching-progress = fetching… { $count } in inbox
## First contact / server trust
first-contact-blocked-error = { $address } isn't a contact yet, and { $host } isn't pinned. Pin their server's key to send.
server-key-offered-note = The server presented this key. Pin it only if the fingerprint matches what the operator published.
server-key-already-pinned-note = This server's key is already pinned.
server-key-pinned-note = Server key pinned
connect-unpinned-warning = { $host } is not pinned; its key is { $key }.{"\u000A"}Lookups from this session are UNVERIFIED.
recall-rotation-not-found-error = the key bound to { $address } is not derived from this master within { $max } rotations
contacts-refresh-embedded-key-error = that address already carries a key; use import instead
# Shared by a first send to a never-before-seen recipient and a contact's
# own "re-resolve" button — both land here the same way, by RESOLVE finding
# no existing key for the address.
contacts-resolved-note = { $address } pinned (trust on first use)
contacts-resolved-note-unverified = { $address } pinned (trust on first use, UNVERIFIED server)
contacts-refresh-unchanged-note = { $address }: key unchanged
contacts-refresh-rotated-note = { $address } rotated its key; a signed chain confirms it.{"\u000A"}now { $key }
contacts-refresh-no-chain-warning = { $address } presents a different key with no valid rotation chain.{"\u000A"}Verify out of band, then import the new smol:// address.

1644
src/app.rs

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,7 @@
//! Sample data for layout work, compiled into dev builds only. The dev
//! build uses its own `carrier-demo` profile directory; seeding runs once
//! when it has no accounts yet, so read marks survive across refinement
//! when it has no key yet, so read marks survive across refinement
//! sessions and the release profile's real account is never touched.
//! Two accounts are planted so the account switcher has something to show.
use std::path::Path;
@ -23,58 +22,15 @@ fn identity(tag: u8) -> Identity {
Identity::from_seed([tag; KEY_LEN])
}
pub fn seed(root: &Path) {
if !crate::ops::list_accounts(root).is_empty() {
pub fn seed(dir: &Path) {
if dir.join("carrier.key").exists() {
return;
}
let planted = (|| {
for id in ["demo", "demo2"] {
let dir = crate::ops::account_dir(root, id);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
}
plant(&crate::ops::account_dir(root, "demo"))?;
plant_second(&crate::ops::account_dir(root, "demo2"))?;
crate::ops::save_active(root, "demo")
})();
if let Err(e) = planted {
if let Err(e) = plant(dir) {
eprintln!("demo seed failed: {e}");
}
}
/// A sparse second account with its own master, so switching shows a
/// visibly different inbox.
fn plant_second(dir: &Path) -> Result<(), String> {
let master: [u8; KEY_LEN] = core::array::from_fn(|i| i as u8 + 101);
crate::ops::save_key(&dir.join("carrier.key"), &master)?;
let store = Store::open(&dir.join("carrier.db")).map_err(|e| e.to_string())?;
let me = Account::new(master, 0).map_err(|e| e.to_string())?;
let addr = Address::parse("demo2@carrier.local").map_err(|e| e.to_string())?;
store.set_account(&addr).map_err(|e| e.to_string())?;
store
.pin_server(&addr.host, addr.port, &identity(42).pk())
.map_err(|e| e.to_string())?;
let sender = identity(11);
let uri = Address::parse("alice@example.org")
.unwrap()
.uri(&sender.pk());
let text = build_frontmatter(
&[("Subject", "your second address works"), ("Reply-To", &uri)],
"This inbox belongs to the second demo account.",
);
let envelope = seal(&sender, &me.me().pk(), text.as_bytes(), now() - 3600, true)
.map_err(|e| e.to_string())?;
store
.store_inbox(
&message_id(&envelope),
&envelope,
now() - 3600,
false,
false,
)
.map_err(|e| e.to_string())?;
Ok(())
}
fn plant(dir: &Path) -> Result<(), String> {
let master = demo_master();
crate::ops::save_key(&dir.join("carrier.key"), &master)?;
@ -83,11 +39,6 @@ fn plant(dir: &Path) -> Result<(), String> {
let me = Account::new(master, 0).map_err(|e| e.to_string())?;
let addr = Address::parse("demo@carrier.local").map_err(|e| e.to_string())?;
store.set_account(&addr).map_err(|e| e.to_string())?;
// Our own server is pinned, the contacts' server is not: the two
// states the trust lines and the pin prompts distinguish.
store
.pin_server(&addr.host, addr.port, &identity(42).pk())
.map_err(|e| e.to_string())?;
let pk = me.me().pk();
let mut read_ids: Vec<String> = Vec::new();
@ -207,30 +158,6 @@ fn plant(dir: &Path) -> Result<(), String> {
true,
)?;
// one inbox mail with the sender's own frontmatter fields, so the
// detail view has a custom header block to show
{
let sender = identity(11);
let uri = Address::parse("alice@example.org")
.unwrap()
.uri(&sender.pk());
let text = build_frontmatter(
&[
("Subject", "canary rollout report"),
("Reply-To", &uri),
("Priority", "high"),
("X-Stage", "canary at 5%"),
],
"The custom fields on this one are the sender's own — carrier shows them between the meta line and the body.",
);
let envelope = seal(&sender, &pk, text.as_bytes(), now() - 12 * 3600, true)
.map_err(|e| e.to_string())?;
let id = message_id(&envelope);
store
.store_inbox(&id, &envelope, now() - 12 * 3600, false, false)
.map_err(|e| e.to_string())?;
}
// requests: senders not in the contact list
deliver(
&identity(31),
@ -300,7 +227,6 @@ fn plant(dir: &Path) -> Result<(), String> {
to: "grace@example.org".into(),
subject: "Re: quarterly infrastructure review".into(),
body: "started a reply, will finish it after the review call".into(),
headers: vec!["Urgency: low".into()],
reply_to: None,
at: now() - 3 * 3600,
}],

View file

@ -1,64 +0,0 @@
//! A single Fluent bundle, loaded once from `locales/en-US/main.ftl` and
//! baked into the binary via `include_str!`. Every user-facing string in
//! the app is looked up through `t`/`t1`/`t_args` rather than written
//! inline, so copy review and translation both have one place to work
//! from. There is only one locale today; a future one would plug in here
//! without touching a call site.
use std::sync::OnceLock;
use fluent_bundle::concurrent::FluentBundle;
use fluent_bundle::{FluentArgs, FluentResource, FluentValue};
fn bundle() -> &'static FluentBundle<FluentResource> {
static BUNDLE: OnceLock<FluentBundle<FluentResource>> = OnceLock::new();
BUNDLE.get_or_init(|| {
let ftl = include_str!("../locales/en-US/main.ftl");
let resource =
FluentResource::try_new(ftl.to_string()).expect("main.ftl must parse as Fluent");
let locale = "en-US".parse().expect("\"en-US\" is a valid language tag");
let mut bundle = FluentBundle::new_concurrent(vec![locale]);
// Bidi isolation marks are invisible but real characters; with a
// single left-to-right locale they only cost correctness —
// copy/paste, clipboard round-trips and plain string equality all
// pick them up — for no visible benefit.
bundle.set_use_isolating(false);
bundle
.add_resource(resource)
.expect("main.ftl must not redefine a message id");
bundle
})
}
/// Looks up a message with no variables.
pub fn t(id: &str) -> String {
t_args(id, None)
}
/// Looks up a message with one variable — the common case, since most
/// interpolated strings here carry a single value.
pub fn t1(id: &str, key: &'static str, value: impl Into<FluentValue<'static>>) -> String {
let mut args = FluentArgs::new();
args.set(key, value);
t_args(id, Some(&args))
}
/// Looks up a message against a caller-built argument set, for the rare
/// string that interpolates more than one value.
pub fn t_args(id: &str, args: Option<&FluentArgs>) -> String {
let bundle = bundle();
// A missing id or a value-less term is a copy bug, not a runtime
// condition to hide: surface it in the string itself rather than
// silently falling back to English prose that isn't there to fall
// back to (there's only the one locale).
let Some(message) = bundle.get_message(id) else {
return format!("???{id}???");
};
let Some(pattern) = message.value() else {
return format!("???{id}???");
};
let mut errors = Vec::new();
bundle
.format_pattern(pattern, args, &mut errors)
.into_owned()
}

View file

@ -1,17 +1,11 @@
//! carrier — a minimalist smolmail client built on fumi-core, with a
//! Relm4 UI. Each account lives in its own `accounts/<id>/` folder under
//! the config directory: `carrier.key` (the master, 0600), `carrier.db`
//! (fumi-core's sealed store) and carrier's own sidecars (read marks,
//! drafts, settings). `active.txt` names the account the app opens.
// No console window when the Windows release build is double-clicked.
// Debug builds keep it for eprintln output.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
//! Relm4 UI. State lives in the config directory: `carrier.key` (the
//! master, 0600), `carrier.db` (fumi-core's sealed store) and `read.txt`
//! (read marks, carrier's own sidecar).
mod app;
#[cfg(debug_assertions)]
mod demo;
mod i18n;
mod ops;
mod view;
@ -23,10 +17,6 @@ fn main() {
eprintln!("cannot create {}: {e}", dir.display());
std::process::exit(1);
}
if let Err(e) = ops::migrate_legacy(&dir) {
eprintln!("cannot move the account into {}: {e}", dir.display());
std::process::exit(1);
}
#[cfg(debug_assertions)]
demo::seed(&dir);
view::run(dir);
@ -35,7 +25,9 @@ fn main() {
fn config_dir() -> PathBuf {
let base = match std::env::var("XDG_CONFIG_HOME") {
Ok(xdg) if !xdg.is_empty() => PathBuf::from(xdg).join("carrier"),
_ => default_config_root(),
_ => PathBuf::from(std::env::var("HOME").unwrap_or_default())
.join(".config")
.join("carrier"),
};
// Dev builds get their own profile, seeded with sample data in
// `demo.rs`, so layout work never touches the real account.
@ -43,20 +35,3 @@ fn config_dir() -> PathBuf {
let base = base.with_file_name("carrier-demo");
base
}
// HOME is rarely set on Windows, and when it is missing the Unix
// fallback would resolve .config\carrier against the working
// directory - for an installed copy that's Program Files, which isn't
// writable, so the app would die on startup. Roaming AppData is the
// per-user config location there.
#[cfg(windows)]
fn default_config_root() -> PathBuf {
PathBuf::from(std::env::var("APPDATA").unwrap_or_default()).join("carrier")
}
#[cfg(not(windows))]
fn default_config_root() -> PathBuf {
PathBuf::from(std::env::var("HOME").unwrap_or_default())
.join(".config")
.join("carrier")
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,38 +0,0 @@
#!/usr/bin/env bash
# Build carrier and bundle the result: carrier.exe plus the GTK runtime
# into dist/, then dist/ into carrier-windows-<version>.zip.
#
# Run from the carrier checkout inside the MSYS2 MINGW64 shell (or with
# the MINGW64 toolchain on PATH — windows/setup.sh installs the deps,
# and the forgejo Windows runner host already carries them).
set -euo pipefail
say() { printf '\n==> %s\n' "$*"; }
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
say "building carrier (release; the first build takes a while)"
cargo build --release --locked || die "cargo build failed"
say "bundling the runtime"
rm -rf dist
mkdir -p dist/share/glib-2.0/schemas \
dist/share/icons/hicolor/512x512/apps \
dist/share/icons/hicolor/256x256/apps \
dist/share/icons/hicolor/128x128/apps
cp target/release/carrier.exe dist/
for dll in $(ldd target/release/carrier.exe | awk '/mingw64/ {print $3}'); do
cp "$dll" dist/ || die "copying $dll failed"
done
[ "$(ls dist/*.dll | wc -l)" -gt 0 ] || die "no DLLs collected — ldd found none under /mingw64"
cp /mingw64/share/glib-2.0/schemas/* dist/share/glib-2.0/schemas/
glib-compile-schemas dist/share/glib-2.0/schemas/
cp -r /mingw64/share/icons/Adwaita dist/share/icons/
cp -r /mingw64/share/icons/hicolor dist/share/icons/
cp data/icons/com.randogoth.carrier.png dist/share/icons/hicolor/512x512/apps/
cp data/icons/com.randogoth.carrier-256.png dist/share/icons/hicolor/256x256/apps/
cp data/icons/com.randogoth.carrier-128.png dist/share/icons/hicolor/128x128/apps/
say "zipping"
version=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)
rm -f "carrier-windows-$version.zip"
(cd dist && zip -qr "../carrier-windows-$version.zip" .)

View file

@ -1,42 +0,0 @@
; carrier Windows installer. Packs the same portable bundle windows/
; bundle.sh produces (dist/) into a classic setup.exe: Program Files,
; Start Menu shortcut, optional desktop icon, Apps & Features uninstall
; entry. Built by windows/installer.sh, which passes /DVersion= from
; Cargo.toml.
;
; Not yet wired into the forgejo release job - deliberately local until
; the experience is signed off.
#ifndef Version
#error Pass the version: ISCC /DVersion=0.2.0 windows/carrier.iss
#endif
[Setup]
AppId={{345BADA4-146B-4F57-B7D3-E9FD5D664F4B}
AppName=carrier
AppVersion={#Version}
AppPublisher=randogoth
DefaultDirName={autopf}\carrier
DefaultGroupName=carrier
; Next to the portable zip (paths relative to this script's directory).
OutputDir=..
OutputBaseFilename=carrier-windows-{#Version}-setup
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64compatible
UninstallDisplayName=carrier
[Tasks]
Name: "desktopicon"; Description: "Create a &desktop icon"; GroupDescription: "Additional icons:"
[Files]
Source: "..\dist\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
[Icons]
; Shortcut icons come from carrier.exe's embedded .ico resource.
Name: "{group}\carrier"; Filename: "{app}\carrier.exe"
Name: "{autodesktop}\carrier"; Filename: "{app}\carrier.exe"; Tasks: desktopicon
[Run]
Filename: "{app}\carrier.exe"; Description: "Launch carrier"; Flags: nowait postinstall skipifsilent

View file

@ -1,31 +0,0 @@
#!/usr/bin/env bash
# Pack the dist/ bundle into carrier-windows-<version>-setup.exe.
#
# Run from the carrier checkout after windows/bundle.sh has produced
# dist/. Requires Inno Setup 7 (per-user install, which its own
# installer's "just me" option produces).
set -euo pipefail
say() { printf '\n==> %s\n' "$*"; }
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
# MSYS2's login shells prune LOCALAPPDATA from the environment, so fall
# back to cygpath (CSIDL 28 = local AppData of the current user).
if [ -n "${LOCALAPPDATA:-}" ]; then
appdata=$(cygpath -u "$LOCALAPPDATA")
else
appdata=$(cygpath -F 28)
fi
ISCC="$appdata/Programs/Inno Setup 7/ISCC.exe"
[ -f "$ISCC" ] || die "Inno Setup not found at $ISCC — install it from https://jrsoftware.org/isdl.php"
[ -d dist ] || die "no dist/ bundle — run windows/bundle.sh first"
version=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)
say "compiling the installer"
# MSYS2_ARG_CONV_EXCL stops MSYS2 mangling /DVersion into a path.
MSYS2_ARG_CONV_EXCL="*" "$ISCC" /DVersion="$version" windows/carrier.iss \
|| die "ISCC failed"
say "done — carrier-windows-$version-setup.exe (in the checkout root)"

View file

@ -1,38 +0,0 @@
#!/usr/bin/env bash
# Windows build in one command. Run inside the MSYS2 MINGW64 shell from
# the carrier checkout: it installs the dependencies into MSYS2, clones
# fumi next to carrier if missing, builds, and bundles carrier plus the
# GTK runtime into dist/ and carrier-windows.zip.
#
# If MSYS2 updates its own core mid-run the shell may die — just rerun.
# Building and bundling live in windows/bundle.sh, shared with the
# forgejo Windows release job.
set -euo pipefail
say() { printf '\n==> %s\n' "$*"; }
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
[ -d ../fumi/core ] || {
say "fumi not found next to carrier — cloning it"
git clone ssh://git@code.randogoth.com:2222/randogoth/fumi.git ../fumi \
|| die "cloning fumi failed — check your SSH key in ~/.ssh"
}
say "installing dependencies (first run downloads several hundred MB)"
pacman -Syu --noconfirm || die "pacman update failed — reopen the shell and rerun this script"
pacman -S --noconfirm --needed \
mingw-w64-x86_64-gtk4 \
mingw-w64-x86_64-libadwaita \
mingw-w64-x86_64-adwaita-icon-theme \
mingw-w64-x86_64-toolchain \
mingw-w64-x86_64-rust \
mingw-w64-x86_64-git \
zip \
|| die "installing the build stack failed"
pkg-config --exists gtk4 libadwaita-1 \
|| die "gtk4/libadwaita invisible to pkg-config — are you in the MINGW64 shell?"
bash windows/bundle.sh
say "done — test with ./dist/carrier.exe (SmartScreen may ask once)"