Compare commits
No commits in common. "main" and "v0.2.0" have entirely different histories.
21 changed files with 140 additions and 2430 deletions
|
|
@ -66,30 +66,3 @@ jobs:
|
|||
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
|
||||
|
|
|
|||
|
|
@ -73,75 +73,6 @@ jobs:
|
|||
"$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:
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -13,4 +13,3 @@ result-*
|
|||
*.db-shm
|
||||
/dist
|
||||
carrier-windows-*.zip
|
||||
carrier-windows-*-setup.exe
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -116,7 +116,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "carrier"
|
||||
version = "0.3.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"fluent-bundle",
|
||||
"fumi-core",
|
||||
|
|
@ -430,7 +430,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fumi-core"
|
||||
version = "0.1.1"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chacha20poly1305",
|
||||
"data-encoding",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "carrier"
|
||||
version = "0.3.1"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
description = "A minimalist smolmail client built on fumi-core, with a Relm4 UI"
|
||||
license = "Apache-2.0"
|
||||
|
|
|
|||
201
LICENSE
201
LICENSE
|
|
@ -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.
|
||||
73
README.md
73
README.md
|
|
@ -1,39 +1,33 @@
|
|||
# carrier
|
||||
|
||||
[](LICENSE) [](https://github.com/jetify-com/devbox/) [](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.
|
||||
|
||||

|
||||
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.
|
||||
- Custom `Key: value` frontmatter rows when writing: one line per header under the subject, validated against the frontmatter rules — the client's own fields (subject, reply-to, in-reply-to, accept) can't be shadowed, and rows survive a stash as part of the draft. Received mail shows the sender's own fields in the message detail, between the meta line and the body; replying to such a mail asks whether to carry copies of the fields into the reply, where they stay editable.
|
||||
- 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; each contact's detail names the server its address lives on and whether that server's key is pinned, with a pin action when it is not. 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.
|
||||
- Optional strict first contact: writing to someone new whose address carries no key means their key has to be looked up on their own server, and an unpinned server can answer with one of its own. With the setting on, that send stops and the compose offers to fetch and pin the server's key first, so it can be checked against what the operator published. A smol:// address whose key differs from the one already on file asks before replacing it, either way.
|
||||
- Key rotation with a confirm-first step, a network timeout, gsmol backup export/import that never contains the seed, and a log-out that erases the local files and returns to onboarding. Preferences live in a plaintext sidecar beside the store, so they survive a restart.
|
||||
|
||||
## 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; `settings.txt` holds the preferences as one `key value` line each.
|
||||
|
||||
## Build
|
||||
## Run and verify
|
||||
|
||||
```
|
||||
devbox run run # build release and launch
|
||||
|
|
@ -41,21 +35,38 @@ 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.
|
||||
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.
|
||||
|
||||
### Windows
|
||||
|
||||
Install MSYS2 (`winget install MSYS2.MSYS2`), open the **MSYS2 MINGW64** shell, and run the setup script from your carrier checkout:
|
||||
Install MSYS2 (`winget install MSYS2.MSYS2`), open the **MSYS2 MINGW64**
|
||||
shell, and run the one-command script from the 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.
|
||||
`windows/setup.sh` installs the GTK and Rust stack into MSYS2, clones fumi
|
||||
next to carrier if it is missing, builds, and bundles carrier.exe with the
|
||||
GTK runtime into `dist/` and a zip — the complete Windows distribution. The
|
||||
app keeps its config in `%APPDATA%\carrier`. If MSYS2 updates its core
|
||||
mid-run the shell may close; rerun the script and it continues. Cloning
|
||||
over SSH needs the key in `~/.ssh` inside the MSYS2 home.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -14,8 +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>
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 337 KiB |
|
|
@ -42,11 +42,6 @@ fetch-result-note-rejected = { $total } fetched, { $stored } stored, { $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
|
||||
##
|
||||
|
|
@ -64,8 +59,6 @@ send-result-note-tofu-token = Sent to { $address } ({ $bytes } bytes), accept to
|
|||
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
|
||||
##
|
||||
|
|
@ -78,13 +71,6 @@ 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 }…
|
||||
|
||||
|
|
@ -99,23 +85,16 @@ 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
|
||||
|
||||
|
|
@ -131,19 +110,6 @@ contacts-reply-address-invalid-error = The sender's reply address isn't in the r
|
|||
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
|
||||
|
||||
|
|
@ -158,11 +124,6 @@ action-delete = Delete
|
|||
# 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
|
||||
|
||||
|
|
@ -173,19 +134,10 @@ 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
|
||||
##
|
||||
|
|
@ -199,24 +151,6 @@ mail-meta-line-yes = { $label } { $from } · { $time } UTC · id { $id }… · a
|
|||
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
|
||||
|
||||
|
|
@ -238,11 +172,6 @@ mail-empty-description = Pick a message from the list above to read it.
|
|||
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
|
||||
|
||||
|
|
@ -252,8 +181,6 @@ 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
|
||||
|
||||
|
|
@ -274,20 +201,8 @@ contact-meta-line-no = { $state } · server not pinned
|
|||
|
||||
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
|
||||
|
||||
|
|
@ -304,18 +219,9 @@ 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
|
||||
|
||||
|
|
@ -341,27 +247,23 @@ 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
|
||||
advanced-logout-description = Removes this identity from this app entirely: the secret, pins, contacts, and every cached message. 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 all 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
|
||||
|
|
@ -369,24 +271,12 @@ 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
|
||||
|
|
@ -412,9 +302,7 @@ delete-dialog-body-kept = Deleting it here leaves the server's copy in place. De
|
|||
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
|
||||
reset-dialog-title = Sign out and delete all data?
|
||||
# `$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.
|
||||
|
|
@ -439,7 +327,6 @@ 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
|
||||
|
|
@ -448,14 +335,3 @@ first-contact-blocked-error = { $address } isn't a contact yet, and { $host } is
|
|||
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.
|
||||
|
|
|
|||
384
src/app.rs
384
src/app.rs
|
|
@ -60,16 +60,6 @@ impl ReadMarks {
|
|||
}
|
||||
}
|
||||
|
||||
/// One account folder, as the switcher lists it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountEntry {
|
||||
pub id: String,
|
||||
/// The registered address; None until signup completes.
|
||||
pub label: Option<String>,
|
||||
/// Whether the folder holds a master another account can reuse.
|
||||
pub has_key: bool,
|
||||
}
|
||||
|
||||
/// Onboarding: create a master or restore one from a pasted seed.
|
||||
pub struct Onboard {
|
||||
pub mode: OnboardMode,
|
||||
|
|
@ -212,16 +202,6 @@ pub enum Message {
|
|||
OnboardSeedChanged(String),
|
||||
OnboardRestoreFromSeed,
|
||||
OnboardSeedSaved,
|
||||
/// Copies the master of the named account into this one, so both
|
||||
/// addresses share a key.
|
||||
OnboardReuse(String),
|
||||
|
||||
// Accounts
|
||||
AccountSwitch(String),
|
||||
AccountAdd,
|
||||
/// Abandons an account that's still being set up and returns to
|
||||
/// `Carrier::previous`.
|
||||
AccountAddCancelled,
|
||||
|
||||
// Signup
|
||||
SignupUserChanged(String),
|
||||
|
|
@ -366,14 +346,7 @@ pub enum PendingOp {
|
|||
}
|
||||
|
||||
pub struct Carrier {
|
||||
/// The config root holding `accounts/` and `active.txt`.
|
||||
pub root: PathBuf,
|
||||
/// The active account's id and folder.
|
||||
pub id: String,
|
||||
pub dir: PathBuf,
|
||||
pub accounts: Vec<AccountEntry>,
|
||||
/// The account to return to when one still being set up is cancelled.
|
||||
pub previous: Option<String>,
|
||||
pub store: Arc<Store>,
|
||||
pub master: Option<[u8; KEY_LEN]>,
|
||||
pub marks: ReadMarks,
|
||||
|
|
@ -425,36 +398,7 @@ pub struct Carrier {
|
|||
}
|
||||
|
||||
impl Carrier {
|
||||
/// Opens the account `active.txt` names, else the first one, else a
|
||||
/// fresh empty one.
|
||||
pub fn boot(root: PathBuf) -> (Carrier, Option<PendingOp>) {
|
||||
let id = match ops::load_active(&root)
|
||||
.or_else(|| ops::list_accounts(&root).into_iter().next())
|
||||
{
|
||||
Some(id) => id,
|
||||
None => match ops::new_account(&root) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
eprintln!("cannot create an account in {}: {e}", root.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
Carrier::boot_account(root, id)
|
||||
}
|
||||
|
||||
pub fn boot_account(root: PathBuf, id: String) -> (Carrier, Option<PendingOp>) {
|
||||
let dir = ops::account_dir(&root, &id);
|
||||
if let Err(e) = ops::save_active(&root, &id) {
|
||||
eprintln!("{e}");
|
||||
}
|
||||
let accounts = load_accounts(&root);
|
||||
// An unregistered account next to registered ones is one being
|
||||
// added, even after a restart, so it always offers a way back.
|
||||
let previous = accounts
|
||||
.iter()
|
||||
.find(|entry| entry.id != id && entry.label.is_some())
|
||||
.map(|entry| entry.id.clone());
|
||||
pub fn boot(dir: PathBuf) -> (Carrier, Option<PendingOp>) {
|
||||
let store = match Store::open(&dir.join("carrier.db")) {
|
||||
Ok(store) => Arc::new(store),
|
||||
Err(e) => {
|
||||
|
|
@ -472,14 +416,9 @@ impl Carrier {
|
|||
};
|
||||
let drafts = ops::load_drafts(&dir.join("drafts.txt"));
|
||||
let settings = ops::load_settings(&dir.join("settings.txt"));
|
||||
let previous = previous.filter(|_| !registered);
|
||||
let mut app = Carrier {
|
||||
let app = Carrier {
|
||||
marks: ReadMarks::load(dir.join("read.txt")),
|
||||
root,
|
||||
id,
|
||||
dir,
|
||||
accounts,
|
||||
previous,
|
||||
store,
|
||||
master,
|
||||
page,
|
||||
|
|
@ -520,29 +459,12 @@ impl Carrier {
|
|||
compose_fresh: Cell::new(false),
|
||||
form_fresh: Cell::new(true),
|
||||
};
|
||||
// Through the busy guard, so a switch can't land while this
|
||||
// snapshot is still loading the old account's lists.
|
||||
let op = if registered { app.refresh() } else { None };
|
||||
let op = registered.then(|| PendingOp::Snapshot {
|
||||
read: app.marks.snapshot(),
|
||||
});
|
||||
(app, op)
|
||||
}
|
||||
|
||||
/// Accounts other than this one whose master a new account can reuse.
|
||||
pub fn reusable_accounts(&self) -> Vec<&AccountEntry> {
|
||||
self.accounts
|
||||
.iter()
|
||||
.filter(|entry| entry.id != self.id && entry.has_key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether another account already holds this address. Two stores
|
||||
/// fetching one mailbox would split its mail between them.
|
||||
fn address_taken(&self, addr: &Address) -> bool {
|
||||
let short = addr.short();
|
||||
self.accounts
|
||||
.iter()
|
||||
.any(|entry| entry.id != self.id && entry.label.as_deref() == Some(short.as_str()))
|
||||
}
|
||||
|
||||
fn master(&self) -> [u8; KEY_LEN] {
|
||||
self.master.unwrap_or([0u8; KEY_LEN])
|
||||
}
|
||||
|
|
@ -584,7 +506,7 @@ impl Carrier {
|
|||
return;
|
||||
}
|
||||
self.drafts.push(Draft {
|
||||
id: ops::random_id(),
|
||||
id: ops::draft_id(),
|
||||
to: compose.to,
|
||||
subject: compose.subject,
|
||||
body: compose.body,
|
||||
|
|
@ -614,51 +536,27 @@ impl Carrier {
|
|||
self.compose_fresh.set(true);
|
||||
}
|
||||
|
||||
/// Deletes the active account's folder, then boots `next`, or
|
||||
/// whichever account `boot` picks. The connection is swapped out
|
||||
/// before the files go because Windows refuses to remove a file a
|
||||
/// live connection holds, and the busy guard has already seen to it
|
||||
/// that no operation holds a clone of the store.
|
||||
fn remove_active(&mut self, next: Option<String>) -> Result<Option<PendingOp>, String> {
|
||||
self.store = Arc::new(Store::open_in_memory().map_err(|e| e.to_string())?);
|
||||
ops::remove_account(&self.root, &self.id)?;
|
||||
let root = self.root.clone();
|
||||
let (app, op) = match next {
|
||||
Some(id) => Carrier::boot_account(root, id),
|
||||
None => Carrier::boot(root),
|
||||
};
|
||||
*self = app;
|
||||
Ok(op)
|
||||
}
|
||||
|
||||
/// Signing out removes this account only; the app moves on to another
|
||||
/// one, or back to onboarding when it was the last.
|
||||
fn factory_reset(&mut self) -> Option<PendingOp> {
|
||||
match self.remove_active(None) {
|
||||
Ok(op) => {
|
||||
match self.store.account().ok().flatten() {
|
||||
Some(addr) => self.note(i18n::t1(
|
||||
"logged-out-switched-note",
|
||||
"address",
|
||||
addr.short(),
|
||||
)),
|
||||
None => self.note(i18n::t("logged-out-note")),
|
||||
}
|
||||
op
|
||||
}
|
||||
/// Factory reset: close the store, erase the config directory, then
|
||||
/// boot a fresh model in place. The connection is swapped out before
|
||||
/// the files go because Windows refuses to remove a file a live
|
||||
/// connection holds, and the busy guard has already seen to it that
|
||||
/// no operation holds a clone of the store.
|
||||
fn factory_reset(&mut self) {
|
||||
let store = match Store::open_in_memory() {
|
||||
Ok(store) => Arc::new(store),
|
||||
Err(e) => {
|
||||
self.fail(e);
|
||||
None
|
||||
self.fail(e.to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.store = store;
|
||||
if let Err(e) = ops::factory_reset(&self.dir) {
|
||||
self.fail(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the model with another account's, keeping nothing of this
|
||||
/// one but what's already on disk.
|
||||
fn switch_to(&mut self, id: String) -> Option<PendingOp> {
|
||||
let (app, op) = Carrier::boot_account(self.root.clone(), id);
|
||||
let (app, _) = Carrier::boot(self.dir.clone());
|
||||
*self = app;
|
||||
op
|
||||
self.note(i18n::t("logged-out-note"));
|
||||
}
|
||||
|
||||
/// Cancelling out of a not-yet-registered identity removes the key
|
||||
|
|
@ -948,52 +846,6 @@ impl Carrier {
|
|||
}
|
||||
}
|
||||
_ if self.busy.is_some() => return Vec::new(),
|
||||
Message::AccountSwitch(id) => {
|
||||
if id == self.id || !self.accounts.iter().any(|entry| entry.id == id) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.stash_draft();
|
||||
return self.switch_to(id).into_iter().collect();
|
||||
}
|
||||
Message::AccountAdd => {
|
||||
self.stash_draft();
|
||||
match ops::new_account(&self.root) {
|
||||
Ok(id) => {
|
||||
let previous = self.id.clone();
|
||||
self.switch_to(id);
|
||||
self.previous = Some(previous);
|
||||
}
|
||||
Err(e) => self.fail(e),
|
||||
}
|
||||
}
|
||||
Message::AccountAddCancelled => {
|
||||
let Some(previous) = self.previous.clone() else {
|
||||
return Vec::new();
|
||||
};
|
||||
return match self.remove_active(Some(previous)) {
|
||||
Ok(op) => op.into_iter().collect(),
|
||||
Err(e) => {
|
||||
self.fail(e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
}
|
||||
Message::OnboardReuse(id) => {
|
||||
if id == self.id {
|
||||
return Vec::new();
|
||||
}
|
||||
let source = ops::account_dir(&self.root, &id).join("carrier.key");
|
||||
let copied = ops::load_key(&source)
|
||||
.and_then(|master| ops::save_key(&self.key_path(), &master).map(|()| master));
|
||||
match copied {
|
||||
Ok(master) => {
|
||||
self.master = Some(master);
|
||||
self.page = Page::Signup;
|
||||
self.note(i18n::t("seed-reused-note"));
|
||||
}
|
||||
Err(e) => self.fail(e),
|
||||
}
|
||||
}
|
||||
Message::OnboardCreate => {
|
||||
let master = ops::new_master();
|
||||
if let Err(e) = ops::save_key(&self.key_path(), &master) {
|
||||
|
|
@ -1061,14 +913,6 @@ impl Carrier {
|
|||
self.fail(i18n::t("signup-enter-user-host-error"));
|
||||
return Vec::new();
|
||||
};
|
||||
if self.address_taken(&addr) {
|
||||
self.fail(i18n::t1(
|
||||
"accounts-duplicate-error",
|
||||
"address",
|
||||
addr.short(),
|
||||
));
|
||||
return Vec::new();
|
||||
}
|
||||
let invite = if self.signup.invite.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
|
@ -1084,14 +928,6 @@ impl Carrier {
|
|||
self.fail(i18n::t("signup-enter-user-host-error"));
|
||||
return Vec::new();
|
||||
};
|
||||
if self.address_taken(&addr) {
|
||||
self.fail(i18n::t1(
|
||||
"accounts-duplicate-error",
|
||||
"address",
|
||||
addr.short(),
|
||||
));
|
||||
return Vec::new();
|
||||
}
|
||||
if !self.start("restoring") {
|
||||
return Vec::new();
|
||||
}
|
||||
|
|
@ -1515,7 +1351,7 @@ impl Carrier {
|
|||
}
|
||||
Message::ResetConfirmed => {
|
||||
self.dialog = None;
|
||||
return self.factory_reset().into_iter().collect();
|
||||
self.factory_reset();
|
||||
}
|
||||
Message::RotateConfirmed => {
|
||||
self.dialog = None;
|
||||
|
|
@ -1558,8 +1394,6 @@ impl Carrier {
|
|||
// address is bound and it's safe to leave the page.
|
||||
if self.page == Page::Signup {
|
||||
self.page = Page::Mail;
|
||||
self.accounts = load_accounts(&self.root);
|
||||
self.previous = None;
|
||||
}
|
||||
self.refresh()
|
||||
}
|
||||
|
|
@ -1619,20 +1453,6 @@ impl Carrier {
|
|||
}
|
||||
}
|
||||
|
||||
fn load_accounts(root: &std::path::Path) -> Vec<AccountEntry> {
|
||||
ops::list_accounts(root)
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let dir = ops::account_dir(root, &id);
|
||||
AccountEntry {
|
||||
label: ops::account_label(&dir),
|
||||
has_key: dir.join("carrier.key").exists(),
|
||||
id,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1643,136 +1463,6 @@ mod tests {
|
|||
app
|
||||
}
|
||||
|
||||
/// A registered account folder under `root`, with its own master.
|
||||
fn registered(root: &std::path::Path, address: &str) -> String {
|
||||
let id = ops::new_account(root).unwrap();
|
||||
let dir = ops::account_dir(root, &id);
|
||||
ops::save_key(&dir.join("carrier.key"), &ops::new_master()).unwrap();
|
||||
let store = Store::open(&dir.join("carrier.db")).unwrap();
|
||||
store
|
||||
.set_account(&Address::parse(address).unwrap())
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Two registered accounts, booted into `alice`.
|
||||
fn two_accounts() -> (Carrier, String, String) {
|
||||
let root = tempfile::tempdir().unwrap().keep();
|
||||
let alice = registered(&root, "alice@example.org");
|
||||
let bob = registered(&root, "bob@example.org");
|
||||
ops::save_active(&root, &alice).unwrap();
|
||||
let (app, _) = Carrier::boot(root);
|
||||
(app, alice, bob)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boot_opens_the_active_account_through_the_busy_guard() {
|
||||
let (app, alice, _) = two_accounts();
|
||||
assert_eq!(app.id, alice);
|
||||
assert_eq!(app.page, Page::Mail);
|
||||
assert_eq!(app.accounts.len(), 2);
|
||||
assert!(app.previous.is_none());
|
||||
// The boot snapshot holds the guard, so no switch can overtake it.
|
||||
assert_eq!(app.busy.as_deref(), Some("refreshing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_keeps_each_accounts_drafts_and_settings_apart() {
|
||||
let (mut app, alice, bob) = two_accounts();
|
||||
app.busy = None;
|
||||
app.handle(Message::StrictPinningChanged(true));
|
||||
app.handle(Message::ComposePressed);
|
||||
app.handle(Message::ComposeSubjectChanged("half written".into()));
|
||||
|
||||
let ops = app.handle(Message::AccountSwitch(bob.clone()));
|
||||
assert!(matches!(ops.as_slice(), [PendingOp::Snapshot { .. }]));
|
||||
assert_eq!(app.id, bob);
|
||||
assert_eq!(ops::load_active(&app.root).as_deref(), Some(bob.as_str()));
|
||||
assert!(app.compose.is_none());
|
||||
assert!(app.drafts.is_empty());
|
||||
assert!(!app.strict_pinning);
|
||||
|
||||
app.busy = None;
|
||||
app.handle(Message::AccountSwitch(alice.clone()));
|
||||
assert_eq!(app.drafts.len(), 1);
|
||||
assert_eq!(app.drafts[0].subject, "half written");
|
||||
assert!(app.strict_pinning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_waits_for_the_running_operation() {
|
||||
let (mut app, alice, bob) = two_accounts();
|
||||
app.busy = Some("fetching".into());
|
||||
assert!(app.handle(Message::AccountSwitch(bob)).is_empty());
|
||||
assert_eq!(app.id, alice);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_an_account_can_reuse_a_secret_or_be_cancelled() {
|
||||
let (mut app, alice, _) = two_accounts();
|
||||
app.busy = None;
|
||||
app.handle(Message::AccountAdd);
|
||||
assert_eq!(app.page, Page::Onboard);
|
||||
assert_eq!(app.previous.as_deref(), Some(alice.as_str()));
|
||||
assert_eq!(app.accounts.len(), 3);
|
||||
assert_eq!(app.reusable_accounts().len(), 2);
|
||||
let added = app.dir.clone();
|
||||
|
||||
app.handle(Message::OnboardReuse(alice.clone()));
|
||||
assert_eq!(app.page, Page::Signup);
|
||||
let alice_master = ops::load_key(&ops::account_dir(&app.root, &alice).join("carrier.key"));
|
||||
assert_eq!(app.master, alice_master.ok());
|
||||
assert_eq!(ops::load_key(&added.join("carrier.key")).ok(), app.master);
|
||||
|
||||
app.handle(Message::AccountAddCancelled);
|
||||
assert!(!added.exists());
|
||||
assert_eq!(app.id, alice);
|
||||
assert_eq!(app.page, Page::Mail);
|
||||
assert_eq!(app.accounts.len(), 2);
|
||||
}
|
||||
|
||||
/// Quitting halfway through adding an account must not strand the
|
||||
/// user in onboarding with no way back.
|
||||
#[test]
|
||||
fn an_unfinished_account_offers_a_way_back_after_a_restart() {
|
||||
let (mut app, alice, _) = two_accounts();
|
||||
app.busy = None;
|
||||
app.handle(Message::AccountAdd);
|
||||
let (rebooted, _) = Carrier::boot(app.root.clone());
|
||||
assert_eq!(rebooted.page, Page::Onboard);
|
||||
assert_eq!(rebooted.previous.as_deref(), Some(alice.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_another_account_holds_is_refused() {
|
||||
let (mut app, _, _) = two_accounts();
|
||||
app.busy = None;
|
||||
app.handle(Message::AccountAdd);
|
||||
app.handle(Message::OnboardCreate);
|
||||
app.handle(Message::OnboardSeedSaved);
|
||||
app.signup.user = "bob".into();
|
||||
app.signup.host = "example.org".into();
|
||||
assert!(app.handle(Message::SignupRegister).is_empty());
|
||||
assert!(app.status_error);
|
||||
assert!(app.handle(Message::SignupRecall).is_empty());
|
||||
assert!(app.busy.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_out_of_one_account_moves_to_the_other() {
|
||||
let (mut app, alice, bob) = two_accounts();
|
||||
app.busy = None;
|
||||
let alice_dir = app.dir.clone();
|
||||
app.handle(Message::ResetPressed);
|
||||
let ops = app.handle(Message::ResetConfirmed);
|
||||
assert!(matches!(ops.as_slice(), [PendingOp::Snapshot { .. }]));
|
||||
assert!(!alice_dir.exists());
|
||||
assert_eq!(app.id, bob);
|
||||
assert_eq!(app.page, Page::Mail);
|
||||
assert!(app.accounts.iter().all(|entry| entry.id != alice));
|
||||
assert!(app.dir.join("carrier.key").exists());
|
||||
}
|
||||
|
||||
/// Results clear the busy flag instead of deadlocking: this is the
|
||||
/// regression test for the guard bug the Iced version shipped with.
|
||||
#[test]
|
||||
|
|
@ -2063,7 +1753,7 @@ mod tests {
|
|||
app.handle(Message::StrictPinningChanged(true));
|
||||
app.handle(Message::KeepMailChanged(true));
|
||||
app.handle(Message::TimeoutChanged("90".into()));
|
||||
assert!(app.dir.join("settings.txt").exists());
|
||||
assert!(dir.join("settings.txt").exists());
|
||||
|
||||
let (rebooted, _) = Carrier::boot(dir);
|
||||
assert!(rebooted.strict_pinning);
|
||||
|
|
@ -2105,13 +1795,12 @@ mod tests {
|
|||
#[test]
|
||||
fn factory_reset_erases_the_files_and_returns_to_onboarding() {
|
||||
let dir = tempfile::tempdir().unwrap().keep();
|
||||
let (mut app, _) = Carrier::boot(dir);
|
||||
let account = app.dir.clone();
|
||||
let (mut app, _) = Carrier::boot(dir.clone());
|
||||
let master = ops::new_master();
|
||||
ops::save_key(&account.join("carrier.key"), &master).unwrap();
|
||||
ops::save_key(&dir.join("carrier.key"), &master).unwrap();
|
||||
app.master = Some(master);
|
||||
std::fs::write(account.join("read.txt"), "ab").unwrap();
|
||||
std::fs::write(account.join("drafts.txt"), "draft2 id 1 -\n\n\n\n").unwrap();
|
||||
std::fs::write(dir.join("read.txt"), "ab").unwrap();
|
||||
std::fs::write(dir.join("drafts.txt"), "draft2 id 1 -\n\n\n\n").unwrap();
|
||||
app.signup.host = "typed-before-the-reset.example".into();
|
||||
app.signup.user = "alice".into();
|
||||
app.handle(Message::StrictPinningChanged(true));
|
||||
|
|
@ -2139,15 +1828,14 @@ mod tests {
|
|||
assert!(app.signup.user.is_empty());
|
||||
assert!(app.form_fresh.get());
|
||||
for name in ["carrier.key", "read.txt", "drafts.txt", "settings.txt"] {
|
||||
assert!(!account.join(name).exists(), "{name} survived the reset");
|
||||
assert!(!dir.join(name).exists(), "{name} survived the reset");
|
||||
}
|
||||
// The last account's folder goes too; boot starts a fresh one.
|
||||
assert!(!account.exists());
|
||||
assert_ne!(app.dir, account);
|
||||
// Preferences go with everything else, so the rebooted model is
|
||||
// back to its defaults rather than the logged-out user's.
|
||||
assert!(!app.strict_pinning);
|
||||
assert!(app.dir.join("carrier.db").exists());
|
||||
// The store is reopened by boot, so a fresh empty db is the one
|
||||
// file a reset legitimately leaves behind.
|
||||
assert!(dir.join("carrier.db").exists());
|
||||
assert!(app.store.account().unwrap().is_none());
|
||||
}
|
||||
|
||||
|
|
@ -2160,11 +1848,11 @@ mod tests {
|
|||
let (mut app, _) = Carrier::boot(dir.clone());
|
||||
app.handle(Message::OnboardCreate);
|
||||
assert!(app.master.is_some());
|
||||
assert!(app.dir.join("carrier.key").exists());
|
||||
assert!(dir.join("carrier.key").exists());
|
||||
|
||||
app.handle(Message::OnboardBack);
|
||||
assert!(app.master.is_none());
|
||||
assert!(!app.dir.join("carrier.key").exists());
|
||||
assert!(!dir.join("carrier.key").exists());
|
||||
assert_eq!(app.page, Page::Onboard);
|
||||
assert_eq!(app.onboard.mode, OnboardMode::Start);
|
||||
}
|
||||
|
|
@ -2195,7 +1883,7 @@ mod tests {
|
|||
|
||||
app.handle(Message::SignupStartOver);
|
||||
assert!(app.master.is_none());
|
||||
assert!(!app.dir.join("carrier.key").exists());
|
||||
assert!(!dir.join("carrier.key").exists());
|
||||
assert_eq!(app.page, Page::Onboard);
|
||||
assert!(app.signup.host.is_empty());
|
||||
}
|
||||
|
|
|
|||
52
src/demo.rs
52
src/demo.rs
|
|
@ -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)?;
|
||||
|
|
|
|||
36
src/main.rs
36
src/main.rs
|
|
@ -1,12 +1,7 @@
|
|||
//! 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)]
|
||||
|
|
@ -23,10 +18,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 +26,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 +36,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")
|
||||
}
|
||||
|
|
|
|||
181
src/ops.rs
181
src/ops.rs
|
|
@ -3,7 +3,7 @@
|
|||
//! values these functions return.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -138,8 +138,8 @@ pub struct Draft {
|
|||
pub at: i64,
|
||||
}
|
||||
|
||||
/// A fresh id for a draft or an account folder: 8 random bytes as hex.
|
||||
pub fn random_id() -> String {
|
||||
/// A fresh draft id: 8 random bytes as hex, unique within the sidecar.
|
||||
pub fn draft_id() -> String {
|
||||
use rand_core::RngCore;
|
||||
let mut bytes = [0u8; 8];
|
||||
rand_core::OsRng.fill_bytes(&mut bytes);
|
||||
|
|
@ -399,13 +399,19 @@ pub fn delete_key(path: &Path) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Removes every file an account owns: the store, the key and the sidecars.
|
||||
/// Removes every file carrier owns: the store, the key and both sidecars.
|
||||
/// A missing file is fine — reset is idempotent — but a file that cannot
|
||||
/// be removed is reported, so a reset that did not take is never silent.
|
||||
/// The caller must close the store first: Windows refuses to remove a
|
||||
/// file a live connection holds.
|
||||
pub fn factory_reset(dir: &Path) -> Result<(), String> {
|
||||
for name in ACCOUNT_FILES {
|
||||
for name in [
|
||||
"carrier.db",
|
||||
"carrier.key",
|
||||
"read.txt",
|
||||
"drafts.txt",
|
||||
"settings.txt",
|
||||
] {
|
||||
let path = dir.join(name);
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
|
|
@ -419,113 +425,6 @@ pub fn factory_reset(dir: &Path) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The files one account owns. The WAL pair is listed so a migration never
|
||||
/// strands commits a crash left uncheckpointed.
|
||||
const ACCOUNT_FILES: [&str; 7] = [
|
||||
"carrier.db",
|
||||
"carrier.db-wal",
|
||||
"carrier.db-shm",
|
||||
"carrier.key",
|
||||
"read.txt",
|
||||
"drafts.txt",
|
||||
"settings.txt",
|
||||
];
|
||||
|
||||
pub fn accounts_dir(root: &Path) -> PathBuf {
|
||||
root.join("accounts")
|
||||
}
|
||||
|
||||
pub fn account_dir(root: &Path, id: &str) -> PathBuf {
|
||||
accounts_dir(root).join(id)
|
||||
}
|
||||
|
||||
/// Moves a single-account config root into `accounts/<id>/` and makes it
|
||||
/// the active account. Runs before anything opens the files; a root that
|
||||
/// already has `accounts/`, or holds none of the files, is left alone.
|
||||
pub fn migrate_legacy(root: &Path) -> Result<(), String> {
|
||||
if accounts_dir(root).exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let present: Vec<&str> = ACCOUNT_FILES
|
||||
.into_iter()
|
||||
.filter(|name| root.join(name).exists())
|
||||
.collect();
|
||||
if present.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let id = random_id();
|
||||
let dir = account_dir(root, &id);
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| i18n::t_args("file-error", Some(&file_error_args(&dir, &e))))?;
|
||||
for name in present {
|
||||
let from = root.join(name);
|
||||
std::fs::rename(&from, dir.join(name))
|
||||
.map_err(|e| i18n::t_args("file-error", Some(&file_error_args(&from, &e))))?;
|
||||
}
|
||||
save_active(root, &id)
|
||||
}
|
||||
|
||||
/// Account folder names, sorted so the switcher's order is stable.
|
||||
pub fn list_accounts(root: &Path) -> Vec<String> {
|
||||
let mut ids: Vec<String> = std::fs::read_dir(accounts_dir(root))
|
||||
.map(|entries| {
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|entry| entry.path().is_dir())
|
||||
.filter_map(|entry| entry.file_name().into_string().ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
/// The active account's id, if `active.txt` names a folder that exists.
|
||||
pub fn load_active(root: &Path) -> Option<String> {
|
||||
let id = std::fs::read_to_string(root.join("active.txt")).ok()?;
|
||||
let id = id.trim().to_string();
|
||||
(!id.is_empty() && account_dir(root, &id).is_dir()).then_some(id)
|
||||
}
|
||||
|
||||
pub fn save_active(root: &Path, id: &str) -> Result<(), String> {
|
||||
let path = root.join("active.txt");
|
||||
std::fs::write(&path, format!("{id}\n"))
|
||||
.map_err(|e| i18n::t_args("file-error", Some(&file_error_args(&path, &e))))
|
||||
}
|
||||
|
||||
/// Creates an empty account folder and returns its id.
|
||||
pub fn new_account(root: &Path) -> Result<String, String> {
|
||||
let id = random_id();
|
||||
let dir = account_dir(root, &id);
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| i18n::t_args("file-error", Some(&file_error_args(&dir, &e))))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Deletes an account's files, then its folder. Same contract as
|
||||
/// `factory_reset`: the caller must close the account's store first.
|
||||
pub fn remove_account(root: &Path, id: &str) -> Result<(), String> {
|
||||
let dir = account_dir(root, id);
|
||||
factory_reset(&dir)?;
|
||||
match std::fs::remove_dir(&dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(i18n::t_args("file-error", Some(&file_error_args(&dir, &e)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The address an account folder is registered under, read from its own
|
||||
/// store; None for a folder that isn't registered or can't be opened.
|
||||
pub fn account_label(dir: &Path) -> Option<String> {
|
||||
// Opening a missing database would create one.
|
||||
let path = dir.join("carrier.db");
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let store = Store::open(&path).ok()?;
|
||||
store.account().ok().flatten().map(|addr| addr.short())
|
||||
}
|
||||
|
||||
pub fn account(store: &Store, master: &[u8; KEY_LEN]) -> Result<account::Account, String> {
|
||||
let index = store.rotations().map_err(explain)?;
|
||||
account::Account::new(*master, index).map_err(explain)
|
||||
|
|
@ -716,14 +615,7 @@ pub fn send(
|
|||
.collect();
|
||||
let draft = client::SendDraft {
|
||||
address: &addr,
|
||||
// Trimmed at the send site, before the payload is signed, so the
|
||||
// signature covers exactly the text the author meant. A compose buffer
|
||||
// ends with whatever the author last pressed, and a trailing newline
|
||||
// is not content — but it is content once it has been signed, and
|
||||
// nothing downstream can remove it without invalidating the
|
||||
// signature. smolboid, for one, refuses a post whose text carries a
|
||||
// line break, because a published record is one line of a feed.
|
||||
text: body.trim().to_string(),
|
||||
text: body.to_string(),
|
||||
subject,
|
||||
reply_to,
|
||||
headers: &owned,
|
||||
|
|
@ -1074,55 +966,13 @@ mod tests {
|
|||
assert_eq!(load_settings(&path).timeout, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_root_migrates_into_an_account_folder_once() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root = root.path();
|
||||
// A fresh root has nothing to move.
|
||||
migrate_legacy(root).unwrap();
|
||||
assert!(list_accounts(root).is_empty());
|
||||
|
||||
for name in ["carrier.key", "carrier.db", "carrier.db-wal", "read.txt"] {
|
||||
std::fs::write(root.join(name), name).unwrap();
|
||||
}
|
||||
migrate_legacy(root).unwrap();
|
||||
let ids = list_accounts(root);
|
||||
assert_eq!(ids.len(), 1);
|
||||
assert_eq!(load_active(root).as_ref(), Some(&ids[0]));
|
||||
let dir = account_dir(root, &ids[0]);
|
||||
for name in ["carrier.key", "carrier.db", "carrier.db-wal", "read.txt"] {
|
||||
assert!(!root.join(name).exists(), "{name} stayed in the root");
|
||||
assert_eq!(std::fs::read_to_string(dir.join(name)).unwrap(), name);
|
||||
}
|
||||
|
||||
// Once `accounts/` exists, stray root files are left alone.
|
||||
std::fs::write(root.join("carrier.key"), "stray").unwrap();
|
||||
migrate_legacy(root).unwrap();
|
||||
assert_eq!(list_accounts(root).len(), 1);
|
||||
assert!(root.join("carrier.key").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_active_account_must_name_an_existing_folder() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root = root.path();
|
||||
save_active(root, "gone").unwrap();
|
||||
assert!(load_active(root).is_none());
|
||||
let id = new_account(root).unwrap();
|
||||
save_active(root, &id).unwrap();
|
||||
assert_eq!(load_active(root), Some(id.clone()));
|
||||
remove_account(root, &id).unwrap();
|
||||
assert!(load_active(root).is_none());
|
||||
assert!(list_accounts(root).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drafts_round_trip_through_the_sidecar() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("drafts.txt");
|
||||
let drafts = vec![
|
||||
Draft {
|
||||
id: random_id(),
|
||||
id: draft_id(),
|
||||
to: "smol://alice@example.org/KEY".into(),
|
||||
subject: "line breaks\nand \\backslashes\r".into(),
|
||||
body: "a draft\n\nwith paragraphs\nand a \\ literal".into(),
|
||||
|
|
@ -1137,7 +987,7 @@ mod tests {
|
|||
at: now(),
|
||||
},
|
||||
Draft {
|
||||
id: random_id(),
|
||||
id: draft_id(),
|
||||
to: "bob@example.org".into(),
|
||||
subject: "no reply".into(),
|
||||
body: String::new(),
|
||||
|
|
@ -1437,8 +1287,7 @@ mod tests {
|
|||
fn demo_seed_carries_a_mail_with_custom_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
crate::demo::seed(dir.path());
|
||||
let account = account_dir(dir.path(), "demo");
|
||||
let store = Store::open(&account.join("carrier.db")).unwrap();
|
||||
let store = Store::open(&dir.path().join("carrier.db")).unwrap();
|
||||
// The demo master is fixed bytes in demo.rs.
|
||||
let master: [u8; KEY_LEN] = core::array::from_fn(|i| i as u8 + 1);
|
||||
let empty = HashSet::new();
|
||||
|
|
|
|||
127
src/view.rs
127
src/view.rs
|
|
@ -21,8 +21,8 @@ use relm4::ComponentSender;
|
|||
use relm4::{Component, ComponentParts};
|
||||
|
||||
use crate::app::{
|
||||
AccountEntry, Carrier, Dialog, DialogKind, Message, OnboardMode, OpResult, Page, PendingOp,
|
||||
SignupMode, SignupStep,
|
||||
Carrier, Dialog, DialogKind, Message, OnboardMode, OpResult, Page, PendingOp, SignupMode,
|
||||
SignupStep,
|
||||
};
|
||||
use crate::i18n;
|
||||
use crate::ops::{self, Contact, Folder, Row};
|
||||
|
|
@ -178,32 +178,6 @@ fn contact_state(contact: &Contact) -> String {
|
|||
i18n::t(id)
|
||||
}
|
||||
|
||||
/// An account's address, or the not-registered label while it has none.
|
||||
fn account_title(entry: &AccountEntry) -> String {
|
||||
entry
|
||||
.label
|
||||
.clone()
|
||||
.unwrap_or_else(|| i18n::t("account-not-registered-value"))
|
||||
}
|
||||
|
||||
/// One switcher row, named by account id; the active one carries a check.
|
||||
fn account_row(entry: &AccountEntry, active: bool) -> gtk::ListBoxRow {
|
||||
let line = gtk::Box::new(Orientation::Horizontal, 6);
|
||||
line.set_margin_top(6);
|
||||
line.set_margin_bottom(6);
|
||||
let title = label(&account_title(entry), "mono");
|
||||
title.set_hexpand(true);
|
||||
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
||||
line.append(&title);
|
||||
let check = gtk::Image::from_icon_name("object-select-symbolic");
|
||||
check.set_opacity(if active { 1.0 } else { 0.0 });
|
||||
line.append(&check);
|
||||
let row = gtk::ListBoxRow::new();
|
||||
row.set_widget_name(&entry.id);
|
||||
row.set_child(Some(&line));
|
||||
row
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CarrierWidgets {
|
||||
/// `AlertDialog::present` needs a parent, and only the window is one.
|
||||
|
|
@ -251,9 +225,6 @@ pub struct CarrierWidgets {
|
|||
// sidebar bottom: own identity and the contact list
|
||||
sidebar_own: gtk::Label,
|
||||
sidebar_contacts: gtk::ListBox,
|
||||
/// The account switcher beside the identity card, and its list.
|
||||
accounts_menu: gtk::MenuButton,
|
||||
accounts_list: gtk::ListBox,
|
||||
|
||||
// the QR-only share popover on the detail view's header
|
||||
detail_share: gtk::MenuButton,
|
||||
|
|
@ -274,9 +245,6 @@ pub struct CarrierWidgets {
|
|||
// onboarding
|
||||
onboard_seed_label: gtk::Label,
|
||||
onboard_seed_entry: adw::EntryRow,
|
||||
onboard_reuse: gtk::Box,
|
||||
onboard_reuse_list: gtk::ListBox,
|
||||
onboard_cancel: gtk::Button,
|
||||
|
||||
// signup
|
||||
signup_key_button: gtk::Button,
|
||||
|
|
@ -424,37 +392,10 @@ impl CarrierWidgets {
|
|||
own_text.append(&sidebar_own);
|
||||
let own_row = gtk::Button::new();
|
||||
own_row.add_css_class("card");
|
||||
own_row.set_hexpand(true);
|
||||
own_row.set_halign(Align::Fill);
|
||||
own_row.set_margin_top(6);
|
||||
own_row.set_child(Some(&own_text));
|
||||
|
||||
// The account switcher sits beside the card: one list of accounts,
|
||||
// the active one checked, and a way to add another.
|
||||
let accounts_list = gtk::ListBox::new();
|
||||
accounts_list.add_css_class("navigation-sidebar");
|
||||
accounts_list.set_selection_mode(gtk::SelectionMode::None);
|
||||
let add_account = icon_label_button(
|
||||
"list-add-symbolic",
|
||||
&i18n::t("accounts-add-button"),
|
||||
"quiet",
|
||||
);
|
||||
let accounts_box = gtk::Box::new(Orientation::Vertical, 6);
|
||||
accounts_box.set_width_request(260);
|
||||
accounts_box.append(&accounts_list);
|
||||
accounts_box.append(&add_account);
|
||||
let accounts_popover = gtk::Popover::new();
|
||||
accounts_popover.set_child(Some(&accounts_box));
|
||||
let accounts_menu = gtk::MenuButton::new();
|
||||
accounts_menu.set_icon_name("system-switch-user-symbolic");
|
||||
accounts_menu.add_css_class("flat");
|
||||
accounts_menu.add_css_class("circular");
|
||||
accounts_menu.set_valign(Align::Center);
|
||||
accounts_menu.set_tooltip_text(Some(&i18n::t("accounts-menu-tooltip")));
|
||||
accounts_menu.set_popover(Some(&accounts_popover));
|
||||
let own_line = gtk::Box::new(Orientation::Horizontal, 6);
|
||||
own_line.set_margin_top(6);
|
||||
own_line.append(&own_row);
|
||||
own_line.append(&accounts_menu);
|
||||
sidebar_bottom.append(&own_line);
|
||||
sidebar_bottom.append(&own_row);
|
||||
|
||||
// The list's header carries its one action: importing a
|
||||
// smol:// address, in a popover off a plain add button.
|
||||
|
|
@ -996,19 +937,6 @@ impl CarrierWidgets {
|
|||
let restore_button = button(&i18n::t("onboard-restore-button"), "pill-quiet");
|
||||
onboard_start.append(&create_button);
|
||||
onboard_start.append(&restore_button);
|
||||
// Shown only while other accounts hold a secret: one row each.
|
||||
let onboard_reuse = gtk::Box::new(Orientation::Vertical, 6);
|
||||
onboard_reuse.set_margin_top(12);
|
||||
onboard_reuse.append(&label(&i18n::t("onboard-reuse-title"), "heading"));
|
||||
let reuse_description = label(&i18n::t("onboard-reuse-description"), "dim");
|
||||
reuse_description.set_wrap(true);
|
||||
onboard_reuse.append(&reuse_description);
|
||||
let onboard_reuse_list = boxed_list();
|
||||
onboard_reuse.append(&onboard_reuse_list);
|
||||
onboard_start.append(&onboard_reuse);
|
||||
let onboard_cancel = button(&i18n::t("accounts-add-cancel-button"), "pill-quiet");
|
||||
onboard_cancel.set_margin_top(12);
|
||||
onboard_start.append(&onboard_cancel);
|
||||
stack.add_named(¢er(&onboard_start), Some("onboard"));
|
||||
|
||||
let onboard_created = gtk::Box::new(Orientation::Vertical, 12);
|
||||
|
|
@ -1268,17 +1196,6 @@ impl CarrierWidgets {
|
|||
});
|
||||
|
||||
own_row.connect_clicked(input(Message::OwnSelected));
|
||||
let switch_sender = sender.clone();
|
||||
let switch_popover = accounts_popover.clone();
|
||||
accounts_list.connect_row_activated(move |_, row| {
|
||||
switch_popover.popdown();
|
||||
switch_sender.input(Message::AccountSwitch(row.widget_name().to_string()));
|
||||
});
|
||||
let add_sender = sender.clone();
|
||||
add_account.connect_clicked(move |_| {
|
||||
accounts_popover.popdown();
|
||||
add_sender.input(Message::AccountAdd);
|
||||
});
|
||||
import_button.connect_clicked(input(Message::ContactImportPressed));
|
||||
let import_sender = sender.clone();
|
||||
contact_import.connect_changed(move |entry| {
|
||||
|
|
@ -1352,11 +1269,6 @@ impl CarrierWidgets {
|
|||
|
||||
create_button.connect_clicked(input(Message::OnboardCreate));
|
||||
restore_button.connect_clicked(input(Message::OnboardRestore));
|
||||
onboard_cancel.connect_clicked(input(Message::AccountAddCancelled));
|
||||
let reuse_sender = sender.clone();
|
||||
onboard_reuse_list.connect_row_activated(move |_, row| {
|
||||
reuse_sender.input(Message::OnboardReuse(row.widget_name().to_string()));
|
||||
});
|
||||
restore_back.connect_clicked(input(Message::OnboardBack));
|
||||
created_back.connect_clicked(input(Message::OnboardBack));
|
||||
saved_button.connect_clicked(input(Message::OnboardSeedSaved));
|
||||
|
|
@ -1436,8 +1348,6 @@ impl CarrierWidgets {
|
|||
compose_pin_button,
|
||||
sidebar_own,
|
||||
sidebar_contacts: contacts_list,
|
||||
accounts_menu,
|
||||
accounts_list,
|
||||
detail_share,
|
||||
detail_copy,
|
||||
detail_qr,
|
||||
|
|
@ -1452,9 +1362,6 @@ impl CarrierWidgets {
|
|||
timeout_row,
|
||||
onboard_seed_label,
|
||||
onboard_seed_entry,
|
||||
onboard_reuse,
|
||||
onboard_reuse_list,
|
||||
onboard_cancel,
|
||||
signup_key_button,
|
||||
signup_key_card,
|
||||
signup_key_value,
|
||||
|
|
@ -2148,30 +2055,6 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
|||
);
|
||||
}
|
||||
|
||||
// Accounts: the switcher hides while one is being set up, so an
|
||||
// unfinished account is only ever left through Cancel.
|
||||
w.accounts_menu
|
||||
.set_visible(matches!(model.page, Page::Mail | Page::Settings));
|
||||
clear_list(&w.accounts_list);
|
||||
for entry in &model.accounts {
|
||||
w.accounts_list
|
||||
.append(&account_row(entry, entry.id == model.id));
|
||||
}
|
||||
clear_list(&w.onboard_reuse_list);
|
||||
let reusable = model.reusable_accounts();
|
||||
w.onboard_reuse.set_visible(!reusable.is_empty());
|
||||
for entry in reusable {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(account_title(entry))
|
||||
.use_markup(false)
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.set_widget_name(&entry.id);
|
||||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
w.onboard_reuse_list.append(&row);
|
||||
}
|
||||
w.onboard_cancel.set_visible(model.previous.is_some());
|
||||
|
||||
// Settings: the identity/server rows are fixed, so only their values
|
||||
// change.
|
||||
if let Some(snap) = &model.snapshot {
|
||||
|
|
|
|||
|
|
@ -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" .)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)"
|
||||
|
|
@ -5,8 +5,6 @@
|
|||
# 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' "$*"; }
|
||||
|
|
@ -33,6 +31,31 @@ pacman -S --noconfirm --needed \
|
|||
pkg-config --exists gtk4 libadwaita-1 \
|
||||
|| die "gtk4/libadwaita invisible to pkg-config — are you in the MINGW64 shell?"
|
||||
|
||||
bash windows/bundle.sh
|
||||
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" .)
|
||||
|
||||
say "done — test with ./dist/carrier.exe (SmartScreen may ask once)"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue