From cc902fc973a6f6c0fbd0a66eff20c84a0738a855 Mon Sep 17 00:00:00 2001 From: Sven Wagener Date: Fri, 25 Sep 2026 09:40:09 +0200 Subject: [PATCH 1/2] feat(cli): ship the terminal client for macOS in every release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal surface built on macOS but could not keep a secret there: the platform store fell through to secret-tool, which a Mac does not have. It now uses the keychain with the app's own access list — any binary signed by the TorroMail team reads the item without a dialog — so the app, the server and the terminal client share passwords and client keys. - torromail-keychain: new crate mirroring the app's teamScopedAccess(). It is the one crate allowed unsafe, confined to its sys module; the rest of the workspace still forbids it. An unsigned build never replaces an item it cannot read, since only it could read the replacement. - torromail-control: KeychainStore as the macOS platform store. - torromail-tui: keychain dialogs off for the run; the background check installs a launchd agent on macOS; notifications via Notification Center. - Release: scripts/build-cli-macos.sh builds torromail and torromail-mcp universal, signs them with the Developer ID under the hardened runtime, notarizes and packs torromail--universal-macos.tar.gz. The macOS release job runs it, and publishing now requires a download for every system. - scripts/install-macos.sh installs a signed local build. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 25 +++- AGENTS.md | 5 + Cargo.lock | 11 ++ Cargo.toml | 1 + README.md | 19 ++- crates/torromail-control/Cargo.toml | 8 +- crates/torromail-control/src/secrets.rs | 52 ++++++- crates/torromail-keychain/Cargo.toml | 26 ++++ crates/torromail-keychain/src/lib.rs | 28 ++++ crates/torromail-keychain/src/macos.rs | 145 ++++++++++++++++++ crates/torromail-keychain/src/sys.rs | 188 ++++++++++++++++++++++++ crates/torromail-tui/Cargo.toml | 6 + crates/torromail-tui/src/autocheck.rs | 100 ++++++++++++- crates/torromail-tui/src/check.rs | 15 +- crates/torromail-tui/src/data.rs | 27 ++-- crates/torromail-tui/src/main.rs | 6 +- crates/torromail-tui/tests/render.rs | 34 +++++ docs/RELEASING.md | 11 +- scripts/build-cli-macos.sh | 99 +++++++++++++ scripts/install-macos.sh | 70 +++++++++ 20 files changed, 835 insertions(+), 41 deletions(-) create mode 100644 crates/torromail-keychain/Cargo.toml create mode 100644 crates/torromail-keychain/src/lib.rs create mode 100644 crates/torromail-keychain/src/macos.rs create mode 100644 crates/torromail-keychain/src/sys.rs create mode 100755 scripts/build-cli-macos.sh create mode 100755 scripts/install-macos.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa38c59..08d08fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,10 @@ name: Release # One tag, one release, every platform: the signed and notarized Mac app with -# its Sparkle appcast, the Linux programs with their AUR package, and the -# Windows programs (unsigned, and marked experimental until someone has used -# them on a real machine). +# its Sparkle appcast, the Mac terminal programs (signed and notarized too), +# the Linux programs with their AUR package, and the Windows programs +# (unsigned, and marked experimental until someone has used them on a real +# machine). # # The release is assembled as a draft and only published once every job has # delivered. The Mac app's updater reads releases/latest/download/appcast.xml, @@ -169,6 +170,13 @@ jobs: - name: Sign and notarize .app run: ./scripts/codesign-macos.sh + # Same certificate, same team as the app: that is what lets the terminal + # programs and the app share keychain items without a dialog. + - name: Build, sign and notarize the terminal programs + env: + VERSION: ${{ needs.version.outputs.version }} + run: ./scripts/build-cli-macos.sh + - name: Build DMG run: ./scripts/build-dmg.sh @@ -223,6 +231,8 @@ jobs: dist/TorroMail-*.dmg dist/SHA256SUMS.txt dist/appcast.xml + dist/torromail-*-universal-macos.tar.gz + dist/torromail-*-universal-macos.tar.gz.sha256 if-no-files-found: error - name: Cleanup keychain @@ -250,9 +260,14 @@ jobs: run: | set -euo pipefail ls -l assets - # The three an installed Mac app will come looking for. + # The appcast an installed Mac app comes looking for, and a download + # for every system. test -f assets/appcast.xml - ls assets/TorroMail-*.dmg assets/torromail-*-x86_64-linux.tar.gz assets/torromail-*-x86_64-windows.zip > /dev/null + ls assets/TorroMail-*.dmg \ + assets/torromail-*-universal-macos.tar.gz \ + assets/torromail-*-x86_64-linux.tar.gz \ + assets/torromail-*-aarch64-linux.tar.gz \ + assets/torromail-*-x86_64-windows.zip > /dev/null flags=(--draft --title "$TAG" --generate-notes) if [[ "$PRERELEASE" == "true" ]]; then flags+=(--prerelease); fi diff --git a/AGENTS.md b/AGENTS.md index c550e06..34232ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,11 @@ localization. cache defaults, pending actions, and search result sets. - `crates/torromail-mcp`: explicit MCP tool catalog and stdio line server facade. +- `crates/torromail-tui`: the terminal control surface (`torromail`) for + macOS, Linux and Windows — the same boundary as the app applies. +- `crates/torromail-keychain`: the macOS keychain with the app's team-scoped + access lists. The only crate allowed `unsafe`, and only in its `sys` module; + the rest of the workspace forbids it. - `apps/TorroMailApp`: SwiftUI macOS configuration/control app. - `docs`: architecture notes, product decisions, and implementation plans. diff --git a/Cargo.lock b/Cargo.lock index 1a5e1ee..bcb4333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1743,6 +1743,7 @@ dependencies = [ "roxmltree", "serde_json", "sha2", + "torromail-keychain", ] [[package]] @@ -1767,6 +1768,15 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "torromail-keychain" +version = "0.10.3" +dependencies = [ + "core-foundation", + "security-framework", + "security-framework-sys", +] + [[package]] name = "torromail-mcp" version = "0.10.3" @@ -1798,6 +1808,7 @@ dependencies = [ "serde_json", "torromail-control", "torromail-discovery", + "torromail-keychain", "torromail-mcp", ] diff --git a/Cargo.toml b/Cargo.toml index 48b58d8..8a5a9a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/torromail-core", "crates/torromail-discovery", "crates/torromail-imap-tls", + "crates/torromail-keychain", "crates/torromail-mcp", "crates/torromail-oauth", "crates/torromail-tui", diff --git a/README.md b/README.md index 6b4b883..0be8408 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ log — and neither shows your mail. The data below is a demo setup. - `crates/torromail-mcp`: explicit MCP tool surface and stdio line server facade. - `crates/torromail-control`: the configuration model every surface shares — accounts and their state file, the policy document writer, MCP client setup, provider discovery, log readers, platform paths. - `crates/torromail-discovery`: the network half of account discovery — a small DNS client (MX, TXT, SRV), HTTPS autoconfig fetches and a TCP probe — behind the `Network` trait `torromail-control` decides with. -- `crates/torromail-tui`: terminal control surface (`torromail`), the Linux counterpart to the macOS app: add accounts, set permissions, connect assistants, read the log. This is not a mail client UI either. +- `crates/torromail-tui`: terminal control surface (`torromail`) for macOS, Linux and Windows — the counterpart to the macOS app, and on Linux and Windows the only one: add accounts, set permissions, connect assistants, read the log. This is not a mail client UI either. +- `crates/torromail-keychain`: the macOS keychain with the app's team-scoped access lists, so the app, the server and the terminal surface share secrets without a dialog. The one crate allowed `unsafe`, confined to its `sys` module. - `contracts`: behaviour pinned as shared cases. The Rust tests and the Swift contract suite run the same files, so the two implementations cannot drift apart silently. - `apps/TorroMailApp`: macOS SwiftUI configuration/control app. This is not a mail client UI. - `docs`: architecture notes and product decisions. @@ -97,19 +98,25 @@ swift build --package-path apps/TorroMailApp --scratch-path apps/TorroMailApp/.b On Linux, `scripts/install-linux.sh` builds a release and installs `torromail` and `torromail-mcp` into `~/.local/bin` (no root; `--uninstall` removes them again). +On a Mac, `scripts/install-macos.sh` does the same and signs the copies with your +development identity — without that signature they could not share keychain items with +the app. On Windows (experimental) the same two programs keep their data in `%LOCALAPPDATA%\TorroMail` and passwords in the Credential Manager; the background check is not available there yet. -Prebuilt Linux programs are part of every release: tarballs for x86_64 and aarch64 and the +Prebuilt terminal programs are part of every release: a signed and notarized universal +tarball for macOS, a zip for Windows, and for Linux tarballs for x86_64 and aarch64 and the `torromail-bin` AUR package ([packaging/aur](packaging/aur/torromail-bin/PKGBUILD)), which the release builds and installs in a clean Arch container before anything is published. To try the terminal surface from a checkout: `cargo run -p torromail-tui`. It reads the shared data directory — `~/Library/Application Support/TorroMail` on macOS, `$XDG_STATE_HOME/torromail` -(default `~/.local/state/torromail`) elsewhere, and keeps secrets in the desktop's Secret -Service (`secret-tool` from libsecret must be installed). Keys: `1`–`7` sections, arrows to +(default `~/.local/state/torromail`) on Linux, and keeps secrets in the macOS keychain or +the desktop's Secret Service (`secret-tool` from libsecret must be installed). On a Mac an +unsigned `cargo run` build can store its own secrets but cannot read the app's; use +`scripts/install-macos.sh` for one that can. Keys: `1`–`7` sections, arrows to select, `n` new account, `enter` edit, `c` connect an assistant, `q` to quit — the bottom line always lists what applies. @@ -121,8 +128,8 @@ For a runnable dev bundle, use `scripts/make-app-bundle.sh`. Pushing a `v*` tag triggers [.github/workflows/release.yml](.github/workflows/release.yml), which builds a universal `TorroMail.app`, signs and notarizes it, packages a -`.dmg`, signs a Sparkle appcast, builds the Linux programs and their AUR -package, and publishes everything as one GitHub Release — assembled as a draft +`.dmg`, signs a Sparkle appcast, builds the terminal programs for macOS +(signed and notarized), Linux (with their AUR package) and Windows, and publishes everything as one GitHub Release — assembled as a draft first, so nothing half-finished is ever the latest release. Installed Mac copies can check that feed automatically or on demand. See [docs/RELEASING.md](docs/RELEASING.md) for versioning rules, the required diff --git a/crates/torromail-control/Cargo.toml b/crates/torromail-control/Cargo.toml index 7f55fbb..243d506 100644 --- a/crates/torromail-control/Cargo.toml +++ b/crates/torromail-control/Cargo.toml @@ -15,7 +15,11 @@ roxmltree = "0.21" serde_json = "1" sha2 = "0.10" -# The Windows Credential Manager. Only there: macOS has the keychain and every -# other desktop the Secret Service, both reached without a library of ours. +# The macOS keychain, with the team-scoped access list the app uses. +[target.'cfg(target_os = "macos")'.dependencies] +torromail-keychain = { path = "../torromail-keychain" } + +# The Windows Credential Manager. Only there: every other desktop has the +# Secret Service, reached without a library of ours. [target.'cfg(windows)'.dependencies] keyring = { version = "3", default-features = false, features = ["windows-native"] } diff --git a/crates/torromail-control/src/secrets.rs b/crates/torromail-control/src/secrets.rs index 5fd9cbc..32ccaf7 100644 --- a/crates/torromail-control/src/secrets.rs +++ b/crates/torromail-control/src/secrets.rs @@ -1,8 +1,8 @@ //! Where passwords, token sets and client keys are kept. Never in a file of -//! ours: on macOS the keychain holds them, on Windows the Credential Manager, -//! elsewhere the desktop's Secret Service (gnome-keyring, KWallet). There is -//! no plaintext fallback — a -//! machine without a secret service cannot hold an account, and saying so is +//! ours: on macOS the keychain holds them (shared by the app, the server and +//! the terminal surface), on Windows the Credential Manager, elsewhere the +//! desktop's Secret Service (gnome-keyring, KWallet). There is no plaintext +//! fallback — a machine without a secret service cannot hold an account, and saying so is //! better than pretending. //! //! The Secret Service is reached through `secret-tool`, the command libsecret @@ -176,9 +176,44 @@ impl SecretStore for CredentialManagerStore { } } -/// The store this platform keeps secrets in: the Credential Manager on -/// Windows, the Secret Service everywhere else this is called. (On macOS the -/// app and the server talk to the keychain themselves.) +/// The macOS keychain, with the access list the app gives its items: any +/// binary signed by TorroMail's team reads them without a dialog, so what the +/// terminal surface stores the app and the server read, and the other way +/// round. See `torromail-keychain` for how. +#[cfg(target_os = "macos")] +#[derive(Debug, Clone, Default)] +pub struct KeychainStore; + +#[cfg(target_os = "macos")] +impl KeychainStore { + /// A keychain that would have needed a dialog is usually a locked one — a + /// state of the machine. Anything else it says is a refusal. + fn error(error: torromail_keychain::Error) -> SecretError { + if error.needs_interaction() { + SecretError::Unavailable(error.to_string()) + } else { + SecretError::Failed(error.to_string()) + } + } +} + +#[cfg(target_os = "macos")] +impl SecretStore for KeychainStore { + fn get(&self, service: &str, account: &str) -> Result, SecretError> { + torromail_keychain::read(service, account).map_err(Self::error) + } + + fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretError> { + torromail_keychain::save(service, account, secret).map_err(Self::error) + } + + fn delete(&self, service: &str, account: &str) -> Result<(), SecretError> { + torromail_keychain::delete(service, account).map_err(Self::error) + } +} + +/// The store this platform keeps secrets in: the keychain on macOS, the +/// Credential Manager on Windows, the Secret Service everywhere else. /// /// `TORROMAIL_SECRET_TOOL` names another `secret-tool` — for tests, and for /// installations that keep it off the `PATH` an assistant starts the server @@ -193,6 +228,9 @@ pub fn platform_store() -> Box { { match std::env::var_os("TORROMAIL_SECRET_TOOL") { Some(program) => Box::new(SecretToolStore::with_program(program)), + #[cfg(target_os = "macos")] + None => Box::new(KeychainStore), + #[cfg(not(target_os = "macos"))] None => Box::new(SecretToolStore::default()), } } diff --git a/crates/torromail-keychain/Cargo.toml b/crates/torromail-keychain/Cargo.toml new file mode 100644 index 0000000..2b035a5 --- /dev/null +++ b/crates/torromail-keychain/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "torromail-keychain" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "The macOS keychain the way the TorroMail app uses it: items any binary of the signing team reads without a dialog." + +# Not `workspace = true`, and on purpose: the workspace forbids `unsafe`, and a +# forbid cannot be lifted further down. This crate is the one place that has to +# call Security.framework functions no safe binding covers, so it denies unsafe +# instead and allows it in exactly one module, `sys`. Everything else matches +# the workspace. +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +unwrap_used = "deny" +dbg_macro = "deny" +todo = "deny" + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.10" +security-framework = "3" +security-framework-sys = "2" diff --git a/crates/torromail-keychain/src/lib.rs b/crates/torromail-keychain/src/lib.rs new file mode 100644 index 0000000..8f9f075 --- /dev/null +++ b/crates/torromail-keychain/src/lib.rs @@ -0,0 +1,28 @@ +//! The macOS keychain, used the way the TorroMail app uses it. +//! +//! A fresh item gets an access list whose application list is empty ("any +//! application") and whose partition list names the signing Team ID. The +//! partition is the gate macOS actually enforces, so every binary signed by +//! that team — the app, the server it bundles, the terminal surface — reads +//! and writes the item without a dialog, and a rebuild keeps working because +//! the grant is keyed to the team, not to one binary's hash. This mirrors +//! `KeychainStore.teamScopedAccess()` in the app; an item either side creates +//! is one the other can read. +//! +//! An unsigned build has no team to scope to and stores its items with the +//! keychain's default list, which names only itself. Those work for that build +//! alone — good enough for a local `cargo run`, useless for sharing. +//! +//! Nothing here asks the user. [`silence_prompts`] turns the keychain's +//! dialogs off for the whole process; a read that would need one fails +//! instead, and the callers treat that as "this secret has to be entered +//! again", which is what it means. + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +mod sys; + +#[cfg(target_os = "macos")] +pub use macos::*; diff --git a/crates/torromail-keychain/src/macos.rs b/crates/torromail-keychain/src/macos.rs new file mode 100644 index 0000000..b75f5c2 --- /dev/null +++ b/crates/torromail-keychain/src/macos.rs @@ -0,0 +1,145 @@ +use security_framework::os::macos::keychain::{KeychainUserInteractionLock, SecKeychain}; +use security_framework::passwords; + +use crate::sys; + +/// `errSecItemNotFound`: nothing is stored under that name. +const ITEM_NOT_FOUND: i32 = -25300; +/// `errSecDuplicateItem`: an item with that name is already there. +const DUPLICATE_ITEM: i32 = -25299; +/// `errSecInteractionNotAllowed`: answering would have needed a dialog — +/// a locked keychain, or an item whose list does not let this binary in. +const INTERACTION_NOT_ALLOWED: i32 = -25308; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error { + /// The `OSStatus` the Security framework answered with. + pub code: i32, + pub message: String, +} + +impl Error { + fn from_status(code: i32) -> Self { + let message = security_framework::base::Error::from_code(code) + .message() + .unwrap_or_else(|| format!("keychain error {code}")); + Self { code, message } + } + + /// Whether the keychain could not be asked without a dialog. Usually a + /// locked keychain, which is a state of the machine rather than of the + /// item. + #[must_use] + pub fn needs_interaction(&self) -> bool { + self.code == INTERACTION_NOT_ALLOWED + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{} ({})", self.message, self.code) + } +} + +impl std::error::Error for Error {} + +/// Turns the keychain's dialogs off for this process for as long as the +/// returned lock lives. `None` when the keychain refused; everything still +/// works then, it may just ask. +#[must_use] +pub fn silence_prompts() -> Option { + SecKeychain::disable_user_interaction().ok() +} + +/// The Team ID in this process's own code signature, or `None` for an +/// unsigned build. Read rather than hardcoded, so it tracks the signature. +#[must_use] +pub fn team_identifier() -> Option { + sys::own_team_identifier() +} + +/// The stored secret. `Ok(None)` is an answer — nothing is stored under that +/// name — and distinct from not having been able to read it. +pub fn read(service: &str, account: &str) -> Result, Error> { + match passwords::get_generic_password(service, account) { + Ok(bytes) => String::from_utf8(bytes) + .map(Some) + .map_err(|_| Error { code: 0, message: "the stored item is not valid UTF-8".to_owned() }), + Err(error) if error.code() == ITEM_NOT_FOUND => Ok(None), + Err(error) => Err(Error::from_status(error.code())), + } +} + +/// Stores a secret, the way the app's `savePassword` does. +/// +/// A new item is created with the team-scoped list. An item this build can +/// still read is refreshed in place, leaving its list alone — that list is +/// what lets the other TorroMail binaries in. An item it cannot read is a +/// legacy one whose list names a build that no longer exists; its value is +/// unreachable for good, so it is replaced, because only a fresh add attaches +/// a current list. Never delete-then-add otherwise: a delete that succeeds +/// before an add that fails drops the secret. +pub fn save(service: &str, account: &str, secret: &str) -> Result<(), Error> { + if add(service, account, secret)? { + return Ok(()); + } + if read(service, account).ok().flatten().is_none() { + // An unsigned build cannot read the team's items either, and whatever + // it put in their place only it could read. Replacing would take the + // secret away from the app and the server, so it leaves the item be. + if team_identifier().is_none() { + return Err(Error { + code: DUPLICATE_ITEM, + message: "this build of TorroMail is not signed, so it may not replace a keychain item \ + the signed TorroMail programs share" + .to_owned(), + }); + } + delete(service, account)?; + return if add(service, account, secret)? { Ok(()) } else { Err(Error::from_status(DUPLICATE_ITEM)) }; + } + passwords::set_generic_password(service, account, secret.as_bytes()).map_err(|error| Error::from_status(error.code())) +} + +/// Removing what is not there succeeds: gone is the state it aims for. +pub fn delete(service: &str, account: &str) -> Result<(), Error> { + match passwords::delete_generic_password(service, account) { + Ok(()) => Ok(()), + Err(error) if error.code() == ITEM_NOT_FOUND => Ok(()), + Err(error) => Err(Error::from_status(error.code())), + } +} + +/// Creates the item. `Ok(false)` when one is already there; the caller then +/// decides whether to refresh or replace it. +fn add(service: &str, account: &str, secret: &str) -> Result { + let team = team_identifier(); + match sys::add_generic_password(service, account, secret.as_bytes(), team.as_deref()) { + 0 => Ok(true), + DUPLICATE_ITEM => Ok(false), + status => Err(Error::from_status(status)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_partition_list_is_a_property_list_naming_the_team() { + let hex = sys::partition_description("ABCDE12345"); + let bytes: Vec = (0..hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&hex[index..index + 2], 16).unwrap_or_default()) + .collect(); + let text = String::from_utf8(bytes).unwrap_or_default(); + assert!(text.starts_with("Partitions")); + assert!(text.contains("teamid:ABCDE12345")); + } + + #[test] + fn a_test_binary_is_not_signed_by_a_team() { + assert_eq!(team_identifier(), None); + } +} diff --git a/crates/torromail-keychain/src/sys.rs b/crates/torromail-keychain/src/sys.rs new file mode 100644 index 0000000..0964698 --- /dev/null +++ b/crates/torromail-keychain/src/sys.rs @@ -0,0 +1,188 @@ +//! The only `unsafe` in the workspace. Three things no safe binding covers: +//! building the team-scoped access list (the legacy `SecAccess`/`SecACL` +//! API), adding an item with that list attached, and reading this process's +//! own Team ID. Each wraps what it gets back under the matching Core +//! Foundation ownership rule before anything else touches it, so nothing +//! leaks and nothing is released twice. + +use std::ffi::c_void; +use std::ptr; + +use core_foundation::array::{CFArray, CFArrayRef}; +use core_foundation::base::{CFType, OSStatus, TCFType}; +use core_foundation::data::CFData; +use core_foundation::dictionary::{CFDictionary, CFDictionaryRef}; +use core_foundation::propertylist::{create_data, kCFPropertyListXMLFormat_v1_0}; +use core_foundation::string::{CFString, CFStringRef}; +use security_framework::os::macos::access::SecAccess; +use security_framework_sys::base::SecAccessRef; +use security_framework_sys::code_signing::{SecCSFlags, SecCodeCopySelf, SecCodeRef, SecStaticCodeRef}; +use security_framework_sys::item::{kSecAttrAccount, kSecAttrService, kSecClass, kSecClassGenericPassword, kSecValueData}; +use security_framework_sys::keychain_item::SecItemAdd; + +type SecACLRef = *const c_void; +/// `SecKeychainPromptSelector`, a `uint16_t`. +type PromptSelector = u16; + +/// `kSecCSSigningInformation`: ask `SecCodeCopySigningInformation` for the +/// signature's details, the Team ID among them. +const SIGNING_INFORMATION: SecCSFlags = 1 << 1; + +#[link(name = "Security", kind = "framework")] +unsafe extern "C" { + static kSecAttrAccess: CFStringRef; + static kSecCodeInfoTeamIdentifier: CFStringRef; + + fn SecAccessCreate(descriptor: CFStringRef, trusted_list: CFArrayRef, access: *mut SecAccessRef) -> OSStatus; + fn SecAccessCopyACLList(access: SecAccessRef, acl_list: *mut CFArrayRef) -> OSStatus; + fn SecACLCopyAuthorizations(acl: SecACLRef) -> CFArrayRef; + fn SecACLCopyContents( + acl: SecACLRef, + application_list: *mut CFArrayRef, + description: *mut CFStringRef, + prompt_selector: *mut PromptSelector, + ) -> OSStatus; + fn SecACLSetContents( + acl: SecACLRef, + application_list: CFArrayRef, + description: CFStringRef, + prompt_selector: PromptSelector, + ) -> OSStatus; + fn SecCodeCopyStaticCode(code: SecCodeRef, flags: SecCSFlags, static_code: *mut SecStaticCodeRef) -> OSStatus; + fn SecCodeCopySigningInformation(code: SecStaticCodeRef, flags: SecCSFlags, information: *mut CFDictionaryRef) + -> OSStatus; +} + +/// Releases a Core Foundation object this module was handed under the create +/// rule and has no typed wrapper for. +fn release(object: *const c_void) { + if !object.is_null() { + // SAFETY: `object` came from a Copy/Create call, so this module owns + // exactly one reference, and it is not used afterwards. + drop(unsafe { CFType::wrap_under_create_rule(object) }); + } +} + +/// Adds a generic password, with the team-scoped access list when there is a +/// team. Answers the raw `OSStatus`; the caller knows what a duplicate means. +pub(crate) fn add_generic_password(service: &str, account: &str, secret: &[u8], team: Option<&str>) -> OSStatus { + // SAFETY: the `kSec…` keys are immutable constants the framework exports. + let key = |constant: CFStringRef| unsafe { CFString::wrap_under_get_rule(constant) }; + let mut pairs: Vec<(CFString, CFType)> = vec![ + (key(unsafe { kSecClass }), key(unsafe { kSecClassGenericPassword }).into_CFType()), + (key(unsafe { kSecAttrService }), CFString::new(service).into_CFType()), + (key(unsafe { kSecAttrAccount }), CFString::new(account).into_CFType()), + (key(unsafe { kSecValueData }), CFData::from_buffer(secret).into_CFType()), + ]; + if let Some(access) = team.and_then(|team| team_scoped_access(service, team)) { + pairs.push((key(unsafe { kSecAttrAccess }), access.into_CFType())); + } + let attributes = CFDictionary::from_CFType_pairs(&pairs); + // SAFETY: `attributes` is a valid dictionary for the duration of the + // call; a null result pointer asks for nothing back. + unsafe { SecItemAdd(attributes.as_concrete_TypeRef(), ptr::null_mut()) } +} + +/// The partition list as `SecACLSetContents` wants it for the partition ACL: +/// an XML property list `{"Partitions": ["teamid:"]}`, hex-encoded into +/// the description string. +pub(crate) fn partition_description(team: &str) -> String { + let partitions = CFArray::from_CFTypes(&[CFString::new(&format!("teamid:{team}"))]); + let list = CFDictionary::from_CFType_pairs(&[(CFString::from_static_string("Partitions"), partitions.into_CFType())]); + let Ok(xml) = create_data(list.as_CFTypeRef(), kCFPropertyListXMLFormat_v1_0) else { + return String::new(); + }; + xml.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// An access list that grants read and write to any binary signed with +/// `team` and nothing else: the application list of the decrypt and encrypt +/// ACLs is emptied ("any application"), and the partition ACL is pinned to +/// the team. `None` when the framework refuses to build one; the item is then +/// stored with the default list. +fn team_scoped_access(service: &str, team: &str) -> Option { + let descriptor = CFString::new(service); + let nobody: CFArray = CFArray::from_CFTypes(&[]); + let mut raw_access: SecAccessRef = ptr::null_mut(); + // SAFETY: valid string and array in, an out pointer this frame owns. + let status = unsafe { SecAccessCreate(descriptor.as_concrete_TypeRef(), nobody.as_concrete_TypeRef(), &mut raw_access) }; + if status != 0 || raw_access.is_null() { + return None; + } + // SAFETY: SecAccessCreate hands over one reference. + let access = unsafe { SecAccess::wrap_under_create_rule(raw_access) }; + + let mut raw_list: CFArrayRef = ptr::null(); + // SAFETY: `access` is alive; the out pointer is this frame's. + if unsafe { SecAccessCopyACLList(access.as_concrete_TypeRef(), &mut raw_list) } != 0 || raw_list.is_null() { + return None; + } + // SAFETY: a Copy call hands over one reference. + let acls: CFArray = unsafe { CFArray::wrap_under_create_rule(raw_list) }; + let partitions = CFString::new(&partition_description(team)); + + for acl in acls.iter() { + let acl: SecACLRef = acl.as_CFTypeRef(); + let authorizations = authorizations(acl); + let mut applications: CFArrayRef = ptr::null(); + let mut description: CFStringRef = ptr::null(); + let mut prompt: PromptSelector = 0; + // SAFETY: `acl` lives as long as `acls`; the out pointers are ours + // and whatever they receive is released below. + let copied = unsafe { SecACLCopyContents(acl, &mut applications, &mut description, &mut prompt) }; + if copied != 0 { + continue; + } + let label = if description.is_null() { descriptor.as_concrete_TypeRef() } else { description }; + if authorizations.iter().any(|name| name == "ACLAuthorizationDecrypt" || name == "ACLAuthorizationEncrypt") { + // SAFETY: a null application list means "any application". + unsafe { SecACLSetContents(acl, ptr::null(), label, 0) }; + } + if authorizations.iter().any(|name| name == "ACLAuthorizationPartitionID") { + // SAFETY: all arguments are alive for the call. + unsafe { SecACLSetContents(acl, applications, partitions.as_concrete_TypeRef(), prompt) }; + } + release(applications.cast()); + release(description.cast()); + } + Some(access) +} + +/// The authorization tags of one ACL, as strings. +fn authorizations(acl: SecACLRef) -> Vec { + // SAFETY: `acl` is alive; the call returns an owned array or null. + let raw = unsafe { SecACLCopyAuthorizations(acl) }; + if raw.is_null() { + return Vec::new(); + } + // SAFETY: a Copy call hands over one reference. + let names: CFArray = unsafe { CFArray::wrap_under_create_rule(raw) }; + names.iter().filter_map(|name| name.downcast::()).map(|name| name.to_string()).collect() +} + +pub(crate) fn own_team_identifier() -> Option { + let mut code: SecCodeRef = ptr::null_mut(); + // SAFETY: an out pointer this frame owns. + if unsafe { SecCodeCopySelf(0, &mut code) } != 0 || code.is_null() { + return None; + } + let mut static_code: SecStaticCodeRef = ptr::null_mut(); + // SAFETY: `code` is the reference just handed over. + let status = unsafe { SecCodeCopyStaticCode(code, 0, &mut static_code) }; + release(code.cast_const().cast()); + if status != 0 || static_code.is_null() { + return None; + } + let mut raw_information: CFDictionaryRef = ptr::null(); + // SAFETY: `static_code` is the reference just handed over. + let status = unsafe { SecCodeCopySigningInformation(static_code, SIGNING_INFORMATION, &mut raw_information) }; + release(static_code.cast_const().cast()); + if status != 0 || raw_information.is_null() { + return None; + } + // SAFETY: a Copy call hands over one reference. + let information: CFDictionary = unsafe { CFDictionary::wrap_under_create_rule(raw_information) }; + // SAFETY: an immutable constant the framework exports. + let key = unsafe { CFString::wrap_under_get_rule(kSecCodeInfoTeamIdentifier) }; + information.find(&key).and_then(|team| team.downcast::()).map(|team| team.to_string()) +} diff --git a/crates/torromail-tui/Cargo.toml b/crates/torromail-tui/Cargo.toml index dce36fa..72bf413 100644 --- a/crates/torromail-tui/Cargo.toml +++ b/crates/torromail-tui/Cargo.toml @@ -21,3 +21,9 @@ torromail-mcp = { path = "../torromail-mcp" } chrono = { version = "0.4", default-features = false, features = ["clock"] } ratatui = "0.30" serde_json = "1" + +# The keychain's dialogs are switched off for the whole run, like the server +# does: nobody may be there to answer one, and a background check certainly +# is not. +[target.'cfg(target_os = "macos")'.dependencies] +torromail-keychain = { path = "../torromail-keychain" } diff --git a/crates/torromail-tui/src/autocheck.rs b/crates/torromail-tui/src/autocheck.rs index 9832221..4cbbcd9 100644 --- a/crates/torromail-tui/src/autocheck.rs +++ b/crates/torromail-tui/src/autocheck.rs @@ -1,20 +1,31 @@ -//! Checking the accounts while nobody is looking. The macOS app does this -//! from its own background process; here a systemd user timer runs -//! `torromail check` every quarter of an hour — no daemon of ours to keep -//! alive, and nothing at all when the user turned it off. +//! Checking the accounts while nobody is looking. The system's own scheduler +//! runs `torromail check` every quarter of an hour — a systemd user timer on +//! Linux, a launchd agent on macOS — so there is no daemon of ours to keep +//! alive, and nothing at all when the user turned it off. (The macOS app has a +//! background monitor of its own; both append to the same health log, so +//! running the two side by side only checks more often.) use std::path::{Path, PathBuf}; pub const SERVICE: &str = "torromail-check.service"; pub const TIMER: &str = "torromail-check.timer"; -/// Runs `systemctl --user `. Injected so the unit files can be -/// tested without a systemd to talk to. +/// The launchd job on macOS, and the file it is described in. +pub const LAUNCH_AGENT: &str = "com.torromail.check"; +pub const LAUNCH_AGENT_FILE: &str = "com.torromail.check.plist"; + +/// Runs the scheduler's command — `systemctl --user ` on Linux, +/// `launchctl ` on macOS. Injected so the job files can be tested +/// without a scheduler to talk to. pub type Systemctl<'a> = &'a dyn Fn(&[&str]) -> Result<(), String>; +/// Where the job files live: `~/Library/LaunchAgents` on macOS, elsewhere /// `$XDG_CONFIG_HOME/systemd/user`, falling back to `~/.config/systemd/user`. #[must_use] pub fn unit_directory(home: &Path, xdg_config_home: Option<&Path>) -> PathBuf { + if cfg!(target_os = "macos") { + return home.join("Library/LaunchAgents"); + } xdg_config_home .filter(|path| path.is_absolute()) .map_or_else(|| home.join(".config"), Path::to_path_buf) @@ -23,7 +34,7 @@ pub fn unit_directory(home: &Path, xdg_config_home: Option<&Path>) -> PathBuf { #[must_use] pub fn is_enabled(unit_directory: &Path) -> bool { - unit_directory.join(TIMER).exists() + unit_directory.join(if cfg!(target_os = "macos") { LAUNCH_AGENT_FILE } else { TIMER }).exists() } fn service_unit(program: &Path) -> String { @@ -70,6 +81,81 @@ pub fn disable(unit_directory: &Path, systemctl: Systemctl<'_>) -> Result<(), St Ok(()) } +/// The launchd agent: `torromail check` at login, then every fifteen minutes +/// — the same cadence as the timer. `program` goes in by absolute path. +fn launch_agent(program: &Path) -> String { + let program = program.display().to_string().replace('&', "&").replace('<', "<").replace('>', ">"); + format!( + "\n\ + \n\ + \n\n\ + \tLabel\n\t{LAUNCH_AGENT}\n\ + \tProgramArguments\n\t\n\t\t{program}\n\t\tcheck\n\t\n\ + \tRunAtLoad\n\t\n\ + \tStartInterval\n\t900\n\ + \tProcessType\n\tBackground\n\ + \n\n" + ) +} + +/// The launchd domain of whoever owns the agent directory — the user, since +/// it is in their home. Read from the file system because the process has no +/// safe way to ask for its own uid. +#[cfg(unix)] +fn gui_domain(launch_agents: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + let uid = std::fs::metadata(launch_agents).map_err(|error| error.to_string())?.uid(); + Ok(format!("gui/{uid}")) +} + +#[cfg(not(unix))] +fn gui_domain(_launch_agents: &Path) -> Result { + Err("launchd exists only on macOS".to_owned()) +} + +/// Writes the launchd agent and loads it. A stale one from an earlier +/// install is unloaded first, so the new path is the one that runs. +pub fn enable_launch_agent(launch_agents: &Path, program: &Path, launchctl: Systemctl<'_>) -> Result<(), String> { + std::fs::create_dir_all(launch_agents).map_err(|error| error.to_string())?; + let file = launch_agents.join(LAUNCH_AGENT_FILE); + std::fs::write(&file, launch_agent(program)).map_err(|error| error.to_string())?; + let domain = gui_domain(launch_agents)?; + let _ = launchctl(&["bootout", &format!("{domain}/{LAUNCH_AGENT}")]); + let started = launchctl(&["bootstrap", &domain, &file.to_string_lossy()]); + if started.is_err() { + // An agent that did not load must not look enabled next time. + let _ = std::fs::remove_file(&file); + } + started +} + +pub fn disable_launch_agent(launch_agents: &Path, launchctl: Systemctl<'_>) -> Result<(), String> { + // Unloading fails when it was never loaded; the file going is what makes + // it off. + if let Ok(domain) = gui_domain(launch_agents) { + let _ = launchctl(&["bootout", &format!("{domain}/{LAUNCH_AGENT}")]); + } + let file = launch_agents.join(LAUNCH_AGENT_FILE); + if file.exists() { + std::fs::remove_file(&file).map_err(|error| error.to_string())?; + } + Ok(()) +} + +pub fn run_launchctl(arguments: &[&str]) -> Result<(), String> { + let output = std::process::Command::new("launchctl") + .args(arguments) + .stdin(std::process::Stdio::null()) + .output() + .map_err(|error| format!("launchctl could not be started: {error}"))?; + if output.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&output.stderr).trim().to_owned()) } +} + +/// The scheduler this platform has. +pub fn run_scheduler(arguments: &[&str]) -> Result<(), String> { + if cfg!(target_os = "macos") { run_launchctl(arguments) } else { run_systemctl(arguments) } +} + pub fn run_systemctl(arguments: &[&str]) -> Result<(), String> { let output = std::process::Command::new("systemctl") .arg("--user") diff --git a/crates/torromail-tui/src/check.rs b/crates/torromail-tui/src/check.rs index 702ee4a..12af60f 100644 --- a/crates/torromail-tui/src/check.rs +++ b/crates/torromail-tui/src/check.rs @@ -54,8 +54,21 @@ pub fn run(backend: &Backend, lang: Lang, notify: Option>) -> Summary summary } -/// `notify-send`, which every desktop with a notification daemon has. +/// A desktop notification: Notification Center on macOS, `notify-send` +/// (which every desktop with a notification daemon has) elsewhere. pub fn notify_send(title: &str, body: &str) { + if cfg!(target_os = "macos") { + // Title and body travel as arguments, never inside the script, so + // nothing an account is called can become AppleScript. + let _ = std::process::Command::new("osascript") + .args(["-e", "on run argv", "-e", "display notification (item 2 of argv) with title (item 1 of argv)"]) + .args(["-e", "end run", title, body]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + return; + } let _ = std::process::Command::new("notify-send") .args(["--app-name", "TorroMail", "--icon", "mail-unread", title, body]) .stdin(std::process::Stdio::null()) diff --git a/crates/torromail-tui/src/data.rs b/crates/torromail-tui/src/data.rs index 194e39c..a0d8d5c 100644 --- a/crates/torromail-tui/src/data.rs +++ b/crates/torromail-tui/src/data.rs @@ -233,7 +233,8 @@ pub struct Backend { /// Asked once: launching the server for every two-second reload would be /// a process per frame for an answer that does not change. pub tool_count: std::cell::OnceCell>, - /// Where systemd user units live, and how `systemctl --user` is run. + /// Where the background check's job files live, and how the scheduler + /// (`systemctl --user`, `launchctl`) is run. pub unit_directory: PathBuf, pub systemctl: Box, /// Asks for the newest release tag. The real one asks GitHub. @@ -367,20 +368,24 @@ impl Backend { Ok(target) } - /// Turns the systemd timer behind the background check on or off. + /// Turns the scheduler job behind the background check on or off: a + /// systemd timer on Linux, a launchd agent on macOS. pub fn set_autocheck(&self, on: bool) -> Result<(), String> { - // A systemd user timer is what runs it. Elsewhere the accounts are - // still checked whenever an assistant starts the server. - if !cfg!(target_os = "linux") { - return Err(if cfg!(target_os = "macos") { - "on a Mac the TorroMail app checks the accounts in the background".to_owned() - } else { - "not available on this system yet — accounts are still checked whenever an assistant starts".to_owned() - }); + // Elsewhere the accounts are still checked whenever an assistant + // starts the server. + if !cfg!(any(target_os = "linux", target_os = "macos")) { + return Err("not available on this system yet — accounts are still checked whenever an assistant starts".to_owned()); } + let macos = cfg!(target_os = "macos"); if on { let program = std::env::current_exe().map_err(|error| error.to_string())?; - crate::autocheck::enable(&self.unit_directory, &program, self.systemctl.as_ref()) + if macos { + crate::autocheck::enable_launch_agent(&self.unit_directory, &program, self.systemctl.as_ref()) + } else { + crate::autocheck::enable(&self.unit_directory, &program, self.systemctl.as_ref()) + } + } else if macos { + crate::autocheck::disable_launch_agent(&self.unit_directory, self.systemctl.as_ref()) } else { crate::autocheck::disable(&self.unit_directory, self.systemctl.as_ref()) } diff --git a/crates/torromail-tui/src/main.rs b/crates/torromail-tui/src/main.rs index ee8c203..4e364d3 100644 --- a/crates/torromail-tui/src/main.rs +++ b/crates/torromail-tui/src/main.rs @@ -15,6 +15,10 @@ fn main() -> std::io::Result<()> { println!("torromail {}", ui::VERSION); return Ok(()); } + // A secret that would need a dialog to read fails instead, and the surface + // says so. The lock re-enables the dialogs on drop, so it lives for the run. + #[cfg(target_os = "macos")] + let _keychain_ui = torromail_keychain::silence_prompts(); // No home means no place the files could be. Inventing one would show an // empty, healthy-looking surface over a broken setup. let Some(directory) = paths::default_data_directory() else { @@ -34,7 +38,7 @@ fn main() -> std::io::Result<()> { rebuild: std::cell::RefCell::new(None), tool_count: std::cell::OnceCell::new(), unit_directory: torromail_tui::autocheck::unit_directory(&home, xdg_config.as_deref()), - systemctl: Box::new(torromail_tui::autocheck::run_systemctl), + systemctl: Box::new(torromail_tui::autocheck::run_scheduler), release_lookup: Box::new(|| torromail_discovery::latest_release_tag("mahype/TorroMail")), // Downloads when there is one — where people look for a file they // just asked for — otherwise the home directory. diff --git a/crates/torromail-tui/tests/render.rs b/crates/torromail-tui/tests/render.rs index a2345dd..f668202 100644 --- a/crates/torromail-tui/tests/render.rs +++ b/crates/torromail-tui/tests/render.rs @@ -1013,6 +1013,40 @@ fn the_background_check_installs_a_timer_and_removes_it_again() { assert_shows(&render(&app), &["[ ] Konten alle 15 Minuten prüfen", "Die Prüfung im Hintergrund ist aus."]); } +#[cfg(target_os = "macos")] +#[test] +fn the_background_check_installs_a_launch_agent_and_removes_it_again() { + use std::sync::{Arc, Mutex}; + let mut scene = scene("settings-autocheck-launchd"); + let calls: Arc>> = Arc::default(); + let recorded = Arc::clone(&calls); + scene.backend.systemctl = Box::new(move |arguments| { + recorded.lock().expect("not poisoned").push(arguments.join(" ")); + Ok(()) + }); + let mut app = App::new(Lang::De, scene.backend.load()); + press(&mut app, KeyCode::Char('4')); + press(&mut app, KeyCode::Down); + press(&mut app, KeyCode::Char(' ')); + scene.act(&mut app); + + let agents = scene.root.join("units"); + let agent = std::fs::read_to_string(agents.join("com.torromail.check.plist")).expect("a launch agent"); + assert!(agent.contains("com.torromail.check")); + assert!(agent.contains("/") && agent.contains("check"), "this program by absolute path: {agent}"); + assert!(agent.contains("900"), "every fifteen minutes: {agent}"); + let calls = calls.lock().expect("not poisoned").clone(); + assert_eq!(calls.len(), 2, "{calls:?}"); + assert!(calls[0].starts_with("bootout gui/") && calls[0].ends_with("/com.torromail.check"), "{calls:?}"); + assert!(calls[1].starts_with("bootstrap gui/") && calls[1].ends_with("com.torromail.check.plist"), "{calls:?}"); + assert_shows(&render(&app), &["[✓] Konten alle 15 Minuten prüfen", "alle 15 Minuten geprüft"]); + + press(&mut app, KeyCode::Char(' ')); + scene.act(&mut app); + assert!(!agents.join("com.torromail.check.plist").exists()); + assert_shows(&render(&app), &["[ ] Konten alle 15 Minuten prüfen", "Die Prüfung im Hintergrund ist aus."]); +} + #[cfg(target_os = "linux")] #[test] fn a_timer_that_would_not_start_does_not_look_enabled() { diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 71cb300..a991a3b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,7 +2,8 @@ This document is for maintainers. It describes how TorroMail is versioned, built, signed, notarized, and published: the Mac app as a downloadable `.dmg`, -the Linux programs as tarballs and as the `torromail-bin` AUR package — all in +the terminal programs for macOS (a signed, notarized universal tarball), Linux +(tarballs and the `torromail-bin` AUR package) and Windows (a zip) — all in one GitHub Release per version. ## TL;DR @@ -58,6 +59,14 @@ halves side by side. 5. Mounts the DMG and smoke-tests codesign / Gatekeeper / stapled ticket. 6. Signs the DMG with TorroMail's Sparkle Ed25519 key and creates `appcast.xml` with the generated release notes. +7. Builds the terminal programs `torromail` and `torromail-mcp` universal, + signs them with the same Developer ID under the hardened runtime, has them + notarized and packs `torromail--universal-macos.tar.gz` + ([scripts/build-cli-macos.sh](../scripts/build-cli-macos.sh)). The shared + Team ID is what lets them and the app read one another's keychain items; + the script refuses a signature without one. A bare executable cannot carry + a stapled ticket, so Gatekeeper checks the notarization online on first + start. **Linux** ([build-linux.yml](../.github/workflows/build-linux.yml)): diff --git a/scripts/build-cli-macos.sh b/scripts/build-cli-macos.sh new file mode 100755 index 0000000..ce89242 --- /dev/null +++ b/scripts/build-cli-macos.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Builds the macOS terminal programs for a release: +# +# torromail the terminal control surface +# torromail-mcp the MCP server assistants spawn +# +# Both universal (arm64 + x86_64), signed with the Developer ID certificate +# under the hardened runtime, notarized, and packed side by side into +# dist/torromail--universal-macos.tar.gz with a .sha256 beside it. +# +# The signature is not decoration here. Keychain items are shared by Team ID: +# the app, its bundled server and these two programs read one another's +# secrets without a dialog only because all of them carry the same team. An +# unsigned build of this could not read a single password the app stored. +# +# A bare executable cannot carry a stapled ticket, so Gatekeeper looks the +# notarization up online on first start; the submission below only has to be +# accepted. +# +# Required environment: +# VERSION e.g. 0.11.0 or 0.11.0-rc.1 (goes into the file name) +# MACOS_SIGN_IDENTITY "Developer ID Application: … (TEAMID)" +# APPLE_ID, APPLE_TEAM_ID, APPLE_APP_SPECIFIC_PASSWORD for notarization +# +# NOTARIZE=0 skips notarization (a local dry run with a development identity). + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +: "${VERSION:?VERSION must be set}" +: "${MACOS_SIGN_IDENTITY:?MACOS_SIGN_IDENTITY must be set}" +notarize="${NOTARIZE:-1}" +if [[ "$notarize" != "0" ]]; then + : "${APPLE_ID:?APPLE_ID must be set}" + : "${APPLE_TEAM_ID:?APPLE_TEAM_ID must be set}" + : "${APPLE_APP_SPECIFIC_PASSWORD:?APPLE_APP_SPECIFIC_PASSWORD must be set}" +fi + +name="torromail-${VERSION}-universal-macos" +stage="dist/$name" +programs=(torromail torromail-mcp) + +for target in aarch64-apple-darwin x86_64-apple-darwin; do + echo "==> Building for $target" + cargo build --release --locked --target "$target" -p torromail-mcp -p torromail-tui +done + +rm -rf "$stage" "dist/$name.tar.gz" "dist/$name.tar.gz.sha256" +mkdir -p "$stage" +for program in "${programs[@]}"; do + lipo -create \ + "target/aarch64-apple-darwin/release/$program" \ + "target/x86_64-apple-darwin/release/$program" \ + -output "$stage/$program" + lipo -info "$stage/$program" +done + +echo "==> Signing with hardened runtime" +for program in "${programs[@]}"; do + codesign --force --timestamp --options=runtime --sign "$MACOS_SIGN_IDENTITY" "$stage/$program" + codesign --verify --strict --verbose=2 "$stage/$program" +done +team="$(codesign -dv "$stage/torromail" 2>&1 | sed -n 's/^TeamIdentifier=//p')" +if [[ -z "$team" || "$team" == "not set" ]]; then + echo "error: the signature carries no Team ID — the keychain could not share secrets with the app" >&2 + exit 1 +fi +echo "Team ID: $team" + +echo "==> Smoke test" +"$stage/torromail" --version +"$stage/torromail-mcp" --list-tools > /dev/null + +if [[ "$notarize" != "0" ]]; then + echo "==> Submitting to Apple's notary service" + submission="dist/$name-notarize.zip" + rm -f "$submission" + /usr/bin/ditto -c -k --keepParent "$stage" "$submission" + result="$(xcrun notarytool submit "$submission" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --wait --timeout 30m --output-format json)" + rm -f "$submission" + echo "$result" + status="$(printf '%s' "$result" | /usr/bin/python3 -c 'import json, sys; print(json.load(sys.stdin).get("status", ""))')" + if [[ "$status" != "Accepted" ]]; then + echo "error: notarization ended with status '$status'" >&2 + exit 1 + fi +fi + +cp README.md LICENSE-MIT LICENSE-APACHE "$stage/" +tar -C dist -czf "dist/$name.tar.gz" "$name" +(cd dist && shasum -a 256 "$name.tar.gz" > "$name.tar.gz.sha256") +rm -rf "$stage" +echo "==> Done: dist/$name.tar.gz" diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh new file mode 100755 index 0000000..47a45a5 --- /dev/null +++ b/scripts/install-macos.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Builds the TorroMail terminal programs on a Mac and installs them for the +# current user: +# +# torromail the terminal control surface +# torromail-mcp the MCP server assistants spawn +# +# Both land in $PREFIX/bin (default ~/.local), side by side — the surface finds +# the server next to itself, and writes that absolute path into an assistant's +# configuration when you connect one. No root needed. +# +# scripts/install-macos.sh build and install +# scripts/install-macos.sh --uninstall remove the two programs again +# PREFIX=/usr/local scripts/install-macos.sh +# +# The copies are signed with your Apple Development or Developer ID identity +# (or CODESIGN_IDENTITY). That is what lets them share keychain items with the +# TorroMail app without a dialog: the items are scoped to the signing team. +# Unsigned, they could read none of the app's passwords. +# +# Your accounts, the policy document and the logs live in +# ~/Library/Application Support/TorroMail and are never touched here. +set -euo pipefail + +prefix="${PREFIX:-$HOME/.local}" +bin="$prefix/bin" +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ "${1:-}" == "--uninstall" ]]; then + rm -f "$bin/torromail" "$bin/torromail-mcp" + echo "Removed torromail and torromail-mcp from $bin." + echo "Your data in ~/Library/Application Support/TorroMail was left alone." + echo "Assistants you connected from the terminal still name the removed server" + echo "in their configuration — reconnect them from the TorroMail app, or remove" + echo "the \"torromail\" entry from their MCP settings by hand." + exit 0 +fi + +if ! command -v cargo >/dev/null; then + echo "cargo was not found. Install Rust first: https://rustup.rs" >&2 + exit 1 +fi + +identity="${CODESIGN_IDENTITY:-$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F'"' '/Developer ID Application|Apple Development/ { print $2; exit }')}" + +echo "Building (release)…" +cargo build --release --manifest-path "$root/Cargo.toml" -p torromail-mcp -p torromail-tui + +mkdir -p "$bin" +for program in torromail torromail-mcp; do + install -m755 "$root/target/release/$program" "$bin/$program" + if [[ -n "$identity" ]]; then + codesign --force --sign "$identity" "$bin/$program" + fi +done +if [[ -n "$identity" ]]; then + echo "Signed: $identity" +else + echo "warning: no codesigning identity found, so the programs are unsigned." >&2 + echo " They cannot read passwords the TorroMail app stored, and the app" >&2 + echo " cannot read theirs. Set CODESIGN_IDENTITY, or use a release build." >&2 +fi + +echo +echo "Installed $("$bin/torromail" --version) to $bin." +case ":$PATH:" in + *":$bin:"*) echo "Start it with: torromail" ;; + *) echo "$bin is not on your PATH — start it with: $bin/torromail" ;; +esac From 1694b645a3d7455f05af898ccd9b1efeb0164f66 Mon Sep 17 00:00:00 2001 From: Sven Wagener Date: Mon, 28 Sep 2026 22:04:21 +0200 Subject: [PATCH 2/2] fix(build): sync keychain lockfile version --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 5d7af36..5648c9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1770,7 +1770,7 @@ dependencies = [ [[package]] name = "torromail-keychain" -version = "0.10.3" +version = "0.10.4" dependencies = [ "core-foundation", "security-framework",