37 Commits
Author SHA1 Message Date
雷电芽衣 5843fb4b1f 0.1.9
release / macos (push) Canceled after 0s
release / windows (push) Canceled after 0s
release / linux (push) Canceled after 0s
2026-10-20 03:14:58 -04:00
雷电芽衣 ff317dd863 R9.5: merge PR #6, ElyTable completion keys and overlay clicks
Enter and Tab are resolved by the worker against the kernel's current
candidates, after the typing queued before them. The dropdown arrow's
mouse blocker is at least as tall as the button.
2026-10-20 03:14:24 -04:00
雷电芽衣 72bec20a60 R9.3, R9.4: AutoFilter without the header query (GitHub #4); paste on Windows
Patch 0053: under LOK, Calc takes the first row as header instead of
asking asynchronously; the worker closed the query and crashed.
Patch 0049 includes doc.hxx (gcc on Linux did not build it).
arboard read a missing HTML clipboard format as an error on Windows;
the clipboard checks the format first, and paste errors show the
whole chain. ElyTable tests compare CSV and selection text the
platform's way (P3-09).
2026-10-20 02:23:17 -04:00
雷电芽衣 ba358c7de6 C12.9: three-pane mailbox view, filters, virtualized list; ids never reused 2026-10-20 00:25:57 -04:00
Jetkwok c5cb29fa34 Guard completion callbacks and cancel occluded grid drags 2026-10-20 11:42:13 +08:00
Jetkwok cc0f5a9caf Fix ElyTable completion keys and overlay click-through 2026-10-20 11:18:13 +08:00
雷电芽衣 7f70ab6df7 R9.1: ElyShow master view reads each master's own part info (GitHub #2) 2026-10-19 23:05:29 -04:00
雷电芽衣 6c972163b1 Merge branch 'main' of https://github.com/ELY-Open-Source/ElyOffice 2026-10-19 22:55:13 -04:00
雷电芽衣 bddf765f77 Merge pull request #1 from OmarGonD/fix/readable-text-selection
[BUG REPORT] Keep selected document text readable
2026-10-19 22:54:48 -04:00
雷电芽衣 22d913d45e C12.8: sync now, timed sync, pause, offline folders 2026-10-19 22:29:27 -04:00
雷电芽衣 4d0d64ff54 C12.7: POP3 fetch by UIDL 2026-10-19 21:40:25 -04:00
OmarGonD e42f580510 Keep selected document text readable 2026-10-19 20:32:44 -05:00
雷电芽衣 d4c59706d8 Issue templates: [FEATURE REQUEST], [BUG REPORT] and [Other] 2026-10-19 21:21:27 -04:00
雷电芽衣 890bd80d4d C12.6: IMAP sync 2026-10-19 21:21:27 -04:00
雷电芽衣 5c5ebf5e93 PR template: titles start with [FEATURE REQUEST], [BUG REPORT] or [Other] 2026-10-19 21:18:20 -04:00
雷电芽衣 1c225f6812 C12.5: mail accounts, add and remove
ElyMail's window lists accounts and adds Gmail, Microsoft, iCloud
(app-specific password), IMAP and POP3 ones; a check logs in to both
servers and reports each. Accounts live in the store as JSON with a
case-insensitive unique address; secrets go to the keychain. Removal
names the cache it deletes, takes the secret first and puts it back
when local removal fails. Originals are set aside until commit and
restored after a crash. Requests are numbered so late results stay
with their own dialog. The account list is keyboard-navigable.
2026-10-19 20:17:19 -04:00
ZacharyZhang-NY 5068bc07ca Merge branch 'main' of https://github.com/ELY-Open-Source/ElyOffice
# Conflicts:
#	agents.md
#	scripts/fetch-references.sh
2026-10-19 19:53:11 -04:00
ZacharyZhang-NYandClaude 0e0415fdbe Site: the download count adds Gitea's history to GitHub's
Gitea holds the downloads made before the move, so the worker sums
both hosts' asset download counts (update manifests excluded). Gitea is
read with the same five-minute cache and a five-second limit; when it
is down or answers oddly, the count is GitHub's alone. Downloads stay
on GitHub only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:37:59 -04:00
ZacharyZhang-NYandClaude 271468d0e5 Relicense Ely Office's own code from AGPL-3.0 to MIT
LICENSE is the MIT text, copyright Yang Zhang; the workspace crates,
the docs and the site say MIT. Bundled engines keep their licences:
LibreOffice (MPL-2.0), Git (GPL-2.0) and MuPDF (AGPL-3.0), which ElyPDF
links, so the Linux packages name both, e.g. "MIT AND AGPL-3.0-or-later".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:31:09 -04:00
ZacharyZhang-NYandClaude 1d74bbc417 Updates and the component library come from GitHub
The update check reads the latest release from GitHub's API; its JSON
has the same tag_name and assets[].browser_download_url the reader
expects. Ely-GPUI-Components is fetched from ELY-Open-Source at the same
pinned commits.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:22:39 -04:00
ZacharyZhang-NYandClaude a06c9b299d Docs: the code lives on GitHub under ELY-Open-Source
CONTRIBUTING opens pull requests from a GitHub fork; the Linux
packages' homepage is the GitHub repository; agents.md records the move.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:22:12 -04:00
ZacharyZhang-NYandClaude 6b28c54e33 Site: releases and source on GitHub; the Mac build is notarized
The worker reads the newest release and download counts from GitHub's
API, with an optional GITHUB_TOKEN secret for the rate limit. Download
links go to GitHub's latest release, and the source link wears the
GitHub mark. The "not notarized" notice goes: macOS builds are signed
and notarized now.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:21:30 -04:00
ZacharyZhang-NYandClaude 41321fd05f CI: release from GitHub Actions to GitHub releases
The release workflow moves from .gitea to .github: macOS on the
macos-signing runner, Windows and Linux (WSL) on ely-windows. The
persistent clones fetch from GitHub. ci.sh creates or reuses the tag's
release through GitHub's API and replaces a rerun's assets of the same
name; the update manifests point at GitHub's download URLs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:19:04 -04:00
ZacharyZhang-NYandClaude bd68382921 C12.4: mail credentials and OAuth sign-in
Passwords and refresh tokens go only to the system keychain; a refused
keychain keeps them in memory until exit and says why. Gmail and
Microsoft sign in by authorization code with S256 PKCE on a loopback
redirect; refresh tokens are kept, and revoked grants ask to sign in
again. Client IDs come from the build (env or oauth.env). ureq trace
records never reach the log.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 18:59:56 -04:00
ZacharyZhang-NYandClaude Opus 5.5 7c5bf48589 C12.3: ElyMail local store
SQLite index, content-addressed originals, rebuildable FTS5 search,
schema versions with a backup before each upgrade and read-only on
failure, and size reporting. Three review rounds; the round 3 fixes
are checked by tests, not reviewed again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 18:14:09 -04:00
ZacharyZhang-NYandClaude Opus 5.5 fe90221c11 C12.2: read and write MIME by the tree
The reader walks the MIME tree with its container context: alternative
picks versions, related finds its root by start, attached multiparts
stay whole, digest children are messages. Nesting deeper than 64
levels, counting enclosed messages, is refused, and over-deep parse
results are taken apart iteratively so dropping them cannot overflow
the stack. Composite parts with a Content-ID are cid targets.

The writer keeps attachment parameters and Content-IDs, writes
multipart and message attachments unencoded, and makes its own
Message-ID. SMTP adds BODY=8BITMIME for 8-bit bodies and SMTPUTF8
when any header block at any depth is 8-bit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 17:35:05 -04:00
ZacharyZhang-NYandClaude Opus 5.5 9f47b94714 C12.1: ely-mail-engine connects over IMAP, POP3 and SMTP
TLS or STARTTLS with the system trust store; password and XOAUTH2
logins keep the server's words on refusal. IMAP and POP3 are written
in the engine (imap-proto parses IMAP replies): async-imap dropped
NO/BAD on LIST and FETCH and lost BYE text, async-pop kept dot
stuffing and logged passwords. SMTP runs its own transaction with
RFC 5321 mailbox checks, full dot transparency, SMTPUTF8 and RSET.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:53:53 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d7de5619a7 C12.0: ElyMail backend moves to Rust crates; iCloud by app password
async-imap, async-pop, mail-send, mail-parser and mail-builder replace
MailCore 2. The MailCore 2 and libetpan fetch, build and probe scripts
go. HTML bodies render as a sanitized block subset in GPUI; compose uses
the Markdown document component. OAuth client IDs come from oauth.env
(ignored) or CI secrets. The task list for C12 is rewritten in order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:02:34 -04:00
ZacharyZhang-NYandClaude 0d991e1c71 R8.5, R8.6: 0.1.8 released; #8 and #9 closed
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 02:06:20 -04:00
ZacharyZhang-NYandClaude 0dfb4bb720 CI: macOS job runs on the GUI-session signing runner
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 02:05:55 -04:00
ZacharyZhang-NY c8d7e6f3cc Merge remote-tracking branch 'origin/main' 2026-10-19 00:27:17 -04:00
ZacharyZhang-NYandClaude 2754b2e3dc 0.1.8
release / windows (push) Failing after 9m38s
release / linux (push) Skipped
release / macos (push) Failing after 21m8s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 00:27:04 -04:00
ZacharyZhang-NYandClaude 4ef7c96916 R8.4: ElyMark typesets inline formulas; \( and \[ read as math
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 00:26:23 -04:00
ZacharyZhang-NY 05b205f629 Update README.md 2026-10-19 04:20:14 +00:00
ZacharyZhang-NYandClaude 1e6147ae77 R8.3: sheet tab strip scrolls the chosen sheet in (Gitea #8)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-18 22:41:28 -04:00
ZacharyZhang-NYandClaude 2ca53ef585 R8.2: Word EQ fields open as formulas; inline objects follow their run
Gitea #9. Patch 0049 moves inline pictures and OLE objects with
their run's w:position. Patches 0050 and 0051 turn EQ fields into
Math objects; unreadable fields show their code. Round 3 findings
(lowercase \s\do tail, split subscripts, \ai/\di, italics through
OMML) fixed and covered by word.rs; no fourth review.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-18 21:39:17 -04:00
ZacharyZhang-NYandClaude 6f1eebb4b5 R8.1: document content keeps the file's colours in a dark app
Pages, cells, slides and PDF pages paint as the file says in any
appearance; only the chrome around them follows dark mode. The
kernel keeps Ely's one light scheme, sent once a view, and MuPDF
no longer inverts. Reviews now run gpt-6.1-sol at xhigh.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-18 19:42:13 -04:00
231 changed files with 17274 additions and 556 deletions

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
name: Bug Report
description: Something behaves, draws or builds wrong.
title: "[BUG REPORT] "
labels: [bug]
body:
- type: dropdown
id: subject
attributes:
label: App
options:
- ElyOffice
- ElyDoc
- ElyTable
- ElyShow
- ElyPDF
- ElyMark
- ElyTree
validations:
required: true
- type: dropdown
id: where
attributes:
label: Where
options:
- macOS
- Windows
- Linux
validations:
required: true
- type: textarea
id: what
attributes:
label: What happened
description: A screenshot or a short recording helps most.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1.
2.
3.
validations:
required: true
- type: textarea
id: expected
attributes:
label: What you expected
validations:
required: true
- type: input
id: version
attributes:
label: "Version"
placeholder: "0.1.8"
- type: textarea
id: logs
attributes:
label: Logs
description: Terminal output or error messages.
render: shell
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Ely Office
url: https://office.elygpui.com
about: "Downloads and what each app does."
@@ -0,0 +1,42 @@
name: Feature Request
description: Something new, or something that should do more.
title: "[FEATURE REQUEST] "
labels: [enhancement]
body:
- type: dropdown
id: subject
attributes:
label: App
options:
- ElyOffice
- ElyDoc
- ElyTable
- ElyShow
- ElyPDF
- ElyMark
- ElyTree
validations:
required: false
- type: textarea
id: use
attributes:
label: What it is for
description: The problem, and the moment you need it in.
validations:
required: true
- type: textarea
id: behavior
attributes:
label: How it should work
- type: textarea
id: references
attributes:
label: References
description: Links or screenshots of it elsewhere.
- type: checkboxes
id: searched
attributes:
label: Checked
options:
- label: I searched the open issues for the same request.
required: true
+11
View File
@@ -0,0 +1,11 @@
name: Other
description: Questions, docs, ideas, anything else.
title: "[Other] "
labels: [question]
body:
- type: textarea
id: what
attributes:
label: What's on your mind
validations:
required: true
+35
View File
@@ -0,0 +1,35 @@
<!--
Title: start with the type, for example
[FEATURE REQUEST] Add dark mode to the settings page
[BUG REPORT] Fix a crash when opening an empty file
[Other] Update the README install steps
-->
## Type
- [ ] **[FEATURE REQUEST]**: a new feature or an improvement
- [ ] **[BUG REPORT]**: a bug fix
- [ ] **[Other]**: docs, refactoring, CI, dependencies or chores
## What and why
<!-- One or two sentences. Link the issue: Closes #123 -->
## Bug details
<!-- [BUG REPORT] only; delete this section otherwise. -->
- Steps to reproduce:
- Expected:
- Actual:
- Cause and fix:
## How it was tested
<!-- Commands you ran, and what you checked by hand. -->
## Checklist
- [ ] The title starts with `[FEATURE REQUEST]`, `[BUG REPORT]` or `[Other]`
- [ ] Tests and the build pass locally
- [ ] Docs are updated if behaviour changed
@@ -1,17 +1,21 @@
# A tag builds, packages and uploads all three platforms.
# Tags only: no pull request ever reaches the self-hosted runners.
name: release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
macos:
runs-on: macos-arm64
# A GUI-session agent: daemons see no login keychain.
runs-on: [self-hosted, macos-signing]
timeout-minutes: 720
steps:
- name: Build, package and upload for macOS
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The dev clone's engines: LibreOffice from scratch outlasts the runner.
ELY_SHARE: /Users/zacharyzhang/Documents/GitHub/ElyOfficeSuite
ELY_PATH: /Users/zacharyzhang/.cargo/bin:/opt/homebrew/bin
@@ -20,42 +24,45 @@ jobs:
ELY_NOTARY_PROFILE: ${{ vars.ELY_NOTARY_PROFILE }}
run: |
dir=$HOME/ely-ci/ElyOffice
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
git -C "$dir" fetch --force --tags origin
git -C "$dir" checkout --force "${{ github.sha }}"
"$dir/scripts/ci.sh" macos "${{ github.ref_name }}"
windows:
runs-on: windows
runs-on: [self-hosted, ely-windows]
timeout-minutes: 360
steps:
- name: Build, package and upload for Windows
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ELY_SHARE: /c/Users/zhang/Documents/Github/ElyOffice
ELY_PATH: /c/Program Files/CMake/bin:/c/Users/zhang/AppData/Local/Microsoft/WinGet/Packages/Ninja-build.Ninja_Microsoft.Winget.Source_8wekyb3d8bbwe
run: |
dir=$HOME/ely-ci/ElyOffice
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
git -C "$dir" fetch --force --tags origin
git -C "$dir" checkout --force "${{ github.sha }}"
"$dir/scripts/ci.sh" windows "${{ github.ref_name }}"
linux:
# WSL on the Windows runner keeps a built kernel.
runs-on: windows
runs-on: [self-hosted, ely-windows]
needs: windows
timeout-minutes: 360
steps:
- name: Build, package and upload for Linux
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WSLENV: ELY_RELEASE_TOKEN
run: |
wsl -d Ubuntu --exec bash -lc '
set -euo pipefail
dir=$HOME/ely-ci/ElyOffice
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
git -C "$dir" fetch --force --tags origin
git -C "$dir" checkout --force "${{ github.sha }}"
ELY_SHARE=$HOME/Documents/Github/ElyOffice "$dir/scripts/ci.sh" linux "${{ github.ref_name }}"
+2 -1
View File
@@ -18,4 +18,5 @@ node_modules/
/assets/papirus_icons-master.zip
/Gitea.md
/.work/
/TyporaRef
/TyporaRef
/oauth.env
+5 -8
View File
@@ -1,15 +1,12 @@
# Contributing
## Opening a pull request on git.zacharyzhang.com
## Opening a pull request on GitHub
Forking is disabled on this server. Open pull requests with AGit instead:
1. Register an account at https://git.zacharyzhang.com
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
1. Fork https://github.com/ELY-Open-Source/ElyOffice and clone your fork:
```bash
git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
git clone https://github.com/<your-account>/ElyOffice.git
```
3. To update the pull request, push again with the same `topic`.
2. Commit your changes on a branch and push it to your fork.
3. Open a pull request against `main`. To update it, push to the same branch.
Generated
+487 -19
View File
@@ -410,6 +410,45 @@ dependencies = [
"zbus",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 2.0.21",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -755,6 +794,9 @@ name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bit_field"
@@ -1648,6 +1690,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
@@ -1706,6 +1754,20 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1938,7 +2000,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "ely-agent"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"ely-platform",
"ely-ui",
@@ -1947,7 +2009,7 @@ dependencies = [
[[package]]
name = "ely-doc"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"anyhow",
"csv",
@@ -1969,7 +2031,7 @@ dependencies = [
[[package]]
name = "ely-files"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"aes-gcm",
"dunce",
@@ -1986,7 +2048,7 @@ dependencies = [
[[package]]
name = "ely-gpui-component"
version = "0.1.1"
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=2e65b05a6592d8c3166a252261fc01bf6215c3c5#2e65b05a6592d8c3166a252261fc01bf6215c3c5"
source = "git+https://github.com/ELY-Open-Source/Ely-GPUI-Components?rev=cd365e92ea0c4467c439b8253963461ba0f6d155#cd365e92ea0c4467c439b8253963461ba0f6d155"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -2032,14 +2094,14 @@ dependencies = [
[[package]]
name = "ely-ipc"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"log",
]
[[package]]
name = "ely-lok"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"base64 0.23.1",
"dunce",
@@ -2060,16 +2122,54 @@ dependencies = [
[[package]]
name = "ely-mail"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"ely-gpui-component",
"ely-mail-engine",
"ely-platform",
"ely-ui",
"embed-resource",
"gpui-pre",
"jiff",
"log",
"tempfile",
"tokio",
]
[[package]]
name = "ely-mail-engine"
version = "0.1.9"
dependencies = [
"base64 0.23.1",
"ely-platform",
"form_urlencoded",
"getrandom 0.4.3",
"html5ever",
"imap-proto",
"jiff",
"keyring",
"log",
"mail-builder",
"mail-parser",
"mail-send",
"nom 8.0.0",
"rcgen",
"rusqlite",
"rustls",
"rustls-platform-verifier",
"serde",
"serde_json",
"sha2 0.10.9",
"smtp-proto",
"tempfile",
"tokio",
"tokio-rustls",
"ureq",
]
[[package]]
name = "ely-mark"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"anyhow",
"base64 0.23.1",
@@ -2094,7 +2194,7 @@ dependencies = [
[[package]]
name = "ely-office"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"anyhow",
"directories",
@@ -2115,7 +2215,7 @@ dependencies = [
[[package]]
name = "ely-pdf"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"dunce",
"ely-files",
@@ -2133,7 +2233,7 @@ dependencies = [
[[package]]
name = "ely-pdf-engine"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"cc",
"ely-ipc",
@@ -2150,10 +2250,11 @@ dependencies = [
[[package]]
name = "ely-platform"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"anyhow",
"arboard",
"clipboard-win",
"core-foundation 0.10.0",
"core-video",
"directories",
@@ -2189,7 +2290,7 @@ dependencies = [
[[package]]
name = "ely-show"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"ely-files",
"ely-gpui-component",
@@ -2209,7 +2310,7 @@ dependencies = [
[[package]]
name = "ely-table"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"ely-gpui-component",
"ely-lok",
@@ -2226,7 +2327,7 @@ dependencies = [
[[package]]
name = "ely-tree"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"dunce",
"ely-files",
@@ -2246,7 +2347,7 @@ dependencies = [
[[package]]
name = "ely-ui"
version = "0.1.7"
version = "0.1.9"
dependencies = [
"anyhow",
"directories",
@@ -2470,6 +2571,18 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fancy-regex"
version = "0.14.0"
@@ -3514,6 +3627,27 @@ dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashify"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26"
dependencies = [
"indexmap",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heapless"
version = "0.9.3"
@@ -3793,6 +3927,15 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "imap-proto"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ccf963d57074747b455398a1763d174da80bcaba6f51e3671a82252b531a68b"
dependencies = [
"nom 8.0.0",
]
[[package]]
name = "imgref"
version = "1.12.3"
@@ -4044,6 +4187,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -4251,6 +4424,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
@@ -4436,6 +4620,38 @@ dependencies = [
"libc",
]
[[package]]
name = "mail-builder"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccbc27cb02a58a311a404769a22f5abf63fe3173b290d80be7c534f9c7023151"
[[package]]
name = "mail-parser"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ec00bda90c6e645a54506c630c2820cd6b1890cfd2b0a169b50f74b2b8c7c86"
dependencies = [
"encoding_rs",
"hashify",
]
[[package]]
name = "mail-send"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8355326d14f08c9de5e9badfe606d9ba8fea2febb60fe4318ecbac33cfb7b41e"
dependencies = [
"base64 0.23.1",
"gethostname",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"smtp-proto",
"tokio",
"tokio-rustls",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -5320,6 +5536,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -5377,6 +5602,12 @@ dependencies = [
"libc",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -5481,6 +5712,16 @@ dependencies = [
"hmac",
]
[[package]]
name = "pem"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
dependencies = [
"base64 0.23.1",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -6259,6 +6500,20 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rcgen"
version = "0.14.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]]
name = "read-fonts"
version = "0.37.0"
@@ -6467,6 +6722,31 @@ dependencies = [
"lru",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.21",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rust-embed"
version = "8.12.0"
@@ -6529,6 +6809,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "rustix"
version = "1.1.5"
@@ -6568,6 +6857,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework 3.7.0",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -6577,6 +6878,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.0",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
@@ -6639,6 +6967,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "1.2.2"
@@ -6976,6 +7313,16 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simd_helpers"
version = "0.1.0"
@@ -7084,6 +7431,25 @@ dependencies = [
"serde_core",
]
[[package]]
name = "smtp-proto"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142a5a642c6bd7ffd7e1b525ad6a6e9921ccef992dba4663974f8519209a00c1"
dependencies = [
"memchr",
]
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "spin"
version = "0.9.9"
@@ -7111,6 +7477,18 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -7377,6 +7755,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -7643,6 +8032,42 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
@@ -8092,6 +8517,12 @@ dependencies = [
"sval_serde",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -8369,6 +8800,15 @@ dependencies = [
"string_cache_codegen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
@@ -9288,7 +9728,7 @@ dependencies = [
"dpi",
"dunce",
"http",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2 0.6.4",
@@ -9340,6 +9780,24 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom 7.1.3",
"oid-registry",
"ring",
"rusticata-macros",
"thiserror 2.0.21",
"time",
]
[[package]]
name = "xcb"
version = "1.7.1"
@@ -9412,6 +9870,16 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yasna"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec 0.9.1",
"time",
]
[[package]]
name = "yazi"
version = "0.2.1"
@@ -9449,7 +9917,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -9663,7 +10131,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+15 -2
View File
@@ -5,7 +5,7 @@ members = ["apps/*", "crates/*"]
exclude = ["engines", "reference"]
[workspace.package]
version = "0.1.7"
version = "0.1.9"
edition = "2024"
rust-version = "1.95"
license = "MIT"
@@ -19,7 +19,7 @@ jiff = "0.2"
anyhow = "1"
directories = "6"
dunce = "1"
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "2e65b05a6592d8c3166a252261fc01bf6215c3c5" }
ely-gpui-component = { git = "https://github.com/ELY-Open-Source/Ely-GPUI-Components", rev = "cd365e92ea0c4467c439b8253963461ba0f6d155" }
env_logger = "0.11"
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
@@ -39,6 +39,8 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
semver = { version = "1", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
html5ever = "0.39"
sha2 = "0.10"
tempfile = "3"
ureq = "3"
@@ -56,10 +58,21 @@ core-foundation = "0.10"
objc2 = "0.6"
security-framework = "3.7"
objc2-pdf-kit = { version = "0.3", default-features = false, features = ["PDFDocument", "PDFPage", "objc2-app-kit"] }
imap-proto = "0.17"
nom = "8"
mail-builder = { version = "1.0", default-features = false }
mail-parser = { version = "0.11", features = ["full_encoding"] }
mail-send = { version = "0.6", default-features = false, features = ["ring", "tls12"] }
rcgen = "0.14"
rustls = { version = "0.23", default-features = false, features = ["ring", "tls12", "std"] }
rustls-platform-verifier = "0.7"
tokio = { version = "1", features = ["net", "time", "io-util", "rt", "macros"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
ely-files = { path = "crates/ely-files" }
ely-ipc = { path = "crates/ely-ipc" }
ely-lok = { path = "crates/ely-lok" }
ely-mail-engine = { path = "crates/ely-mail-engine" }
ely-office = { path = "crates/ely-office" }
ely-pdf-engine = { path = "crates/ely-pdf-engine" }
ely-platform = { path = "crates/ely-platform" }
+11 -13
View File
@@ -22,12 +22,12 @@ Ely Office 提供八个桌面应用。用户可以写文档、计算表格、制
| ElyTable | 电子表格编辑与计算 | LOK | XLSX、ODS、CSV |
| ElyShow | 演示文稿编辑与放映 | LOK | PPTX、ODP |
| ElyPDF | PDF 阅读、编辑、批注、表单和页面处理 | MuPDF;OCR 复用 Tesseract | PDF |
| ElyMail | 邮件收发、本地存储和搜索 | MailCore 2,加本地同步与存储 | 邮件、附件、草稿 |
| ElyMail | 邮件收发、本地存储和搜索 | Rust 的 mail-send、mail-parser、mail-builder(IMAP 与 POP3 由引擎自写),加本地同步与存储 | 邮件、附件、草稿 |
| ElyTree | Git 图形化版本管理 | Git CLI | 本地 Git 仓库 |
| ElyMark | 本地 Markdown 编辑 | Ely-GPUI-Components 的 Markdown 文档组件,markdown-rs 解析;参考 MarkText、ColaMD、Markion | Markdown 与本地附件 |
| ElyAgent | 独立的 Agent 配置与会话管理 | 嵌入的 Kigi CLI Rust 代码 | 配置、会话记录、运行状态 |
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
LOK、MuPDF 和 GPUI 是已确定的方向。邮件协议 crate、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能。它不是其他七个应用的聊天侧栏,也不是整个套件的控制中枢。[S19]
@@ -375,16 +375,18 @@ XFA 动态表单、任意嵌入脚本执行、专业印前分色及 PDF 到 Offi
### 12.1 定位与后端
提供多账号邮件客户端,不建设邮件托管服务。MailCore 2 负责 IMAP、POP、SMTP 和邮件解析等能力。Ely 负责账号流程、本地存储、搜索、同步队列和界面。MailCore 2 官方列出 C++ API 与三端支持,但发行前仍须验证实际构建和依赖。[S10]
提供多账号邮件客户端,不建设邮件托管服务。协议与 MIME 用 Rust crate(用户 2026-10-19 定,取代 MailCore 2):IMAP(imap-proto 解析)与 POP3 由 Ely 自写(async-imap 与 async-pop 有数处缺陷,C12.1 审查查出),mail-send 发 SMTP,mail-parser 读 MIME,mail-builder 写 MIME。它们在应用进程的后台任务里运行,不另起工作进程。Ely 负责账号流程、本地存储、搜索、同步队列和界面。
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文和富文本撰写区不用 CEF 与 Electron,渲染方式随 ElyMail 任务另定;不能把正文 HTML 解析当成完整页面渲染。
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文与写信不用 CEF 与 Electron(用户 2026-10-19 定):正文以 html5ever 解析,净化后按块的子集由 GPUI 绘制(段落、标题、列表、引用、表格、图片、链接、粗斜体与颜色),脚本、定位、浮动与样式表之外的版式不画,不当作完整页面渲染;写信用组件库的 Markdown 文档组件,发出纯文本与由 markdown-rs 生成的 HTML 两部分。
内置登录:Gmail 与 Microsoft 走 OAuth;iCloud 不给第三方 OAuth,走应用专用密码向导(服务器 `imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)[S10]。OAuth 的客户端 ID 在构建时注入,缺 ID 的构建里该登录写明不可用。
### 12.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| MAIL-01 | 账号 | 支持多个 IMAP/SMTP 账号、POP3 账号、服务器配置、TLS 与连通性检查 |
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程;支持服务商允许的应用密码或自定义认证配置 |
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程,iCloud 的应用专用密码向导;支持服务商允许的应用密码或自定义认证配置 |
| MAIL-03 | 邮箱视图 | 支持统一收件箱、账号独立视图、文件夹、未读、标记和附件筛选 |
| MAIL-04 | 阅读 | 支持纯文本、HTML、内嵌图片、附件、原始邮件头与会话线程 |
| MAIL-05 | 写信 | 支持收件人、抄送、密送、回复、回复全部、转发、签名、附件和纯文本/富文本撰写 |
@@ -630,7 +632,7 @@ C/C++ 异常、原生错误与资源释放在 FFI 边界处理,不能跨 Rust
### 17.3 内容渲染
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文和富文本撰写的渲染方式随 ElyMail 任务另定,定前不写成已有。
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文按块的子集由 GPUI 绘制,写信用 Markdown 文档组件(§12.1)。
Ely 主题 token 用于内容区。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示。
@@ -766,7 +768,6 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
| --- | --- |
| MuPDF | 按锁定版本的 AGPL 条款提供对应源码、修改、集成和构建资料;不把公开 UI 仓库当成已完成合规 [S8][S9] |
| LibreOffice/LOK | 保留 MPL 2.0 及附带依赖的相应声明;发布对受约束文件的修改与所需资料 [S5] |
| MailCore 2 | 保留其许可、版权和免责条款,并检查其传递依赖 [S24] |
| Git | 作为独立程序分发并遵守其 GPLv2 等实际适用条款;提供所发行版本的源码取得方式 [S23] |
| Markdown | 保留 markdown-rs、RaTeX(含随带字体)、mermaid-rs-renderer 及其依赖的许可;逐项检查实际发行包 [S14][S15][S16] |
| OCR、字体、词典和媒体组件 | 记录每个资源的来源、版本和再分发条件,不从系统目录随意拷贝受限资源 |
@@ -841,7 +842,7 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
| LOK 三端嵌入 | Doc/Table/Show 的编辑、保存、中文输入与窗口操作录像及日志 | 修复适配或更新设计决策;不能只交付查看器 |
| LOK 高级功能 | 母版、放映、图表、修订、输入法、打印和无障碍的接口映射及用例 | 补齐桥接或维护必要上游补丁,不跳回上游原生 UI |
| MuPDF 编辑 | 字体、对象修改、表单、删除及保存的样本结果 | 修复对象编辑与输出;不得改为视觉覆盖冒充编辑 |
| MailCore 2 | 三端可重复构建、TLS、OAuth、同步和 MIME 测试 | 处理依赖及适配问题;替换后端必须显式修订选型 |
| 邮件协议 crate | 三端 TLS、OAuth、IMAP 与 POP3 同步、SMTP 发送和 MIME 测试 | 修复或换 crate;换后端须显式修订选型 |
| Markdown 原生编辑 | 可视、源码、预览的输入、焦点、缩放、输入法和辅助技术测试 | 未通过不能以 CEF、WebView 或 Electron 代替 |
| Markdown 原文保留 | 无编辑保存、模式切换、局部修改和外部冲突的字节级差异 | 修复映射与保存;未知语法回到原文模式,不删除 |
| 邮箱服务注册 | 正式 OAuth 配置、适用审核状态与用户流程 | 不将未获许可的内置登录描述为可用 |
@@ -895,8 +896,8 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
[S9] GNU Affero General Public License 第 3 版原文。
`https://www.gnu.org/licenses/agpl-3.0.en.html`
[S10] MailCore 2 官方介绍,协议、API 与平台范围。
`https://etpan.org/mailcore2.html`
[S10] Apple,iCloud 邮件用于其他邮件应用的服务器设置。
`https://support.apple.com/en-us/102525`
[S11] Google,Gmail IMAP/POP/SMTP OAuth 2.0 与权限要求。
`https://developers.google.com/workspace/gmail/imap/xoauth2-protocol`
@@ -935,8 +936,5 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
[S23] Git 官方 COPYING 文件。
`https://raw.githubusercontent.com/git/git/master/COPYING`
[S24] MailCore 2 官方 LICENSE 文件。
`https://raw.githubusercontent.com/MailCore/mailcore2/master/LICENSE`
[S25] Typora 官方快捷键说明;对照的程序为 Typora 1.14.10 Windows 安装包(本机 `TyporaRef`,不入库)。
`https://support.typora.io/Shortcut-Keys/`
+20 -12
View File
@@ -14,7 +14,7 @@
| Doc / Table / Show | LibreOfficeKit,独立工作进程 |
| Show 媒体 | 各平台原生:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(LOK 不放媒体) |
| PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 |
| Mail | MailCore 2 + SQLite;正文渲染随其任务另定(不用 CEF 与 Electron) |
| Mail | mail-send、mail-parser、mail-builder,IMAP(imap-proto 解析)与 POP3 由引擎自写(`crates/ely-mail-engine`,应用进程内的后台任务,无 worker)+ SQLite(FTS5);正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制;写信用 `documents::MarkdownDocument`,发出纯文本与 HTML;Gmail、Microsoft 走 OAuth,iCloud 走应用专用密码 |
| Tree | Git CLI,参数数组启动 |
| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析与导出 HTML,RaTeX 公式,mermaid-rs-renderer 图;PDF 经 `ely-pdf-worker` 由 MuPDF 排版 |
| Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 |
@@ -23,7 +23,7 @@
```text
apps/ 八个应用,各自一个二进制
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端);ely-mail-engine 随其任务建
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端);ely-mail-engine(邮件协议、同步与本地存储)
reference/ 上游源码,只读,不属于本项目,不审查
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
@@ -33,14 +33,14 @@ assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns
dist/ 打包产物,不入库
docs/ 兼容清单、无障碍、帮助、许可、性能记录;engineering-risks.md、qa.md 等内部笔记只在本机,不入库
scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely)
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely;mail/sample.eml:带编码主题、分组抄送、纯文本加 HTML、cid 图片与附件的邮件,LF 行尾,测试换成 CRLF)
tasks/ TASKS.md 进度真相
site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木;中英两种文案在 src/text.ts);site/video 是发布视频的分镜、配乐、录屏与合成脚本,产物在 site/video/out(不入库)
```
## 组件映射
依赖 `ely-gpui-component`(pin 2e65b05,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
依赖 `ely-gpui-component`(pin cd365e9,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
| 需要 | 用 |
| --- | --- |
@@ -80,7 +80,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
| 幻灯片缩略图条(`documents::PageThumbnailList` 只收图片路径,无隐藏标记、右键菜单与重排) | `apps/ely-show` 的 `slides.rs`,内核按页绘制到内存图 |
| PDF 页面缩略图(同上,且一次铺全部页,不合 PERF-05) | `apps/ely-pdf` 的 `side.rs`,`uniform_list` 只画可见行,worker 按需渲染 |
| 演示的参考线:LOK 不把参考线画进图块,也无参考线接口 | 补丁 0027 读写参考线;`apps/ely-show` 的 `guides.rs` 画在画布上,拖动移动,拖出幻灯片即删除 |
| gpui 文本里不能嵌图片:行内公式无从排版 | 组件库的 `MarkdownRenderer` 把行内公式显示为 TeX 原文(着色),块公式才排版 |
| gpui 文本里不能嵌图片:行内公式无从排版 | 组件库 `render/math_text.rs`:以不断行的空格占出公式的宽(整个公式作一个词折行),排好的公式画在其上,按下处读回公式的原文 |
| `A11yChecker` 的 `audit` 与对照表是私有的;Omarchy 色板要照同一套规则 | `ely-ui` 的 `omarchy/palette.rs` 抄了对照表。上游待办:公开 `audit` |
| 组件无 role/aria 语义:菜单、列表等控件在 AX 树仍不可见 | 上游已修核心控件(7380afb:Button、TextInput、Tabs、面板行、对话框等);其余上游待办。项目自绘控件已自带 |
| gpui 无 aria-disabled API(`AriaProperties` 无 disabled 字段) | 组件 7380afb 起以本地化 aria_description 表达;上游待办:gpui 补 API |
@@ -123,7 +123,7 @@ scripts/fetch-references.sh 取上游源码到 reference/
scripts/fetch-tessdata.sh 取 OCR 语言数据到 engines/tessdata(钉提交,核对散列)
scripts/build-git.sh 构建钉住的 Git 到 engines/git(ElyTree 随包)
scripts/fetch-git-lfs.sh 取 git-lfs 放进 engines/git(核对散列)
scripts/review.sh ID 轮次 路径 审查一条任务(herdr 右侧分栏,codex gpt-6-astra,effort max,fast)
scripts/review.sh ID 轮次 路径 审查一条任务(herdr 右侧分栏,codex gpt-6.1-sol,effort xhigh,fast)
scripts/apply-lo-patches.sh engines/lo-src 找出源码已到补丁序列第几个,补上其余(构建脚本调用)
tests/scripts/lo-patches.sh 补丁序列从中途接着打、再打一次为空操作
tests/scripts/media-targets.sh 媒体播放接口在 Linux、Windows 目标可编译(缺目标时 SKIP)
@@ -149,7 +149,7 @@ packaging/icons/masters.py 写出 assets/icons 的全部 SVG 母版(图标
packaging/icons/build.sh 由 assets/icons 的母版渲染 icns、png、ico(只在 macOS)
tests/scripts/icons.sh 八个图标齐全、未过期,macOS 为 squircle,其余无底形
tests/scripts/release-macos.sh dist 里首批六个 .app:以访达的方式打开文件(启动与运行中),引擎都取自包内,带齐许可,不含系统会登记的内嵌包,每个二进制要求的系统不高于应用所写;源码包齐全
tests/engines/*.sh 引擎探测(LOK、MuPDF、libetpan);LOK 探测本构建的内核,不挂载官方镜像
tests/engines/*.sh 引擎探测(LOK、MuPDF);LOK 探测本构建的内核,不挂载官方镜像
scripts/inert-lo.sh 路径.app 构建出的 LibreOffice.app 去掉文档类型、URL 类型、快速查看与聚焦插件并注销(build-libreoffice.sh 调用)
scripts/extract-official-lo.sh 用 7zz 把官方镜像的 Frameworks 与 Resources 解到 engines/lo-official(不挂载,无包,“未打补丁即拒绝”的单测用)
tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构建出的包不声明任何文件类型
@@ -180,7 +180,7 @@ tests/scripts/windows-installer.sh 假应用的安装程序:所选目录原
tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认
tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限)
tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用)
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:Gitea API 的最新版本与全部发布的下载总数(不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:GitHub API 的最新版本与全部发布的下载总数,加上 Gitea 上迁移前的下载(Gitea 不通时只计 GitHub;都不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
cargo check --workspace
@@ -206,14 +206,13 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-04:ElyTable 公式、计算、名称(C09.2,TABLE-03 至 06)。公式语法为 Excel A1(用户定):`Sheet2!A1`、逗号分隔参数,由安装层 `packaging/libreoffice/ely.xcd` 设定;载入选项 `Language=en-US,DeviceFormFactor=desktop`,内核界面与数字区域都是 en-US(小数点、月/日/年输入)。单元格内编辑由内核完成:键入时内核给出匹配的函数(`editor::CompletionMenu` 显示在单元格下,上下键选,Tab 补全,经 `completeFunction` 入有序队列),参数提示(`editor::SignatureHelp`,取内核的用法文字),点选单元格插入引用并按内核颜色画框;内核只为点选的引用与再次编辑(F2)的已有公式画框,新键入的引用不画(同桌面版)。第一下 Esc 关提示,第二下取消(同内核)。语法错误的公式照原样输入,状态栏按界面语言解释当前格的错误(内核英文错误串换成界面文案,同统计);循环引用同此提示。“公式”菜单:自动计算开关(关时状态栏显示手动计算)、重新计算、全部重新计算、定义名称(名称、引用位置默认为所选区域的绝对引用、范围为工作簿或某表;名称先经内核判定)、名称列表(改引用、转到所命名的绝对区域、删除,失效引用标出;内核给出每个名称的区域,`.uno:SelectedRange` 给所选区域)、追踪错误、追踪引用与从属单元格、移去箭头。名称框可直接输入名称跳转,跳转后显示该名称。手动计算在 ods、xlsx、xls、fods 存后重开都保持。
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案 a4bf696 起接 i18n(原为英文)。
- 2026-10-04:右侧属性栏宽取 `theme.sheet_size()`(380),左栏仍是 `sidebar_width`。`PropertyGrid` 名称列固定 120,240 宽时编辑器只剩约 76。颜色经色块弹出 `ColorPalette`,一次点击一条命令。
- 2026-10-01:MailCore 2 的子模块 libetpan 过旧,改钉 libetpan master `4d47862`。
- 2026-10-01:每个应用的全部数据在 `<本机数据目录>/ElyOffice/<slug>/`,路径由 `ely_platform::data_dir` 给出。卸载只动这里,且先问。用户文档永不碰。
- 2026-10-04(用户定):macOS 包标识前缀 `ely.zacharyzhang.office`(`packaging/macos/bundle.sh`);发布者 ZacharyZhang,网址 zacharyzhang.com(macOS `NSHumanReadableCopyright`,Windows 卸载项 `Publisher` 与 `URLInfoAbout`;Linux 桌面文件无此字段)。界面语言英文与简体中文,以后再加。拼写词典只随英文:LibreOffice 的词典库没有中文(中文不用 Hunspell 拼写)。内核界面语言固定英文,函数名照英文;ElyTable 把内核英文的选区统计(Sum、Average 等七个固定名)换成界面语言,数值照内核。批注与修订的作者取各用户的账户全名(无全名用登录名,与内核自身次序一致),由补丁 0013 的 `setOption("author")` 在 worker 启动时设定;作者设置页随设置任务接入。邮件合并字段写法 `«列名»` 由我定(用户交我决定)。
- 2026-10-01:更新清单是 JSON:`{"version", "assets": {"<os>-<arch>": {"url", "sha256"}}}`,只走 https(本机回环除外)。检查、下载、校验在 `ely_platform::update`,从不静默安装。包管理器安装的 Linux 版本不自更新。2026-10-09 起(用户定:0.1.2 加自动更新):每个应用一份清单,`<slug>.json`,随发布上传,应用经 git.zacharyzhang.com 的 API 读最新发布(`api/v1/repos/ZacharyZhang-NY/ElyOffice/releases/latest`),在其文件里找 `<slug>.json`(`ely_platform::release::asset_url`;Gitea 没有 `releases/latest/download` 路径;`ELY_UPDATE_MANIFEST` 直接给清单地址,测试用);只在从 `.app` 运行时可用(开发构建没有);照 PRD §4.2 自动检查默认关闭:设置“通用”的“启动时检查更新”(通用设置 `check_updates`)打开后启动时查一次、查不到只记日志,应用菜单的“检查更新…”随时手动查(已是最新或查不到都以提示写明);有新版即在窗口上方显示组件库的 `UpdateBanner`(文案进内建中英目录,组件库 5d6efd4):横幅在标题栏下方(`Shell` 的页眉里,`Updates` 自己是一个视图);下载(进度)、校验散列、“重新启动以更新”只走正常的退出流程(有未保存的照常询问;取消即什么也不装,按钮仍在);应用真正退出时(`on_app_quit`)以本程序的安装模式(`--ely-install-update dmg 包 进程号`,`ely_ui::run` 最先识别)起一个独立进程:等本进程结束,`ely_platform::install` 挂载 dmg、把同名 .app 拷到旁边再换掉原包(换上前失败即放回原包),无论成败都 `open` 该包;失败的原因写在数据目录 `updates/failed.txt`,重开的应用以提示写明。下载完成只归属于它下载的那个版本;下载中或已下载时再查不覆盖。`packaging/macos/release.sh` 为每个应用写出清单(版本、本机架构的 dmg 地址与 SHA-256),dmg 里另有指向“应用程序”的替身,可直接拖入。新包换上即为装好,旧副本删不掉只记警告。`tests/scripts/update.sh`:装好的 ElyMark 在默认设置下启动不发任何请求;按“检查更新…”后从本机服务的 9.9.9 下载;有未保存的文字时“重新启动以更新”先问,取消则原包不动,“不保存”后装上新版并重新打开(新版本身的进程在跑)。
- 2026-10-05(用户定):ElyAgent 的后端与内置模型的登录配置以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP;沿用其提供商登录(订阅账号或 API 密钥)与模型配置。
- 2026-10-05(用户定):八个应用都不许卡顿:键入、选择、滚动、文档渲染都要即时(GPUI 的 120 FPS 手感),树莓派 3 上也要流畅。输入延迟与帧时间是验收项,要实测;界面线程不等内核、不持 worker 锁做同步请求,空闲不做事。
- 2026-10-05:LOK 的输入与事件路径(实测后定)。原样:视图的事件监听长轮询 worker(持 worker 锁最多 50 毫秒,worker 读线程也随之阻塞最多 250 毫秒),按键排在锁后,键入锁等待中位 290 毫秒、最长 9 秒;输入队列在界面执行器上逐条跳回界面线程,每键约一帧,连续键入积压到 1 至 2 秒。现在:worker 有新事件即主动推送(`tag::EVENTS`,推送线程在输出锁下取批,批次即发送时状态),客户端读线程把回复与事件分开,事件存入 `client::Feed`,视图以异步方式等它(`Feed::next`,有新批次时唤醒,不占线程、不碰 worker 锁;原先每个打开的文档阻塞一个后台线程最多一秒,文档多于核数时会饿死输入与图块;监听任务归视图所有,视图释放即停);每个视图一条后台通道(`view/lane.rs`),输入优先、图块其次,连续执行,界面线程只收落地结果;文档尺寸与页框只在内核报尺寸变化时在后台读;批次弄脏的已显示图块即刻重绘,不等下一帧;视图的 `Reported` 事件只在内核回报或输入落地时发出,滚动与图块到达不触发应用的观察钩子;编辑与光标移动后应用要读的内核列表(ElyDoc 导航、字数、审阅,ElyTable 工作表与几何、单元格规则、格式面板)经 `ely_lok::read_off` 在后台读,一次一个,结果按发起者核对。实测(macOS,`tests/scripts/lag.sh`):按打字速度键入时输入落地 0 至 1 毫秒,图块 1 至 3 毫秒,按键到新图块 ElyDoc 中位 19 毫秒、ElyTable 3 毫秒;连续快速键入(脚本一次送出近百字)时,各次运行在 3 至 225 毫秒的中位之间摆动:新文档首次键入时内核有一次 150 至 230 毫秒的冷启动(首块图块与拼写等初始化),同一批字排在它之后;原为中位 1 秒、p95 1.9 秒。树莓派 3 未实测(本机无设备)。界面线程不碰 worker:应用对内核的读取、保存与另存为的导出、导出 PDF、比较、邮件合并都经 `Tabs::read_in`(或 `ely_lok::read_off`)在后台执行,回到窗口时按发起的标签与读取时的状态核对;保存导出期间标签状态为“正在保存”,同一标签的后续保存排队到它结束;另存为在写入前按文件身份再查一次目标是否已被别的标签持有;导出期间又有编辑时,“保存后关闭”不关闭,回到关闭询问;关闭标签时的撤销历史也在后台读;邮件合并预览与比较在发起时就保留自己的标签号与工作目录;读取失败不立即重试,等下一次状态变化;载入时一并在后台读版面与撤销历史(`ely_lok::layout`,`LokView::open` 接收版面)。窗口关闭回调先不放行,在后台逐个读标签的撤销历史并关闭(有改动的照常询问),最后一个标签关闭后移除窗口;内核文档的关闭也在后台(2026-10-06 起,原先同步读历史、在界面线程持 worker 锁关闭)。
- 2026-10-04(用户定):八个应用的内容区一律随系统深浅色:Doc 页面、Table 网格、Show 幻灯片、PDF 页面、邮件正文、Mark 编辑区、Agent 输出。自动颜色随之翻转,文件里写明的颜色照原样;打印与导出 PDF 仍为浅色。各引擎(LOK、MuPDF)接入应用主题,不留引擎默认色。LOK:`ely.xcd` 定义 `COLOR_SCHEME_ELY_DARK` 与 `COLOR_SCHEME_ELY_LIGHT`(文档底色、自动文字色、网格、链接等取 Ely 默认色板;内核自带的深色方案颜色留空、按应用外观取默认值,传方案名不改外观,所以不用它),`LokView` 在主题深浅变化时经有序队列发 `.uno:ChangeTheme`(不算编辑),页面底色深色取 `surface`、浅色取 `paper`;Omarchy 色板与这两套固定色可能略有差别。导出 PDF 实测仍为白底。图块作废时旧图仍显示,新图到达后替换,编辑与主题切换都不闪。
- 2026-10-04(用户定),2026-10-18 改(用户定):Doc 页面、Table 单元格、Show 幻灯片、PDF 页面在任何外观下都照文件原色(白底即白底),只有页面四周的界面随深浅色;邮件正文、Mark 编辑区、Agent 输出随系统深浅色。LOK:`ely.xcd` 只有 `COLOR_SCHEME_ELY_LIGHT`(文档底色白、自动文字色黑、网格、链接等);方案按视图记(LOK 下全局改动只作用于当前视图),每个 `LokView` 首帧经有序队列发一次 `.uno:ChangeTheme`(`ely_lok::SCHEME`,不算编辑),ElyShow 放映与输出的副本同样各发一次;旧版写进内核配置的 `COLOR_SCHEME_ELY_DARK` 已删,内核读不到即退回自动方案,首帧的那次改回 Ely 方案。页面底色 `ely_lok::paper()` 为白,同方案的 `DocColor`。MuPDF 照原样画,不反相。图块作废时旧图仍显示,新图到达后替换,编辑不闪。
- 2026-10-01:主题。`Appearance` 为系统、浅色、深色。系统档在有 Omarchy 时跟随 Omarchy(`ThemeSettings::omarchy_sync`)。读 Omarchy 的 `$HOME/.local/state/omarchy/current/theme/colors.toml`(Omarchy 写死此路径,不随 `XDG_STATE_HOME`),目录由 `ELY_OMARCHY_DIR` 可改。读法与回退链照 `reference/omarchy/bin/omarchy-theme-color`:松散引号、`colorN`、`purple`、`selection_background`、`theme_type`、`light.mode`、亮度和 382 比。缺的色相用 Ely 自己的色并警告。色板照组件库 `A11yChecker` 的规则收口:正文、状态色、代码色在各自的表面上 4.5:1,焦点与图表色在页面上 3:1;先拉近前景再拉到纯黑白,最后逐项核对,核对不过的主题整套忽略。切换时 Omarchy 先暂存 `next-theme` 再整体替换 `theme`,用文件监视跟随,不用它的钩子。新主题无 `colors.toml` 时退回 Ely 色板。`ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION` 是覆盖开关,设置页接入前先用环境变量。
- 2026-10-05:组件库 8658999(组件库重写历史后的 main,加本项目三个提交):属主不经 `Close` 移除的浮层(一起移除的嵌套浮层同样,回到最外层打开前之处)(对话框动作只清自身状态、异步校验通过后才关)照样把焦点交回打开前之处;原先焦点落在根上,键入无处可去,须先点一下网格。同批带入上游的 ColumnShell、输入法组字时光标重新闪烁、`Icon::from_path`、MIT 许可。旧 pin 2f02c82 已不在 main 上,只能按完整哈希取得。`Select::label` 与 `TextInput::label` 给控件无障碍名称而不绘制;对话框里描述列表旁的控件以此命名,开关用自身的 `label`。7186179 加 `NumberInput::label`(经内部文本框的 `TextInput::set_label`),同批带入上游的 rustfmt 修正。80c46b1:`TemplatePicker` 的列表是一个 Tab 停靠点,上下、Home、End 移动,回车使用,行带列表项语义,搜索提示、全部、使用、无匹配的文案进内建中英目录。7df3045:`SelectableList` 在 AX 树里是列表(`label` 命名),`ListItem` 是列表项(标题为名,带说明、选中、在可选列表中的序号与总数);原先行无角色,不在 AX 树里。af54877:`NumberInput::blank` 显示占位文字而非数字(混合选区),空着回车或失焦不提交,键入后照常。408f2b0:`CommentSidebar` 与 `RelativeTime` 的文案进内建中英目录;属主不处理“解决”时(无 `on_resolve`)不分未解决与已解决,一列显示全部。bcf0378:`CommentSidebar` 的批注列是列表,每条是 Tab 停靠点(列表项语义,名为作者与正文,带选中、序号与总数),回车或空格选中。71bfc93:`init_for_tests`(`test-support` 下)装主题与键位、不要资源,应用单测可渲染 Ely 组件。617a8d6:`FindWidget` 在查询框里按 Esc 即关闭(调 `on_close`)。2ce3368:`ContextMenu` 可定宿主大小。3d08744:`DescriptionList` 的值占满所在列,其中的文本框随之全宽;同批带入上游的图表、分页改动。a6b1bb0:`SignaturePad` 的“在此签名”与“清除”进内建中英目录;同批带入上游 P01 至 P15(各组件的越界与失效状态记日志而不 panic)。490b022:`ChangesList` 分列冲突、已暂存、改动、未跟踪、已忽略(各有其动作:冲突与已忽略只可打开),标题与提示进内建中英目录;同批带入上游 P16 至 P20。7bb53f4(ElyTree 用):`ChangesList` 行数可缺、按区报按下、当前行;`DiffViewer` 外算的段、块按钮、可选的变动行、逐行读给辅助技术;`CommitList`、`FileHistory`、`BlameView`、`ThreeWayMerge` 只画可见行,`CommitList` 近尾加载;`DescriptionList::text`;`SegmentedControl` 各段为单选;`BranchList`、`TagList` 可选中、只画属主处理的行内动作;`marked`、`conflicts`、`resolve` 按标记宽度(`MARKER` 为默认 7),a284c71 起只按 LF 断行;c5799cc:`StashList` 文案进内建中英目录,行可选(`selected`、`on_pick`),只画属主处理的行内动作;7fab072 至 3741d5d:分支、标签、储藏的行分两行(上行名称与行内动作,下行说明与行尾文字),窄时名称与说明先让;行内动作一律显示(原先只在悬停时显示,键盘停在其上时看不见),改动列表同;各 git 行带透明的 1 像素边框,焦点框才画得出(原先 `focus_ring` 无边框宽度,键盘焦点不可见);同批带入上游 P21 至 P29。4f8f3bd:`Changed::lines` 可缺(二进制文件、文件夹不计行数,不显示);37c5187 缩短其注释。19b0b29:`DiffViewer::from_stretches`(别处算好的差异,如 Git 自己的)、`hunk_action`(每块标题上一个按钮)、`selected` 与 `on_line`(改动行可按,带 Shift),`diff::mark_words` 给外来的行标出改动的词,“行内”“并排”“N 行未改动”进内建中英目录;30fea9e:有块按钮时各行同高,底色相接。edd2c12:`ChangesList` 每次按下带分区(`ChangeSection`),行是 Tab 停靠点(列表项语义),`selected` 标出当前文件。2e080b8:`SegmentedControl` 每段是单选按钮(名为其文字,带选中状态),原先不在 AX 树里。e226d79:差异查看器的行模型与配色移入 `git/rows.rs`(文件不超过 500 行);7b807a9 缩短其注释。51d2aea:`CommitList`、`FileHistory`、`BlameView` 只画视口内的行(`uniform_list`,填满所在的框;`CommitList` 收 `Rc` 的提交与 `lanes` 算好的图,`on_end` 在接近末尾时要下一页),各行是 Tab 停靠点(列表项语义,选中状态;Blame 每段首行可按,名为作者、时间与摘要),`FileHistory` 的行数可缺;`CommitInput` 的“提交到”“修改最近提交”进内建中英目录。e6220ad 与 3fc5a46:提交行窄时作者、提交号与时Line truncated
- 2026-10-05:ElyTable 透视表与图表(C09.4,TABLE-10、11)。补丁 `0019-lok-calc-pivot-tables.patch` 与 `0020-lok-calc-charts.patch`:`.uno:DataPilotExec` 收 `Pivot`(JSON:源区域、行、列、筛选、值字段与函数 sum/count/average/max/min),光标在透视表上即原地修改,否则从源区域在新工作表创建;行、列、筛选里的空名表示内核的数据布局字段(多个值字段横排或竖排的位置),读出与写回都保留,已在表中的行、列、筛选字段保留各自的小计函数,读出的布局原样写回不改表;照内核布局对话框的步骤(`ConvertOrientation`,保留各字段的成员、排序与版式),新表的筛选按钮、钻取、展开收起都关(同对话框初值);放到新工作表时插表与透视表是两步撤销(同桌面版)。刷新 `.uno:RecalcPivotTable` 在内核里本就不进撤销(它改缓存)。`.uno:InsertObjectChart` 收 `Chart`(JSON:类型 column/bar/line/scatter/pie、标题、图例、X/Y 轴标题;`other` 保持原类型),LOK 下不开图表向导、插入为一步撤销、插入后图表选中;给出的区域每段都要解析得了、所指工作表都要存在,否则插入与修改都拒绝、文档不动(内核原会改用示例数据,修改时 `ChartModel::setArguments` 抛出未捕获的异常使 worker 退出);`.uno:ChangeChartData` 也收 `Chart`,区域与外观合成一步撤销(撤销保存整个图表的图示与主标题的副本,堆积等外观以外的设置一并恢复)(列表动作带当前视图号,否则 LOK 拒绝撤销别的视图的动作)。读取:`.uno:Chart`(选中图表的对象名、区域、表头、外观)、`.uno:PivotTable`(光标处透视表的名称、位置、源区域、字段与布局),`.uno:SelectedRange` 加 `data`(选区内数据的绝对区域)。界面在“插入”菜单:数据透视表、刷新数据透视表、图表;对话框在光标处有透视表、或有选中图表时为修改,否则以选中的数据(单格扩到数据区)新建,源与图表区域取 `data`,不含空行。为对话框发起的读取(`Tabs::ask_in`)回来时,发起的工作簿已不是当前标签即丢弃,不为隐藏的工作簿开对话框(ElyTable 全部对话框、合并询问)。没有数据时恢复原光标(`select.data` 会把单格标成区域,回车随之只在格内打转)。公式语法是 Excel A1,区域写 `Sheet1!$A$1:$C$5`。
@@ -277,7 +276,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
- 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里借用开发克隆 `~/Documents/GitHub/ElyOfficeSuite` 的引擎(`ELY_SHARE`;2026-10-11 起:从零构建 LibreOffice 超过 act_runner 默认的 3 小时,v0.1.3 的 Mac 任务被杀、无 dmg),Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage、deb、rpm 与 Arch 包(引擎构建与打包都经 `packaging/linux/container.sh` 进 Ubuntu 26.04 容器,WSL 里须有 docker;`ELY_SHARE` 的目录一并挂进容器),都由 `packaging/stage.sh` 分装引擎;本提交的源码包只由 Linux 上传(`packaging/source.sh`;macOS 照建供 `release-macos.sh` 核对,不上传,两端并行时不重复;Windows 的检出把符号链接写成文件,不出);重跑时所传文件有任一同名的已在发布里即失败、什么也不传,写明须先删去哪些(重建的字节不同,更新清单的散列会对不上已在的 dmg);更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。
- 2026-10-11:gpui 的 Windows 窗口关闭(W-03 冒烟查出)。`gpui-pre-windows` 0.3.8 在窗口关闭时报三条错:系统已销毁窗口(ElyTree 的关窗交给系统)后 Drop 仍对旧句柄 `RevokeDragDrop` 与 `DestroyWindow`(拖放处理器因此不释放,连带窗口状态);销毁时的失活回报排在异步任务里,执行时窗口已移除(“window not found”);退出后后台任务向已销毁的平台窗口投递(`Invalid window handle`)。修复在 `vendor/gpui-pre-windows`:`WM_DESTROY` 时记下已销毁、在句柄仍有效时撤销拖放、去掉失活回调,Drop 见已销毁即不再动句柄;平台窗口已不在时投递失败只记 debug(应用已退出),其余投递失败照报。三个应用关窗与退出的日志无错。
- 2026-10-11(用户报:Windows 启动带黑色命令行窗口、“打开方式”里无图标、点 X 不退出、表格全黑无网格线):四处根因。命令行窗口:应用是控制台子系统;发行构建改为 `windows_subsystem = "windows"`(开发构建照旧有控制台看日志),子进程(两个 worker、Git、`cmd start`)一律经 `ely_ipc::windowless` 以 `CREATE_NO_WINDOW` 启动。图标:exe 里只有 gpui 的清单资源;各应用共用构建脚本 `packaging/windows/icon.rs`,把 `assets/icons/<应用>.ico` 编成资源 1(gpui 的窗口图标也取资源 1)。点 X 不退出:窗口其实弹了“保存更改”询问。`ely.xcd` 深色方案的 `CalcNotesBackground` 与浅色不同,Calc 在配色变化时改写批注样式并记一步撤销(`ScDetectiveFunc::UpdateAllComments`),未动的表格切到深色即算已修改;两套方案的批注底色改为同一值(LibreOffice 默认的浅黄),切换主题不再改文档,三端同此(`sheet.rs` 的 `a_theme_switch_is_no_edit`)。全黑:Win32 的虚拟设备没有 `SetSizeUsingBuffer`(基类默认返回假,断言在发行构建里不在),给客户缓冲区的设备停在 1×1 像素,Calc 图块只画了一个像素,放映图层同空;补丁 `0044-lok-win32-paints-then-copies.patch` 让 Windows 走 macOS 的路:按全尺寸画进设备,再拷进客户缓冲区(`doc_paintTile` 与 `SlideshowLayerRenderer`)。Writer 自设设备尺寸,原本不受影响。`tests/scripts/lo-patches.sh` 的临时库也设 `core.autocrlf false`(原先在 Windows 上恒失败)。
- 2026-10-11(用户报:Windows 启动带黑色命令行窗口、“打开方式”里无图标、点 X 不退出、表格全黑无网格线):四处根因。命令行窗口:应用是控制台子系统;发行构建改为 `windows_subsystem = "windows"`(开发构建照旧有控制台看日志),子进程(两个 worker、Git、`cmd start`)一律经 `ely_ipc::windowless` 以 `CREATE_NO_WINDOW` 启动。图标:exe 里只有 gpui 的清单资源;各应用共用构建脚本 `packaging/windows/icon.rs`,把 `assets/icons/<应用>.ico` 编成资源 1(gpui 的窗口图标也取资源 1)。点 X 不退出:窗口其实弹了“保存更改”询问。`ely.xcd` 深色方案的 `CalcNotesBackground` 与浅色不同,Calc 在配色变化时改写批注样式并记一步撤销(`ScDetectiveFunc::UpdateAllComments`),未动的表格切到深色即算已修改;两套方案的批注底色改为同一值(LibreOffice 默认的浅黄),切换主题不再改文档,三端同此(2026-10-18 起只剩浅色方案,`sheet.rs` 的 `the_scheme_is_no_edit`)。全黑:Win32 的虚拟设备没有 `SetSizeUsingBuffer`(基类默认返回假,断言在发行构建里不在),给客户缓冲区的设备停在 1×1 像素,Calc 图块只画了一个像素,放映图层同空;补丁 `0044-lok-win32-paints-then-copies.patch` 让 Windows 走 macOS 的路:按全尺寸画进设备,再拷进客户缓冲区(`doc_paintTile` 与 `SlideshowLayerRenderer`)。Writer 自设设备尺寸,原本不受影响。`tests/scripts/lo-patches.sh` 的临时库也设 `core.autocrlf false`(原先在 Windows 上恒失败)。
- 2026-10-11(用户报:Windows 上标题栏的最小化、最大化、关闭三个按钮都按不动):gpui 回答系统的命中测试(`WM_NCHITTEST`)时按绘制次序取第一个含指针的窗口控制区,标题栏的拖动区先于其中的按钮画出,按钮上一律答“标题栏”,系统把按下当作拖动窗口的开始,点击到不了按钮。修复在 `vendor/gpui-pre`(原样一个提交,其后是修复):按命中测试的次序取最上层的控制区,且只看未被遮挡的部分(同 `is_hovered`,盖在标题栏上的对话框挡住其下的按钮)。实测(发行构建,DPI 感知的探针移动真实指针):自右向左依次答关闭、最大化、最小化、拖动;按下最小化即最小化,最大化即最大化,关闭即退出、worker 随之结束;v0.1.3 的安装版同一探针处处答拖动。该回调只有 Windows 平台调用。
- 2026-10-11:Linux 标题栏按钮(核查 Windows 按钮问题时查出)。macOS 的红绿灯由系统画,不受影响;Linux 为客户端装饰,按钮由组件库画在标题栏的拖动区里,拖动区对任何按下都准备移动窗口,按住按钮时指针动一像素即开始移动窗口,合成器接管指针,松开到不了按钮,点击落空。组件库 3eb4d34:`drag_region` 只在按下未被里面的按钮认领(按钮以 `prevent_default` 认领)时才准备移动;`shell/title_tests.rs` 核对晃动的按下点得动 Linux 的关闭、标题栏空处照样移动窗口(去掉修复时前者失败)。未在真 Linux 桌面上点过。
- 2026-10-11(用户报:ElyTree 改动列表里文件名长时盖住行尾的打开按钮):组件库 bd01470:`ChangesList` 行里的文件名原为不缩的盒子,长名越过行宽压在按钮下;现可缩、以省略号结尾,先于按钮让位;`git/tests/narrow.rs` 核对 260 宽时文件名止于按钮之前(原布局时失败)。
@@ -302,7 +301,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-14:ElyMark 的菜单与快捷键(R4.9,MARK-15)。项与键取自 Typora 1.14.10 主进程字节码的字符串表(`.work/typora-main-menus.txt`)与其 `zh-Hans.lproj/Menu.json`;实际大小在字节码里有 Ctrl+Shift+9 与 Ctrl+Shift+0 两串,照用户所定用 Ctrl+Shift+0;R4.11 真窗口查出 Windows 的中文输入法把 Ctrl+Shift+0 留作热键(注册表 `HKCU\Control Panel\Input Method\Hot Keys\00000104`),应用收不到,Windows 另绑 Ctrl+Shift+9(菜单显示它)。表格是“段落”的子菜单(照 Typora),顶层不再有“表格”。原 Ctrl+1/2/3 切模式、Ctrl+=/-/0 字号、Ctrl+Shift+F 专注让给 Typora 的键;可视、源码、预览留在“视图”菜单,无键。Ctrl+/ 另绑在 `ElyEditor` 上(gpui 同深度后绑者胜),盖过代码编辑器的 `//` 注释切换(Markdown 没有这种注释);`MarkdownDocument` 的键上下文只在可视模式有。带 Shift 的标点与数字一律写所出的字符(`secondary-{`、`secondary-+`、`alt-%`):三端的 gpui 都把 Shift 加该键报成字符并去掉 Shift(Windows 见 `gpui-pre-windows` 的 `get_keystroke_key`);R4.9 原在 Windows 写键名,真键盘按不出(R4.11 真窗口查出)。Ctrl+Enter 在打开的块里原是输入框的 Submit(文档不处理),改绑为下方插入行。拒绝一律成信息提示:当场的由 `Desk::edit` 说,后台读出的经 `DocumentEvent::EditRefused`、`view::Refused` 到宿主。菜单打开时,打开的块把焦点借给菜单,块与选区都留着,菜单命令照样作用于所选(组件 6d90351)。复制为 Markdown 与粘贴为纯文本只作用于文档:焦点在文档外的输入框(查找框、对话框)时交给该框自己的复制、粘贴。表格子菜单末项为删除表格(组件 `TableEdit::Delete`,一步撤销,插入点不在表格里即拒绝;在打开的块里删除后焦点回到文档,撤销键照用,组件 6c2d28c)。Typora 有而组件库暂缺的不进菜单:行内公式、上下标、注释、提示块子菜单、任务状态、列表缩进 Ctrl+[/]、代码工具。
- 2026-10-14:ElyMark 的右键菜单(R4.10)。项、次序与显隐规则取自 Typora 1.14.10 的 `resources/window.html`(`#context-menu` 与其子菜单)与 `frame.js` 的显隐函数,记在 `.work/R4.10/typora-context-menu.txt`。菜单在 `view/menu.rs`,`MarkView` 自持(`ely_office::menu::Offer` 与 `ContextMenu::manual`):可视模式由组件的 `DocumentEvent::Pointed` 开在指针处,源码与预览由视图自己的右键开。剪切、复制、粘贴、删除所选范围为图标行,按下即把其键交回菜单打开前的焦点(`dispatch_keystroke`),与键盘同一路径;无选区时剪切、复制、删除灰显。其余项经各自的动作,显示所绑的键。组件 013f142、1b5fb07:文本框与代码编辑器右键落在选区里时保留选区,否则把插入点移到所点处;可视模式打开的块右键不写回、不关闭,目标在后台按全文读出(块外的引用定义照样解析);`MarkdownDocument::delete_image`(删除图片,一步撤销,无图片即 `EditRefusal::NoImage`);可视无块时 Ctrl+V 粘贴文字(原只收图片,文字被丢弃)。复制为 HTML 代码的图片照原地址(`Pictures::Written`,同 Typora),复制为富文本照旧内嵌。“编辑”菜单的复制项照 Typora 次序:纯文本、Markdown、HTML 代码、富文本。Typora 有而不做的:缩进与减少缩进行(可视只在打开的块里有)、复制表格、代码自动缩进、图片上传、缩放、移动与语法切换、拼写、表情复制、检查元素。gpui 的测试只发按下,菜单在 Enter 松开时选中,测试另发松开(`blocks/context.rs` 的 `choose`)。第 1 轮后:右键的目标按所点的字节读(组件 79e94f7),保留的选区只供剪贴板,删除图片删所点的那张;三种“复制为”在后台转换,界面线程只写剪贴板,新的复制取代未完成的旧复制(`Desk::copying`);`ely_office::menu::Offer` 只在首次绘制时把请求交给宿主(宿主按元素存的“已开”记录随标签切走而丢,切回会重开),ElyDoc、ElyTable、ElyShow 同受其益。第 2 轮后:转换完成时剪贴板已不是开始时的内容(此后有任何复制,含编辑器自己的 Ctrl+C)即丢弃这次结果,同内容的再次写入分不出;打开的块里只认最后一次右键(组件 2b8bd9f),所点字节对回所画字节的读法逐段只对齐一次(原逐字重读,8000 字的段落要近 2 秒)。第 3 轮后:剪贴板的比对另含其 HTML(gpui 读剪贴板不含 HTML,纯文字相同而富文本不同的后来复制原被盖掉;`ely_platform::clipboard::html`);组件的计时测试改用 `web_time::Instant`(组件 e07fd34)。
- 2026-10-14(用户报:整个软件中文显示问题很多):中文显示(R4.13)。Windows 上 gpui 以用户区域(本机为 en-US)排版全部文字,DirectWrite 按日文为汉字选回退字体,日文字体缺的简体字再落到另一字体,同一个词里字形与粗细不一(ElyTree 的“打开仓库”:“打开”粗、“仓库”细)。`vendor/gpui-pre-windows` 的 `text_locale`:用户区域不是中日韩时,改用 Windows 语言列表(WinRT `GlobalizationPreferences::Languages`,Win32 的界面语言只给 en-US)里第一个中日韩语言排版(日志 `text: laid out as …`);在 Windows 上打中文须把中文加进该列表,所以它就是用户读的汉字。只换排版的区域,字体族名与字体列表仍按用户区域。读不出语言列表即记警告、照用户区域。Ely 的界面语言是中文时(设置或 `ELY_LOCALE`),`ely_ui::i18n::install_as` 经 `gpui_windows::set_text_language` 让排版随它,不论 Windows 区域(日文区域下“打开仓库”原为 Yu Gothic UI 600 与 Microsoft JhengHei UI 700 两种字);语言下次启动生效,故启动时设一次。组件库 6df9ab0:Ely 用到的组件里写死的英文(下拉的“Choose…”、清除、关闭、移除、取消固定的提示,公式补全的“No suggestions”,设置里字号的重置提示与示例句)改走内建中英目录。a4bf696:查找框(计数、三个选项与上下一处的提示、替换与全部替换)、排序与筛选(添加规则、满足全部或任一、条件、升降序、添加排序)、密码框的显示与隐藏提示同此;筛选条件以目录键为值(`Test::key`);ElyMark 的删除图片按右键所点的字节(`delete_image(at)`)。仍为英文的是内核的文字(LibreOffice 界面语言固定英文:幻灯片占位“Click to add Title”、母版“Default”、配色名、数字格式代码“General”、函数名),以及启动失败对话框(出现在读入语言之前,中英并列)。
- 2026-10-14(用户定:dmg 不再显示未公证):macOS 发布以 Developer ID Application 证书签名、经 `notarytool` 公证并钉票。证书与公证凭据(`xcrun notarytool store-credentials`,App 专用密码只在钥匙串里)装在构建用的 Mac 上;发布流程的 macOS 任务从仓库变量 `ELY_SIGN_IDENTITY`(证书全名)与 `ELY_NOTARY_PROFILE`(凭据名)取得,`ci.sh` 在 macOS 上缺任一即失败,不出未公证的包。本机手动出包同样设这两个变量再跑 `packaging/macos/release.sh`。强化运行时下 LibreOffice 的 UNO 桥以 `MAP_JIT` 映射虚表,`ely-lok-worker` 须带 `com.apple.security.cs.allow-jit`(`packaging/macos/lok-worker.entitlements`,同 LibreOffice 自己的 `hardened_runtime.xcent`;其余几项不需要,实测);0.1.5 首发的三个 LOK 应用缺它,worker 在首个请求时中止(R6.5)。
- 2026-10-14(用户定:dmg 不再显示未公证):macOS 发布以 Developer ID Application 证书签名、经 `notarytool` 公证并钉票。证书与公证凭据(`xcrun notarytool store-credentials`,App 专用密码只在钥匙串里)装在构建用的 Mac 上;发布流程的 macOS 任务从仓库变量 `ELY_SIGN_IDENTITY`(证书全名)与 `ELY_NOTARY_PROFILE`(凭据名)取得,`ci.sh` 在 macOS 上缺任一即失败,不出未公证的包。本机手动出包同样设这两个变量再跑 `packaging/macos/release.sh`。2026-10-19 起:macOS 任务在 `macos-signing` 运行器上跑(Mac mini 上以 LaunchAgent 在登录会话里运行);`macos-arm64` 运行器是以 `UserName` 运行的 LaunchDaemon,其会话的钥匙串搜索列表里没有登录钥匙串,codesign 报“no identity found”(v0.1.8 实测)。两个变量此前未设,0.1.6 至 0.1.8 的 macOS 任务因此在 `ci.sh` 的检查处即失败,已在仓库变量里设好。强化运行时下 LibreOffice 的 UNO 桥以 `MAP_JIT` 映射虚表,`ely-lok-worker` 须带 `com.apple.security.cs.allow-jit`(`packaging/macos/lok-worker.entitlements`,同 LibreOffice 自己的 `hardened_runtime.xcent`;其余几项不需要,实测);0.1.5 首发的三个 LOK 应用缺它,worker 在首个请求时中止(R6.5)。
- 2026-10-15(用户报:ElyMark 点表格整个表格变成源码,应在格内编辑;用户定:格内显示该格原文):表格单元格编辑(R6.1)。组件库 2594b3f:可视模式按下表格只打开所按的那一格,在格内原位以单行输入框显示该格的 Markdown 原文(`**粗体**` 带记号),表格其余照常画出;按在格内文字上即落在所按的字,按在格内空白落在格末;以表格边缘、键盘或上下键从邻块进入的表格块打开其首格或末格;Tab、Shift+Tab 到下一格、上一格(跨行),上下键到上下一格,越出表格即到邻块;回车与 Esc 写回,写回为一步撤销;格内键入的 `|` 写回时转义为 `\|`(表格编辑读到的也是转义后的文字);格开着时做表格编辑(插删行列、整理等)先写回该格再执行,格随之关闭。列表、引用里的表格同此。表格绘制移入组件 `render/table.rs`,按格的空白处也带出所按的格(`Place::Cell`)。第 1 轮后(组件库 6a3f348):开着的格里的按下只给其输入框,插入点落在所按处、仍是一步撤销;格末单数的反斜杠写回时补成一对,不吞下一格的竖线;查找在可视模式里打开匹配所在的格并选中,跨格的匹配转到其行;格开着时右键所按的字节随格内键入的竖线换算;在未开的格的空白处右键,目标为该格末尾。第 2 轮后(组件库 2b6bd1d):格末的反斜杠只在紧接竖线时补成一对(`| a\ |` 的反斜杠后是空格,原样写回,只打开不键入不改一个字节);改首格后仍认得所在表格(无外侧竖线的表格首格始于块首,原被当作写掉了块),Tab 与按邻格照常;开着的格的空白处左键把插入点移到格末、右键以格末为目标,都不写回,仍是一步撤销。第 3 轮后(组件库 f1771b3,最后一轮,修完不再审查):无外侧竖线的行里清空首格或末格时写回一个竖线(`| | beta`、`alpha | |`),列数不变;插入点照写回的转义文字报出(键入竖线后不再落在多字节字中间);改过一格后右键邻格照常给出目标;长文(后台解析)里改格后 Tab 等到解析完成再打开下一格。
- 2026-10-15(用户报:二级菜单选不到,往子菜单移动时经过下面的项会弹出它们的子菜单,七个应用都如此;用户定照桌面系统的悬停意图做:安全三角加打开与关闭的延时):级联菜单(R6.2,组件库 d4fdb69,`ContextMenu`、`DropdownMenu`、`MenuBar` 共用的面板)。菜单面板挡住其下的元素(原先子菜单被挤回、盖在父菜单上时,被盖住的父菜单行照收悬停);子菜单右侧放不下且左侧放得下即开在所在行的左侧,从右滑入,不盖父菜单的行(两侧都放不下时贴窗口边,仍被面板挡住);指针停在有子菜单的行上 150 毫秒才打开;子菜单开着时,指针落在“上一位置与子菜单近侧上下两角”构成的三角形里的另一行,等 300 毫秒,指针仍在该行才切换,其间到达子菜单即作废;三角形外的行即刻标记。键盘与按下照旧即刻打开。实现在组件 `menus::aim`。第 1 轮后(组件库 5a53c08):指针离开一行(到边距、分隔线、禁用的行或菜单外)即作废其延时,回到该行重新计时;菜单在别处重开即忘掉旧的悬停;图标按钮行同样受三角形保护。
- 2026-10-15(用户报:ElyMark 的最近列表清不掉):侧栏“清除最近文件”(R6.3,98692cb)写空 `recent.json`(`ely_files::recent::clear`),失败即提示。会话恢复的标签不把文件加回列表(`Desk::remembered` 预先记下),清空在重启后照旧;显式打开照样加入,已开着的文件(按文件身份,含硬链接,`Sessions::is_open`)只切到该标签,也加入列表。
@@ -318,3 +317,12 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-16(Gitea #3):Windows 与 Linux 打印(R7.6)。`print_pdf` 改为异步:对话框在应用线程上,读页与出作业在自己的线程上(`print::off`),应用不卡。Windows:系统打印对话框(`PrintDlgEx`,Windows 11 显示为新式对话框),打开前把默认打印机的设置改成文档的纸张与方向(按打印机自己的纸型匹配(纸型须装得下页面,至多大 2 毫米,Linux 的 PWG 名同此);没有即自定义尺寸),设置须连同打印机名(`DEVNAMES`)一起交给对话框,否则对话框改用打印机自己的偏好(实测:Letter 文档印成 A4);所选页码范围照办,份数交驱动;勾“打印到文件”时文档输出为 `FILE:`,由打印子系统问文件名。驱动不支持所设纸张时会换成别的(实测“Microsoft Print to PDF”没有自定义尺寸,1000×600 点的页面换成 A4),所以出作业之前(`StartDoc` 之前)按打印机实际的纸张核对每一页:超出纸张半点以上(纸张尺寸至多舍入到整点,`print::ROUNDING`)、转过来也超出即报错,什么也不印(实测:600×842 点的页面原在 A4 上印出、右缘被裁)。页面由 Windows 自带的 PDF 渲染(`Windows.Data.Pdf`)按打印机分辨率分条画出,按原大从纸张的角上放(扣去不可打印的偏移),只在转过来才放得下时左转 90 度;渲染器的尺寸按 DIP 算、乘显示缩放,每次作业先试画一次量出倍数。`ELY_PRINT_TO` 时不弹对话框,经“Microsoft Print to PDF”写成文件(测试用)。`GlobalFree` 成功时返回空,windows-rs 读成错误码 0 的错误,须当作成功(否则打印报“操作成功完成”)。Linux:经 xdg 桌面门户的 `org.freedesktop.portal.Print`(`PreparePrint` 后带其令牌 `Print`,交出 PDF 的文件描述符),页面设置为文档纸张(标准纸型给 PWG 名,否则自定义毫米尺寸),边距为零,只许输出 PDF(`supported_output_file_formats`,否则对话框可选 PostScript,写出的却是 PDF);门户照原样交出所给的字节,对话框的选择由应用排定(`print/linux/sheets.rs`):GTK 设置里的页码范围(从零起)、奇偶页、倒序、份数与逐份、缩放、每张页数与排列,排到对话框所选纸张与方向上,每页为一个表单对象(带可打印批注的外观;带 NoRotate 的批注在转过的页面上绕其左上角转回、保持正立,同 MuPDF;外观的资源名避开页面已有的;可打印却没有外观的批注即报错,链接与弹出说明本无外观),单页按原大乘缩放从纸张左上角放,超出半点以上而转过来放得下即左转,否则报错;方向照 GTK 的写法(`reverse-portrait`,门户文档的页面设置同此);奇偶页只在 GTK 的文件与 lpr 后端时由 Ely 排(设置里有 `output-uri` 或 `lpr-commandline`),CUPS 后端自己把它交给 CUPS(`cups-page-set`,GTK 3.24.52 实测),Ely 再排一次即丢页;份数、每张页数等 GTK 留给应用,Ely 照排;裁剪框超出媒体框的部分不印(取两者的交);页码范围的开放终点 GTK 写作 -1(`1--1`),倒着的范围即报错;多页按格居中,再交内存文件(`memfd`);直接用 zbus:ashpd 0.13.13 的 `PageSetup`、`Settings` 缺任一字段即解析失败,真门户的回复常不带 `PPDName`。页面尺寸由 lopdf 读(CropBox,否则 MediaBox,继承、`/Rotate` 与页面自己的 `/UserUnit`)。打印不再按平台禁用(原 `print::BUILT` 与“此平台尚未构建”删去)。替身门户的测试核对选页、缩放、倒序与份数后交出的页面与位置,与只许 PDF。ElyPDF 的打印副本(`ely_print_copy`)按打印的规则画:可打印(不隐藏)的批注与表单控件,外观画进页面内容(位置照 MuPDF 自己的 `pdf_annot_transform`,与屏上一致;无外观的先由 MuPDF 生成,MuPDF 原只在显示时放在本地表里、不存入文件),其余批注与表单去掉,只留链接;系统的渲染器按屏幕规则画(不打印的照画,“打印但不显示”的不画,实测 Windows),画进内容后各渲染器都只见要打印的;仍画不出的即报错;`tests/output.rs` 渲染副本核对。真窗口(Windows):ElyDoc 的 Ctrl+P、Ely 的打印对话框、系统对话框、“Microsoft Print to PDF”的另存为,印出的 PDF 为文档的 Letter;系统对话框取消时不打印、不报错(`.work/r76/print.ps1`)。未验证:Linux 真桌面上的门户(只对替身门户核对);实体打印机。
- 2026-10-16(用户定):Windows 资源管理器的右键“新建”可建 DOCX、XLSX、PPTX 与 Markdown 文件(R7.9)。空白文件在 `packaging/windows/new`(`templates.sh` 以本构建的内核把空文件转存而得,无作者与日期;Markdown 为空文件),随所属的应用装在安装目录(列在 `.ely-installed` 里),`HKCU\…\.<扩展名>\ShellNew` 的 `FileName` 指向它。资源管理器只在扩展名有默认 ProgID 时列出新建项(实测:只有 `ShellNew`、或只在 `OpenWithProgids` 下时都不列);所以只在该类型没有任何程序处理时(HKCR 下无默认值,或默认值所指的类不存在:本机 Windows 11 的 `.md` 指向不存在的 `Markdown Document`)把默认值设为本应用的 ProgID(写在 HKCU,用户自己的 `UserChoice` 不动),已有的从不改动(装着 Office、WPS 时新建菜单用它们自己的项);没有处理程序的这四种类型从此由 Ely 打开。卸载只删仍是本安装写下的 `ShellNew` 与默认值。ProgID 的名称为“<应用> Document”(新建菜单与类型列显示它)。`tests/scripts/types.sh` 核对四种都有新建项,默认值只在判空之后写。
- 2026-10-16(用户定):ElyMark 打开的块保持原样(R7.5)。组件库 bff7ebc 起,可视模式里按下的块以原文显示在它合上时的框里(同样的边距与透明边框),字号、行高与颜色同合上时:段落、列表、引用等为正文,标题按其级数的字号、1.3 倍行高、加粗(六级为次要色),照原文写出的块(链接定义等)为等宽次要色;不再加深色底板与另一套边距,按下前后不挪位置。代码块、块公式与 front matter 仍有自己的底板(代码与 front matter 为等宽小号,front matter 为次要色,同合上时)。输入框的着色(源码模式同用)给标题与粗体同一字重(原为半粗,压过所设的加粗),六级标题里的字照为次要色(行内代码照为正文色,2e65b05),front matter 照 front matter 读(原读成分隔线加标题,键名变亮变粗)(1ff993d、962964e)。表格单元格照旧。打字机模式的居中另按两端余量判定(原只按块高与视图高,打开前后同高时首块不再居中)。同批带入组件库 cdc5ce8(标题栏拖动区里的控件按下归控件,Windows)。Windows 真窗口核对(`.work/r75`):段落、三级标题、代码块打开前后位置相同。
- 2026-10-18:Word 公式(R8.2,Gitea #9)。附件 math.docx 的六类公式逐类核对(本机无 Word,以文件所写为准):键盘输入、OMML(内核转为公式对象)、MathType(OLE,显示随带的 WMF 预览)、上下标、图片原本就对;差别在两处根因。其一,运行的 `w:position`(升降)原先只给文字,图片与 OLE 公式所在的运行失去它(25 张图片与一个 ½ 浮在行上):补丁 `0049-writer-inline-objects-move-with-their-run.patch` 把它作为固定升降写到对象的锚字符上(按锚字符自己的字号换算百分比,不扩展到其后的文字),版面让“作为字符”的对象随锚字符的固定升降移动(vertAlign 的自动上下标不动对象,同原样);各导出本就写出锚字符的升降,DOCX 往返仍为 `w:position`。其二,EQ 域原先只认 `\o`、`\s\do` 与注音,其余(如分数 `\f`)整个消失:补丁 `0050-writer-eq-field-parser.patch`(读法)与 `0051-writer-eq-fields-as-formulas.patch`(导入)把 EQ 域转为公式对象:`\f` 分数、`\r` 根式、`\s` 上下标、`\b` 括号、`\l` 列表、`\a` 阵列、`\i` 积分与求和求积;`\x` 与 `\o` 只留内容(无框、无叠印),`\d` 只前移,`\ai`、`\di` 只是行距(不升降),开关不分大小写;每个命令字符保留其运行的上下标(Word 照此画),同一段上下标里字样变了仍是一个上下标;斜体字母写成标识符(StarMath 与 OMML 都画成斜体,DOCX 往返不丢),斜体的数字与符号写成 `ital` 文字,存为 DOCX 后成正体(OMML 无此写法,已知限制);没有底的上下标以空文字为底(空的底在 OMML 里是占位符,重开成 `<?>`);公式取域的字号;读不通的命令以其域代码显示为文字,不消失。只有整个域是 `\s\do N(文字)` 时照原样成为下移的文字,其余都经读法(原先小写的 `\s\do` 读到第一个括号即止,其后丢失)。`client/tests/word.rs` 以 `tests/fixtures/office/word.docx`(由 `.work/r8/gen/eq.py` 生成,不入库)核对公式文本(含开头的空格、大写开关、读不通的域、字样变化的下标、`\ai`、`\di`、小写 `\s\do` 的尾部与嵌套)、DOCX 往返后的斜体、下标与无占位符、锚字符的升降与图块里图片低于文字(去掉版面的升降即失败)。审查三轮:第 3 轮的四处已修并以上述测试核对,未再审查(每条至多三轮)。仍与 Word 不同:编辑区里公式与图片四周有内核的辅助框线(`ShowBoundaries`,视图辅助,不印出);DOCX 另存后重开行距变宽,因内核导出改写文档默认字体与段距,与公式无关。
- 2026-10-18:工作表过多时标签条(R8.3,Gitea #8)。组件库 `navigation::Tabs` 横排时可横向滚动(滚轮同样),标签可窄于自然宽度;所选标签在所选或标签条宽度变了时滚入视口,在绘制时以刚排好的宽度判定(原先只在首帧滚一次,其后右栏打开、标签条变窄,所选标签被挤出视口);手动滚动保留到二者之一再变;横排的标签至多与标签条同宽,名字过长以省略号结尾(组件库 e56a343、3a68d1b、7d704d9,同批带入上游列外壳拖动的四个提交)。ElyTable 的标签条随之收缩,添加按钮留在其旁;标签条的状态按工作簿各记一份(元素 id 带标签号),切到另一工作簿时其所选标签照样滚入。`navigation/tests/tabs.rs` 以 30 个标签核对:首帧滚到所选、滚轮回到第一个、新选的滚入、变窄后所选仍在末端(去掉按宽度判定即失败)、长名的所选标签整个在标签条内(去掉宽度上限即失败);ElyTable 的 `tests/strip.rs` 以 `sheets::strip` 画两个各有 30 张表、都选第 30 张的工作簿,滚回开头后来回切换,所选标签每次都在标签条内(两个工作簿共用一个 id 即失败)。Windows 真窗口(`.work/r8/strip3.png`):30 张表、第 30 张为当前,1100 宽时标签条末端为它。
- 2026-10-18:ElyMark 公式(R8.4,用户定两项都做)。行内公式照块公式由 RaTeX 排版(原只显示着色的 TeX 原文):gpui 的文本里放不进图片,组件库 `render/math_text.rs` 把每个行内公式换成等宽的不断行空格(U+00A0,按字体量出个数),排好的公式居中画在其上,行高随最高的公式撑开;空格串与词一样整个折到下一行,画在其所在行(折行处的序号 gpui 读作上一行末);按下与右键所在的字节经换算读回公式的原文;排不出的公式照旧显示 TeX 原文,其后以危险色写出 RaTeX 给的原因与位置,记警告(不含公式)。`\(…\)` 读作行内公式,`\[…\]` 读作块公式(ChatGPT 等常写的分隔符;GitHub 不认,Typora 默认不认):解析前逐字节把成对的分隔符换成 `$`(长度不变,位置照旧对得上)。成对只在同一段落、标题或单元格里(跨列表项、CR 空行不成对);不跨代码、公式、HTML 标签、链接地址与引用标签、图片、脚注引用,`<code>`、`<kbd>` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 TeX 原文。独占一段的 `\[…\]` 为块公式(段首可有一至三个空格)。组件库公开 `documents::markdown_tree`,ElyMark 的 HTML、PDF、纯文本导出都经它解析(`markdown_options` 不再公开),纯文本里独占一段的公式照块公式写其 TeX。同时查出(组件库 e095f06):RaTeX 的 SVG 以 pt 定尺寸,usvg 按 px 量墨迹,两者差 4/3,每个公式的画面右侧与下方多出约四分之一的空白(块公式因此偏左)、导出的公式比正文大 4/3;现生成时即把根改为 px。`markdown/tex/tests.rs` 与 `tree/tests.rs` 核对分隔符的各种情形(相邻、`\$`、链接地址、`<code>`、跨块),`math_text.rs` 的窗口测试核对公式排进并读回原文、折到第二行的公式画在第二行首(改回按起点定位即失败)、排不出的公式写出原因(组件库 a20e712、5fba7b3、de961eb),`math/tests.rs` 核对画面与公式同宽(改回 pt 即失败),ElyMark 的 `visual.rs` 核对导出与纯文本。Windows 真窗口(`.work/r8/mark/visual2.png`):`$…$`、`\(…\)` 排在行内,`\[…\]` 居中成块,行内代码与转义的 `\\(` 照原文。
- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点;正文有 8 位字节即带 `BODY=8BITMIME`,服务器不支持即拒绝。读信按 MIME 树与所在容器(C12.2),嵌套(多部分与内嵌邮件都算)深于 64 层即报错,超深的解析结果以迭代拆开再丢(直接丢弃会递归、栈溢出);mail-parser 开 `full_encoding`;SMTPUTF8 按每一层的头块判定。本地存储(C12.3):rusqlite 自带的 SQLite,`mail.db` 加内容目录(原文按 SHA-256),FTS5(trigram)可由索引与原文重建;`user_version` 记结构版本,升级前备份,失败即只读;原文文件在删去或替换邮件的事务提交前删去,删不掉即回滚。只有 HTML 的邮件经 html5ever 的树构建器取文字入索引,元素深过 512 层即停读(树构建器的开放元素栈扫描随深度平方增长)。凭据(C12.4):密码与刷新令牌只进系统凭据库(keyring),被拒即只留在内存到退出;OAuth 换令牌与刷新经 ureq,回跳在 127.0.0.1 随机端口;Google 授权带 `access_type=offline` 与 `prompt=consent`(每次同意都发刷新令牌),Microsoft 另要 `profile`(无 `email` 时以 `preferred_username` 为地址),换码没有刷新令牌即报错;客户端 ID 由 `crates/ely-mail-engine/build.rs` 取环境变量或 `oauth.env`,该文件不存在即未配置,读不出或写错即构建失败。ureq 在 trace 级逐字节记下请求与回复(令牌、客户端密钥),`ely_platform::logging` 在任何 `ELY_LOG` 下都不记 `ureq`、`ureq_proto` 低于 info 的日志。账号(C12.5):`ely_mail_engine::Account` 存在 accounts 表的 settings 列(JSON),凭据库名 `account:地址`;删账号先删凭据再删本地数据,本地删除失败即放回凭据,凭据库删不掉时内存里的凭据照留,不动服务端;地址在库里唯一,不分 ASCII 大小写(`Error::Taken`)。应用的库读写在后台、锁后进行,网络任务在应用自带的两线程 tokio 运行时上,结果交回 gpui。iCloud 收发都以完整地址为用户名。删去原文先改名为 `.散列.gone` 留在原处,事务提交后才删,失败即改回;打开库时按库里是否仍有邮件指向它放回或删去(C12.5 第 1 轮后)。对话框的请求(检查、登录、保存)在整个窗口里编号,回来时只作用于仍在等它的那个对话框;保存的结果照样更新账号列表,失败而对话框已换即以提示写明。端口记下上次自动填入的值,与之不同的即为键入,换安全方式不覆盖。IMAP 同步(C12.6):文件夹按 LIST 存为邮箱(角色按 RFC 6154,INBOX 按名、库里不分大小写;有 LIST-EXTENDED 与 SPECIAL-USE 即以 `RETURN (SPECIAL-USE)` 取角色),不再列出的连同邮件删去;EXAMINE 缺 UIDVALIDITY 或 EXISTS 即报错;UIDVALIDITY 变了即清空重建;同一 UID 的 FETCH 回复合并;邮件头全取,正文只取 90 天内(`sync::BODY_DAYS`):先读 BODYSTRUCTURE,无附件即取整封,有附件只取头与正文部分、按原结构重组,附件内容为空、部分号记在 `missing`,附件按需(C12.17),处置为 attachment 的多部分整个按需,related 里不看处置(RFC 2387);新列由结构 v2 升级加上,UIDVALIDITY 不同于所记时只删别的 ULine truncated
- 2026-10-19(用户定):代码与发布从 Gitea 搬到 GitHub 组织 ELY-Open-Source(`ELY-Open-Source/ElyOffice`;组件库 `ELY-Open-Source/Ely-GPUI-Components`,Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变;Kigi CLI 同在此组织)。历史、标签与发布已迁过,Gitea 停用,GitHub 是唯一的家。发布由 `.github/workflows/release.yml` 在推送 `v*` 标签时运行(只此一种触发,拉取请求到不了自托管机器):macOS 在 `[self-hosted, macos-signing]`(Mac mini 的图形会话,Developer ID 签名并公证),Windows 与 Linux(WSL)在 `[self-hosted, ely-windows]`;各机的常驻克隆 `$HOME/ely-ci/ElyOffice` 从 GitHub 拉取。`scripts/ci.sh` 经 GitHub API 按标签取发布、没有就建(`GITHUB_TOKEN`,`contents: write`),同名文件先删再传:重跑只换本平台的文件。更新检查读 `api.github.com/repos/ELY-Open-Source/ElyOffice/releases/latest` 的 `<slug>.json`,清单的下载地址为 `github.com/ELY-Open-Source/ElyOffice/releases/download/v<版本>/<文件>`;0.1.8 及以前装好的应用仍查 Gitea,需手动装一次新版。官网 worker 读 GitHub API(可选机密 `GITHUB_TOKEN` 避开共享出口地址的限流),下载与源码链接指向 GitHub(GitHub 标志);macOS 版已公证,去掉“未经公证”的提示。发布视频的源文件与成片里仍是 Gitea 地址,未改未重渲染。
- 2026-10-19(用户定):自有代码由 AGPL-3.0-or-later 改为 MIT(`LICENSE`,Copyright (c) 2026 Yang Zhang;工作区 `Cargo.toml`、Linux 包的许可字段、`docs/licenses.md`、官网)。第三方照旧:LibreOffice 与 `patches/libreoffice` 为 MPL-2.0 等,Git 为 GPL-2.0,MuPDF 为 AGPL-3.0;ElyPDF 链接 MuPDF,其发行包整体仍须按 AGPL 履行,Linux 包的许可字段据此写为 `MIT AND <引擎许可>`。
- 2026-10-19(GitHub #2,R9.1):Impress 在母版视图里 `getParts` 数母版、`getPartName` 给母版名,`getPartInfo` 却读同一序号的幻灯片,母版多于幻灯片时信息里没有 `visible`,ElyShow 每次重读部件都报错;补丁 `0052-lok-impress-master-view-part-info.patch` 让母版视图里取母版页(`client/tests/show.rs` 核对)。`ELY_SHARE` 下发布流程不重建内核:新补丁须先在三台机器的共享引擎上各自重建。GitHub PR #1(R9.2,用户定合并):选区画在图块之上,不透明的选区色盖住文字,`LokView` 画选区时不透明度至多 0.3。
- 2026-10-20(GitHub #4,R9.3):整列看不出标题时 Calc 开自动筛选会弹异步询问,worker 当场关掉即在其回调里崩溃;补丁 `0053-lok-calc-autofilter-header-without-asking.patch`:LOK 下不问,首行作标题(同 Excel)。R9.4:arboard 在 Windows 把剪贴板上没有 HTML 读成错误,`ely_platform::clipboard` 先查格式;剪贴板错误的提示写出整条原因。补丁 0049 补上 `doc.hxx`(Linux 的 gcc 编不过;MSVC 与 clang 经别的头文件带入)。Gitea 上另发同名的过渡发布(用户定):0.1.8 及更早的应用在 Gitea 查更新,过渡发布的清单指向 GitHub 的下载,装上的 0.1.9 起从 GitHub 查;Gitea 只为此推上 main 与标签,不再是发布之处。
- 2026-10-20(GitHub PR #6,R9.5,用户定合并):ElyTable 的函数候选由 Tab、不带修饰的 Enter 或单击接受(原 Enter 提交半个公式并下移);接受后到对应输入与其修订的事件批次到达前不再提供候选,不能重复接受(`view/formula/completion.rs`);单击按所点的函数名接受。接受与否由 worker 在执行时按内核此刻的候选判定(`worker/complete.rs`,同换页的 `TURN`):界面上回报的候选可能已被其后排队的键入作废;所选的仍在即取它,按键时不在即取菜单首项(名最短者,同菜单的次序),没有候选即照常交按键,单击的已不在即什么也不做。有效性下拉按钮挡鼠标的框至少为按钮的主题高度(默认行比按钮矮)。候选、参数提示、有效性下拉按钮与输入说明挡住滚轮以外的鼠标,按下不落到其下的格;填充与行列拖动在其上松开即取消。PR 的 ElyTable 测试原设空的错误样式,补丁 0017 拒绝该规则,改为 `stop`;空的单格读不出选区文字,改读 E2:F2。
+12
View File
@@ -11,8 +11,20 @@ build = "../../packaging/windows/icon.rs"
embed-resource.workspace = true
[dependencies]
ely-gpui-component.workspace = true
ely-mail-engine.workspace = true
ely-platform.workspace = true
ely-ui.workspace = true
gpui.workspace = true
jiff.workspace = true
log.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread"] }
[dev-dependencies]
ely-ui = { workspace = true, features = ["test-support"] }
ely-gpui-component = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
tempfile.workspace = true
[lints]
workspace = true
+285
View File
@@ -0,0 +1,285 @@
//! The add-account dialog (MAIL-01, 02).
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::DescriptionList,
feedback::{Banner, InlineMessage},
forms::{Input, PasswordInput, Switch},
overlays::Dialog,
primitives::Severity,
};
use ely_mail_engine::{Auth, Error, Security};
use gpui::{
AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Styled as _,
WeakEntity, Window, div,
};
use crate::{
adding::{Adding, Step},
kinds::{APPLE_ACCOUNT, KINDS, Kind},
mail::{Mail, text},
view::auth,
};
type Edit = fn(&mut Adding, &str, &mut App);
/// A segmented choice whose change edits the dialog.
fn choice(
this: &WeakEntity<Mail>,
id: &'static str,
picked: &str,
options: impl IntoIterator<Item = (&'static str, SharedString)>,
edit: Edit,
) -> SegmentedControl {
let this = this.clone();
options
.into_iter()
.fold(
SegmentedControl::new(id, picked.to_owned()),
|control, (key, label)| control.segment(key, label, None),
)
.on_change(move |key, _, cx| {
this.update(cx, |mail, cx| {
if let Some(adding) = &mut mail.adding {
edit(adding, key, cx);
cx.notify();
}
})
.ok();
})
}
fn security_key(security: Security) -> &'static str {
match security {
Security::Tls => "tls",
Security::StartTls => "starttls",
}
}
fn security_of(key: &str) -> Security {
match key {
"tls" => Security::Tls,
_ => Security::StartTls,
}
}
fn auth_key(auth: Auth) -> &'static str {
match auth {
Auth::Password => "password",
Auth::Google => "google",
Auth::Microsoft => "microsoft",
}
}
impl Mail {
pub fn add_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let adding = self.adding.as_ref()?;
let this = cx.weak_entity();
let dismiss = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |mail, cx| mail.close_adding(cx)).ok();
}
};
let kinds = KINDS.map(|kind| {
(
kind.key(),
text(cx, &format!("mail.kind.{}", kind.key()), &[]),
)
});
let kinds = choice(
&this,
"mail-add-kind",
adding.kind.key(),
kinds,
|adding, key, cx| {
if let Some(kind) = Kind::from_key(key) {
adding.set_kind(kind, cx);
}
},
);
let oauth = adding.auth() != Auth::Password;
let unavailable = match adding.auth().provider() {
Some(Err(Error::Unavailable(name))) => {
Some(text(cx, &format!("mail.add.unavailable.{name}"), &[]))
}
_ => None,
};
let cancel = text(cx, "mail.cancel", &[]);
let (go_key, go): (&str, fn(&mut Mail, &mut Context<Mail>)) = match &adding.step {
Step::Checked { checked, .. } if checked.passed() => ("mail.add.save", Mail::save),
_ if oauth => ("mail.add.sign_in", Mail::sign_in),
_ => ("mail.add.check", Mail::check),
};
let go_label = text(cx, go_key, &[("method", &auth(adding.auth(), cx))]);
let busy = adding.busy();
let blocked = busy || (unavailable.is_some() && oauth);
let dialog = Dialog::new("mail-add", text(cx, "mail.add.title", &[]), dismiss)
.child(kinds)
.children(
unavailable
.map(|words| Banner::new("mail-add-unavailable", Severity::Danger, words)),
)
.child(self.add_fields(adding, &this, cx))
.children(status(adding, cx))
.action(move |close| {
Button::new("mail-add-cancel", cancel)
.on_click(move |_, window, cx| close(window, cx))
})
.action(move |_| {
Button::new("mail-add-go", go_label)
.variant(ButtonVariant::Primary)
.loading(busy)
.disabled(blocked)
.on_click(move |_, _, cx| {
this.update(cx, go).ok();
})
});
Some(dialog.into_any_element())
}
fn add_fields(&self, adding: &Adding, this: &WeakEntity<Mail>, cx: &App) -> AnyElement {
let label = |key: &str| text(cx, key, &[]);
let field = |input| div().w_full().child(Input::new(input));
let fields = &adding.fields;
let password = div().w_full().child(PasswordInput::new(&fields.password));
match adding.kind {
Kind::Gmail | Kind::Microsoft => DescriptionList::new()
.item(label("mail.add.address_hint"), field(&fields.address))
.into_any_element(),
Kind::ICloud => {
let open = Button::new("mail-add-apple", label("mail.icloud.open"))
.on_click(|_, _, cx| cx.open_url(APPLE_ACCOUNT));
let rows = DescriptionList::new()
.item(label("mail.add.address"), field(&fields.address))
.item(label("mail.icloud.password"), password);
div()
.flex()
.flex_col()
.gap_3()
.child(label("mail.icloud.steps"))
.child(div().child(open))
.child(rows)
.into_any_element()
}
Kind::Imap | Kind::Pop => {
let auths = [Auth::Password, Auth::Google, Auth::Microsoft]
.map(|each| (auth_key(each), auth(each, cx)));
let methods = choice(
this,
"mail-add-auth",
auth_key(adding.auth),
auths,
|adding, key, _| {
adding.stop();
adding.step = Step::Editing;
adding.auth = match key {
"google" => Auth::Google,
"microsoft" => Auth::Microsoft,
_ => Auth::Password,
};
},
);
let securities = |outgoing: bool, picked: Security| {
let options = [Security::Tls, Security::StartTls].map(|each| {
(
security_key(each),
label(&format!("mail.security.{}", security_key(each))),
)
});
let id = if outgoing {
"mail-add-out-security"
} else {
"mail-add-in-security"
};
let edit: Edit = if outgoing {
|adding, key, cx| adding.set_security(true, security_of(key), cx)
} else {
|adding, key, cx| adding.set_security(false, security_of(key), cx)
};
choice(this, id, security_key(picked), options, edit)
};
let mut rows = DescriptionList::new()
.item(label("mail.add.address"), field(&fields.address))
.item(label("mail.auth.title"), methods);
if adding.auth == Auth::Password {
rows = rows
.item(label("mail.add.user"), field(&fields.user))
.item(label("mail.add.password"), password);
}
if adding.kind == Kind::Pop {
let this = this.clone();
let keep = Switch::new("mail-add-keep", adding.keep)
.name(label("mail.add.keep"))
.on_change(move |on, _, cx| {
this.update(cx, |mail, cx| {
if let Some(adding) = &mut mail.adding {
adding.set_keep(on);
cx.notify();
}
})
.ok();
});
rows = rows.item(label("mail.add.keep"), keep);
}
rows.item(label("mail.add.incoming"), field(&fields.incoming))
.item(label("mail.add.port"), field(&fields.incoming_port))
.item(
label("mail.add.security"),
securities(false, adding.incoming_security),
)
.item(label("mail.add.outgoing"), field(&fields.outgoing))
.item(label("mail.add.port"), field(&fields.outgoing_port))
.item(
label("mail.add.security"),
securities(true, adding.outgoing_security),
)
.into_any_element()
}
}
}
}
/// Each server's answer, or what is under way.
fn status(adding: &Adding, cx: &App) -> Option<AnyElement> {
let line =
|severity, words: SharedString| InlineMessage::new(severity, words).into_any_element();
let said = |key: &str, result: &ely_mail_engine::Result<()>| match result {
Ok(()) => line(
Severity::Success,
text(cx, key, &[("result", &text(cx, "mail.check.passed", &[]))]),
),
Err(error) => line(
Severity::Danger,
text(cx, key, &[("result", &failure(error, cx))]),
),
};
Some(match &adding.step {
Step::Editing => return None,
Step::Signing => line(Severity::Info, text(cx, "mail.add.signing", &[])),
Step::Checking => line(Severity::Info, text(cx, "mail.add.checking", &[])),
Step::Saving => line(Severity::Info, text(cx, "mail.add.saving", &[])),
Step::Failed(words) => line(Severity::Danger, words.clone()),
Step::Checked { checked, .. } => div()
.flex()
.flex_col()
.gap_1()
.child(said("mail.check.incoming", &checked.incoming))
.child(said("mail.check.outgoing", &checked.outgoing))
.into_any_element(),
})
}
/// A server's refusal in the reader's language, its words kept.
pub fn failure(error: &Error, cx: &App) -> SharedString {
let (key, detail) = match error {
Error::Connect(err) => ("mail.error.connect", err.to_string()),
Error::Tls(detail) => ("mail.error.tls_failed", detail.clone()),
Error::Rejected(detail) => ("mail.error.rejected", detail.clone()),
Error::Server(detail) => ("mail.error.server", detail.clone()),
Error::Protocol(detail) => ("mail.error.protocol", detail.clone()),
Error::Timeout(step) => ("mail.error.timeout", (*step).to_owned()),
other => ("mail.error.other", other.to_string()),
};
text(cx, key, &[("detail", &detail)])
}
+262
View File
@@ -0,0 +1,262 @@
//! The add-account dialog's fields and steps (MAIL-01, 02).
use ely_gpui_component::forms::{InputEvent, TextInput};
use ely_mail_engine::{Account, Auth, Checked, Security, Server};
use gpui::{App, AppContext as _, Context, Entity, SharedString, Subscription, Window};
use tokio::task::AbortHandle;
use crate::{
kinds::{self, Kind},
mail::{Mail, text},
};
pub struct Fields {
pub address: Entity<TextInput>,
pub user: Entity<TextInput>,
pub password: Entity<TextInput>,
pub incoming: Entity<TextInput>,
pub incoming_port: Entity<TextInput>,
pub outgoing: Entity<TextInput>,
pub outgoing_port: Entity<TextInput>,
}
pub enum Step {
Editing,
/// The browser holds the provider's page.
Signing,
Checking,
/// `kept` goes to the vault: a password or refresh token.
Checked {
account: Account,
kept: String,
checked: Checked,
},
Failed(SharedString),
Saving,
}
pub struct Adding {
pub kind: Kind,
pub fields: Fields,
pub incoming_security: Security,
pub outgoing_security: Security,
/// Typed-in accounts choose; providers fix it.
pub auth: Auth,
/// POP3 leaves a copy on the server (§12.3).
pub keep: bool,
pub step: Step,
/// The ports last filled in; any other was typed.
filled: [String; 2],
/// The request awaited; any other answer is dropped.
pub asked: Option<u64>,
pub task: Option<AbortHandle>,
_edits: Vec<Subscription>,
}
impl Drop for Adding {
fn drop(&mut self) {
self.stop();
}
}
impl Adding {
pub fn new(kind: Kind, window: &mut Window, cx: &mut Context<Mail>) -> Self {
let mut field = |key: &str, masked: bool, cx: &mut Context<Mail>| {
let words = text(cx, key, &[]);
cx.new(|cx| {
let input = TextInput::new(window, cx)
.placeholder(words.clone())
.label(words);
if masked { input.masked() } else { input }
})
};
let address = field("mail.add.address", false, cx);
let user = field("mail.add.user", false, cx);
let password = field("mail.add.password", true, cx);
let incoming = field("mail.add.incoming", false, cx);
let incoming_port = field("mail.add.port", false, cx);
let outgoing = field("mail.add.outgoing", false, cx);
let outgoing_port = field("mail.add.port", false, cx);
let fields = Fields {
address,
user,
password,
incoming,
incoming_port,
outgoing,
outgoing_port,
};
let edits = [
&fields.address,
&fields.user,
&fields.password,
&fields.incoming,
&fields.incoming_port,
&fields.outgoing,
&fields.outgoing_port,
]
.into_iter()
.map(|field| {
cx.subscribe(field, |mail: &mut Mail, _, event: &InputEvent, cx| {
if *event == InputEvent::Changed {
mail.edited(cx);
}
})
})
.collect();
let mut adding = Adding {
kind,
fields,
incoming_security: Security::Tls,
outgoing_security: Security::StartTls,
auth: Auth::Password,
keep: true,
step: Step::Editing,
filled: Default::default(),
asked: None,
task: None,
_edits: edits,
};
adding.set_ports(cx);
adding
}
pub fn stop(&mut self) {
if let Some(task) = self.task.take() {
task.abort();
}
self.asked = None;
}
/// A new kind keeps what was typed.
pub fn set_kind(&mut self, kind: Kind, cx: &mut App) {
self.stop();
self.kind = kind;
self.step = Step::Editing;
self.set_ports(cx);
}
pub fn set_security(&mut self, outgoing: bool, security: Security, cx: &mut App) {
self.stop();
self.step = Step::Editing;
if outgoing {
self.outgoing_security = security;
} else {
self.incoming_security = security;
}
self.set_ports(cx);
}
pub fn set_keep(&mut self, keep: bool) {
self.stop();
self.step = Step::Editing;
self.keep = keep;
}
/// The usual ports, unless one was typed.
fn set_ports(&mut self, cx: &mut App) {
let ports = [
kinds::port(Some(self.kind.protocol(self.keep)), self.incoming_security),
kinds::port(None, self.outgoing_security),
];
let fields = [&self.fields.incoming_port, &self.fields.outgoing_port];
for ((field, port), filled) in fields.into_iter().zip(ports).zip(&mut self.filled) {
let typed = field.read(cx).text().trim().to_owned();
if typed.is_empty() || typed == *filled {
*filled = port.to_string();
field.update(cx, |field, cx| field.set_text(filled.clone(), cx));
}
}
}
/// The sign-in this kind uses.
pub fn auth(&self) -> Auth {
match self.kind.known() {
Some((auth, ..)) => auth,
None => self.auth,
}
}
fn read(&self, field: &Entity<TextInput>, cx: &App) -> String {
field.read(cx).text().trim().to_owned()
}
/// The servers as known or typed.
pub fn servers(&self, cx: &App) -> Result<(Server, Server), SharedString> {
if let Some((_, incoming, outgoing)) = self.kind.known() {
return Ok((incoming, outgoing));
}
let server = |host: &Entity<TextInput>, port: &Entity<TextInput>, security| {
let host = self.read(host, cx);
if host.is_empty() {
return Err(text(cx, "mail.add.need_host", &[]));
}
let port = self.read(port, cx);
let port = port
.parse()
.ok()
.filter(|port| *port > 0)
.ok_or_else(|| text(cx, "mail.add.bad_port", &[("port", &port)]))?;
Ok(Server {
host,
port,
security,
})
};
Ok((
server(
&self.fields.incoming,
&self.fields.incoming_port,
self.incoming_security,
)?,
server(
&self.fields.outgoing,
&self.fields.outgoing_port,
self.outgoing_security,
)?,
))
}
/// The account the fields name; `signed` is an OAuth address.
pub fn account(&self, signed: Option<&str>, cx: &App) -> Result<Account, SharedString> {
let address = match signed {
Some(address) => address.to_owned(),
None => self.read(&self.fields.address, cx),
};
if address.is_empty() {
return Err(text(cx, "mail.add.need_address", &[]));
}
let user = match (signed, self.kind) {
(None, Kind::Imap | Kind::Pop) => self.read(&self.fields.user, cx),
_ => address.clone(),
};
if user.is_empty() {
return Err(text(cx, "mail.add.need_user", &[]));
}
let (incoming, outgoing) = self.servers(cx)?;
let account = Account {
address,
user,
auth: self.auth(),
protocol: self.kind.protocol(self.keep),
incoming,
outgoing,
};
account
.valid()
.map_err(|err| text(cx, "mail.add.invalid", &[("error", &err.to_string())]))?;
Ok(account)
}
pub fn password(&self, cx: &App) -> Result<String, SharedString> {
let password = self.fields.password.read(cx).text().to_owned();
if password.is_empty() {
return Err(text(cx, "mail.add.need_password", &[]));
}
Ok(password)
}
pub fn busy(&self) -> bool {
matches!(self.step, Step::Signing | Step::Checking | Step::Saving)
}
}
+204
View File
@@ -0,0 +1,204 @@
//! What the window has open, and its list.
use std::{collections::HashMap, rc::Rc};
use ely_gpui_component::mail::Mail as Row;
use ely_mail_engine::{AccountId, Filter, MailboxId, Role, Scope, Summary};
use gpui::{Context, SharedString};
use crate::mail::{Mail, text};
pub type Unread = HashMap<MailboxId, u32>;
/// What the middle of the window shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Open {
Inboxes,
Mailbox(AccountId, MailboxId),
/// The account's settings and sync.
Account(AccountId),
}
impl Open {
pub fn key(self) -> SharedString {
match self {
Open::Inboxes => "inboxes".into(),
Open::Mailbox(_, mailbox) => format!("box-{}", mailbox.0).into(),
Open::Account(id) => format!("account-{}", id.0).into(),
}
}
pub fn account(self) -> Option<AccountId> {
match self {
Open::Inboxes => None,
Open::Mailbox(id, _) | Open::Account(id) => Some(id),
}
}
fn scope(self) -> Option<Scope> {
match self {
Open::Inboxes => Some(Scope::Inboxes),
Open::Mailbox(_, mailbox) => Some(Scope::Mailbox(mailbox)),
Open::Account(_) => None,
}
}
}
/// Rows read, and where each key sits.
pub struct Indexed {
rows: Vec<Row>,
at: HashMap<SharedString, usize>,
}
/// Off the UI thread: a long list takes a while.
pub fn index(rows: Vec<Row>) -> Indexed {
let at = rows
.iter()
.enumerate()
.map(|(ix, row)| (row.key.clone(), ix))
.collect();
Indexed { rows, at }
}
/// The open list and the messages picked in it.
#[derive(Default)]
pub struct Listing {
/// Numbers reads; an older one is dropped.
pub read: u64,
/// None until read, or when nothing lists mail.
pub mails: Option<Result<Rc<Vec<Row>>, String>>,
at: HashMap<SharedString, usize>,
pub selected: Vec<SharedString>,
/// The one message picked, for the reading pane.
pub chosen: Option<Row>,
}
impl Listing {
pub fn show(&mut self, mails: Option<Result<Indexed, String>>) {
self.at = HashMap::new();
self.mails = mails.map(|read| {
read.map(|indexed| {
self.at = indexed.at;
Rc::new(indexed.rows)
})
});
}
/// Keeps the picks still listed.
fn pick(&mut self, keys: Vec<SharedString>) {
let Some(Ok(mails)) = &self.mails else {
self.selected.clear();
self.chosen = None;
return;
};
self.selected = keys
.into_iter()
.filter(|key| self.at.contains_key(key))
.collect();
self.chosen = match &self.selected[..] {
[key] => Some(mails[self.at[key]].clone()),
_ => None,
};
}
}
impl Mail {
pub fn open_box(&mut self, open: Open, cx: &mut Context<Self>) {
log::info!("mail: open {}", open.key());
if open != self.open {
self.open = open;
self.listing.show(None);
self.listing.pick(Vec::new());
}
self.read_mail(cx);
self.notify(cx);
}
pub fn set_filter(&mut self, filter: Filter, cx: &mut Context<Self>) {
log::info!("mail: filter {filter:?}");
self.filter = filter;
self.read_mail(cx);
cx.notify();
}
pub fn select(&mut self, keys: &[SharedString], cx: &mut Context<Self>) {
self.listing.pick(keys.to_vec());
cx.notify();
}
/// Unread counts, and the open list when it lists mail.
pub fn read_mail(&mut self, cx: &mut Context<Self>) {
self.listing.read += 1;
let (read, scope, filter) = (self.listing.read, self.open.scope(), self.filter);
let none = (
text(cx, "mail.no_sender", &[]),
text(cx, "mail.no_subject", &[]),
);
self.with_store(
cx,
move |store| {
let rows = match scope {
Some(scope) => {
let list = store.list(scope, filter)?;
Some(index(
list.into_iter().map(|each| row(each, &none)).collect(),
))
}
None => None,
};
Ok((rows, store.unread()?))
},
move |mail, read_now, _| {
if mail.listing.read != read {
return log::info!("mail: list read {read} ended after a newer one");
}
match read_now {
Ok((rows, unread)) => {
mail.unread = unread;
mail.listing.show(rows.map(Ok));
}
Err(error) => {
log::warn!("mail: list read {read} failed: {error}");
mail.listing.show(scope.map(|_| Err(error)));
}
}
let keys = std::mem::take(&mut mail.listing.selected);
mail.listing.pick(keys);
},
);
}
/// Unread mail in every account's inbox.
pub fn inboxes_unread(&self) -> u32 {
self.syncs
.values()
.flat_map(|syncing| &syncing.folders)
.filter(|folder| folder.role == Some(Role::Inbox))
.filter_map(|folder| self.unread.get(&folder.id))
.sum()
}
}
/// `none` names a missing sender, then subject.
fn row(summary: Summary, none: &(SharedString, SharedString)) -> Row {
let key = summary.id.0.to_string();
let from = summary.from.first().map(|from| match &from.name {
Some(name) if !name.trim().is_empty() => name.clone(),
_ => from.email.clone(),
});
let from: SharedString = from.map_or_else(|| none.0.clone(), Into::into);
let subject: SharedString = summary.subject.map_or_else(|| none.1.clone(), Into::into);
let row = match summary.date.map(jiff::Timestamp::from_second) {
Some(Ok(at)) => Row::new(key, from, subject, at),
Some(Err(error)) => {
log::warn!("mail: message {} date unshown: {error}", summary.id.0);
Row::undated(key, from, subject)
}
None => Row::undated(key, from, subject),
};
let row = row.unread(!summary.seen).starred(summary.flagged);
match summary.attachments {
Some(count) => row.attachments(count as usize),
None => row,
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Sync now, pause, and folders kept whole.
use ely_gpui_component::{buttons::Button, feedback::Banner, forms::Switch, primitives::Severity};
use ely_mail_engine::{AccountId, Protocol};
use gpui::{
AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, Styled as _, div,
};
use crate::mail::{Mail, text};
impl Mail {
pub fn sync_buttons(&self, id: AccountId, cx: &mut Context<Self>) -> AnyElement {
let running = self.syncs.get(&id).is_some_and(|each| each.task.is_some());
let this = cx.weak_entity();
let now = Button::new("mail-sync-now", text(cx, "mail.sync.now", &[]))
.disabled(running || self.paused)
.on_click({
let this = this.clone();
move |_, _, cx| {
this.update(cx, |mail, cx| mail.sync(id, cx)).ok();
}
});
let key = if self.paused {
"mail.sync.resume"
} else {
"mail.sync.pause"
};
let pause = Button::new("mail-sync-pause", text(cx, key, &[])).on_click(move |_, _, cx| {
this.update(cx, |mail, cx| mail.set_paused(!mail.paused, cx))
.ok();
});
div()
.flex()
.gap_2()
.child(now)
.child(pause)
.into_any_element()
}
pub fn paused_banner(&self, cx: &Context<Self>) -> Option<Banner> {
self.paused.then(|| {
Banner::new(
"mail-sync-paused",
Severity::Info,
text(cx, "mail.sync.paused", &[]),
)
})
}
/// One switch per IMAP folder.
pub fn offline_switches(
&self,
id: AccountId,
protocol: Protocol,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
if protocol != Protocol::Imap {
return None;
}
let folders = &self.syncs.get(&id)?.folders;
if folders.is_empty() {
return None;
}
let this = cx.weak_entity();
let switches = folders.iter().map(|folder| {
let mailbox = folder.id;
let this = this.clone();
let label = text(cx, "mail.sync.offline", &[("name", &folder.name)]);
let switch = Switch::new(("mail-offline", mailbox.0 as u64), folder.offline)
.label(label)
.on_change(move |on, _, cx| {
this.update(cx, |mail, cx| mail.set_offline(id, mailbox, on, cx))
.ok();
});
div()
.debug_selector(move || format!("mail-offline-{}", mailbox.0))
.child(switch)
});
Some(
div()
.flex()
.flex_col()
.gap_2()
.children(switches)
.into_any_element(),
)
}
}
+289
View File
@@ -0,0 +1,289 @@
//! Checking, signing in and adding an account (MAIL-01, 02).
use ely_gpui_component::primitives::Severity;
use ely_mail_engine::{Account, Checked, Error, Kept, Secret, oauth};
use gpui::{Context, SharedString, Window};
use crate::{
adding::{Adding, Step},
kinds::Kind,
mail::{Mail, text},
};
impl Mail {
pub fn start_adding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.adding.is_none() && self.removing.is_none() {
self.adding = Some(Adding::new(Kind::Gmail, window, cx));
}
cx.notify();
}
pub fn close_adding(&mut self, cx: &mut Context<Self>) {
self.adding = None;
cx.notify();
}
/// A field changed: an earlier check no longer holds.
pub fn edited(&mut self, cx: &mut Context<Self>) {
let Some(adding) = &mut self.adding else {
return;
};
if !matches!(adding.step, Step::Editing | Step::Saving) {
adding.stop();
adding.step = Step::Editing;
cx.notify();
}
}
/// Starts a request for the open dialog.
fn ask(&mut self) -> Option<u64> {
let adding = self.adding.as_mut()?;
adding.stop();
self.asked += 1;
adding.asked = Some(self.asked);
Some(self.asked)
}
/// The open dialog, if it awaits `asked`.
fn awaiting(&mut self, asked: u64) -> Option<&mut Adding> {
self.adding
.as_mut()
.filter(|adding| adding.asked == Some(asked))
}
fn halt(&mut self, words: SharedString, cx: &mut Context<Self>) {
if let Some(adding) = &mut self.adding {
adding.stop();
adding.step = Step::Failed(words);
}
cx.notify();
}
/// The words when the address is already here.
fn taken(&self, address: &str, cx: &Context<Self>) -> Option<SharedString> {
self.accounts()
.iter()
.any(|(_, account)| account.address.eq_ignore_ascii_case(address))
.then(|| text(cx, "mail.add.taken", &[("address", address)]))
}
/// Checks a typed-in password with both servers.
pub fn check(&mut self, cx: &mut Context<Self>) {
let Some(adding) = &self.adding else { return };
let ready = adding
.account(None, cx)
.and_then(|account| Ok((account, adding.password(cx)?)));
match ready {
Ok((account, password)) => {
self.probe(account, password.clone(), Secret::Password(password), cx)
}
Err(words) => self.halt(words, cx),
}
}
/// Logs in with `secret`; `kept` goes to the vault after.
fn probe(&mut self, account: Account, kept: String, secret: Secret, cx: &mut Context<Self>) {
if let Some(words) = self.taken(&account.address, cx) {
return self.halt(words, cx);
}
let tls = match &self.tls {
Ok(tls) => tls.clone(),
Err(error) => return self.halt(text(cx, "mail.error.tls", &[("error", error)]), cx),
};
let Some(asked) = self.ask() else { return };
if let Some(adding) = &mut self.adding {
adding.step = Step::Checking;
}
log::info!("mail: checking a {:?} account", account.protocol);
let probe = account.clone();
let work = async move { probe.check(secret, &tls).await };
let task = self.on_runtime(cx, work, move |mail, done, cx| {
mail.checked(asked, account, kept, done, cx)
});
if let Some(adding) = &mut self.adding {
adding.task = Some(task);
}
cx.notify();
}
fn checked(
&mut self,
asked: u64,
account: Account,
kept: String,
done: Result<Checked, String>,
cx: &mut Context<Self>,
) {
let Some(adding) = self.awaiting(asked) else {
return log::info!("mail: a stale check dropped");
};
adding.task = None;
adding.step = match done {
Ok(checked) => Step::Checked {
account,
kept,
checked,
},
Err(error) => Step::Failed(text(cx, "mail.add.check_failed", &[("error", &error)])),
};
}
/// Opens the provider's page, then checks what it grants.
pub fn sign_in(&mut self, cx: &mut Context<Self>) {
let Some(adding) = &self.adding else { return };
let provider = match adding.auth().provider() {
Some(Ok(provider)) => provider,
Some(Err(error)) => return self.halt(unsigned(&error, cx), cx),
None => return log::error!("mail: sign-in asked of a password account"),
};
if let Err(words) = adding.servers(cx) {
return self.halt(words, cx);
}
let hint = adding.fields.address.read(cx).text().trim().to_owned();
let Some(asked) = self.ask() else { return };
if let Some(adding) = &mut self.adding {
adding.step = Step::Signing;
}
let work = async move { oauth::SignIn::start(provider, &hint).await };
let task = self.on_runtime(cx, work, move |mail, started, cx| {
if mail.awaiting(asked).is_none() {
return log::info!("mail: a stale sign-in dropped");
}
match started.map_err(|error| text(cx, "mail.add.check_failed", &[("error", &error)])) {
Ok(Ok(sign_in)) => mail.wait_for_browser(asked, sign_in, cx),
Ok(Err(error)) => mail.halt(unsigned(&error, cx), cx),
Err(words) => mail.halt(words, cx),
}
});
if let Some(adding) = &mut self.adding {
adding.task = Some(task);
}
cx.notify();
}
fn wait_for_browser(&mut self, asked: u64, sign_in: oauth::SignIn, cx: &mut Context<Self>) {
cx.open_url(sign_in.url());
log::info!("mail: the browser has the sign-in page");
let work = async move {
let tokens = sign_in.finish().await?;
let email = tokens
.email
.clone()
.ok_or_else(|| Error::SignIn("the provider named no address".into()))?;
let refresh = tokens
.refresh
.clone()
.ok_or_else(|| Error::SignIn("no refresh token granted".into()))?;
Ok::<_, Error>((tokens.access, refresh, email))
};
let task = self.on_runtime(cx, work, move |mail, done, cx| {
if mail.awaiting(asked).is_none() {
return log::info!("mail: a stale sign-in dropped");
}
let account = match done {
Ok(Ok((access, refresh, email))) => mail.adding.as_ref().map(|adding| {
adding
.account(Some(&email), cx)
.map(|account| (account, access, refresh))
}),
Ok(Err(error)) => Some(Err(unsigned(&error, cx))),
Err(error) => Some(Err(text(cx, "mail.add.check_failed", &[("error", &error)]))),
};
match account {
Some(Ok((account, access, refresh))) => {
mail.probe(account, refresh, Secret::Token(access), cx)
}
Some(Err(words)) => mail.halt(words, cx),
None => {}
}
});
if let Some(adding) = &mut self.adding {
adding.task = Some(task);
}
}
/// Stores the checked account, then its secret.
pub fn save(&mut self, cx: &mut Context<Self>) {
let Some(Adding {
step:
Step::Checked {
account,
kept,
checked,
},
..
}) = &self.adding
else {
return;
};
if !checked.passed() {
return;
}
if let Some(words) = self.taken(&account.address, cx) {
return self.halt(words, cx);
}
let (account, kept) = (account.clone(), kept.clone());
let Some(asked) = self.ask() else { return };
if let Some(adding) = &mut self.adding {
adding.step = Step::Saving;
}
let vault = self.vault.clone();
let work = move |store: &mut ely_mail_engine::Store| {
let id = match store.add_account(&account) {
Ok(id) => id,
Err(Error::Taken(address)) => return Ok(Err(address)),
Err(error) => return Err(error),
};
let held = vault.put(&account.secret_name(), &kept);
Ok(Ok((id, account, held)))
};
self.with_store(cx, work, move |mail, saved, cx| match saved {
Ok(Err(address)) => {
let words = text(cx, "mail.add.taken", &[("address", &address)]);
match mail.awaiting(asked) {
Some(_) => mail.halt(words, cx),
None => mail.toast(words, Severity::Danger, cx),
}
}
Ok(Ok((id, account, held))) => {
log::info!("mail: account {} added", id.0);
let words = text(cx, "mail.add.added", &[("address", &account.address)]);
if let Some(Ok(accounts)) = &mut mail.accounts {
accounts.push((id, account));
}
mail.open_box(crate::boxes::Open::Account(id), cx);
mail.sync(id, cx);
if mail.awaiting(asked).is_some() {
mail.adding = None;
}
mail.toast(words, Severity::Success, cx);
if let Kept::Session(reason) = held {
mail.toast(
text(cx, "mail.add.session_only", &[("error", &reason)]),
Severity::Warning,
cx,
);
}
}
Err(error) => {
let words = text(cx, "mail.add.save_failed", &[("error", &error)]);
match mail.awaiting(asked) {
Some(_) => mail.halt(words, cx),
None => mail.toast(words, Severity::Danger, cx),
}
}
});
}
}
/// Sign-in failures, with this build's missing client named.
fn unsigned(error: &Error, cx: &gpui::App) -> SharedString {
match error {
Error::Unavailable(name) => text(cx, &format!("mail.add.unavailable.{name}"), &[]),
other => text(
cx,
"mail.add.sign_in_failed",
&[("error", &other.to_string())],
),
}
}
+87
View File
@@ -0,0 +1,87 @@
//! The kinds of account and their known servers.
use ely_mail_engine::{Auth, Protocol, Security, Server};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Gmail,
Microsoft,
ICloud,
Imap,
Pop,
}
pub const KINDS: [Kind; 5] = [
Kind::Gmail,
Kind::Microsoft,
Kind::ICloud,
Kind::Imap,
Kind::Pop,
];
/// Where iCloud makes app-specific passwords.
pub const APPLE_ACCOUNT: &str = "https://account.apple.com";
impl Kind {
pub fn key(self) -> &'static str {
match self {
Kind::Gmail => "gmail",
Kind::Microsoft => "microsoft",
Kind::ICloud => "icloud",
Kind::Imap => "imap",
Kind::Pop => "pop",
}
}
pub fn from_key(key: &str) -> Option<Kind> {
KINDS.into_iter().find(|kind| kind.key() == key)
}
/// `keep` is POP3's server copy.
pub fn protocol(self, keep: bool) -> Protocol {
match self {
Kind::Pop => Protocol::Pop { keep },
_ => Protocol::Imap,
}
}
/// The provider's servers and sign-in; None when typed in.
pub fn known(self) -> Option<(Auth, Server, Server)> {
let server = |host: &str, port, security| Server {
host: host.into(),
port,
security,
};
match self {
Kind::Gmail => Some((
Auth::Google,
server("imap.gmail.com", 993, Security::Tls),
server("smtp.gmail.com", 465, Security::Tls),
)),
Kind::Microsoft => Some((
Auth::Microsoft,
server("outlook.office365.com", 993, Security::Tls),
server("smtp.office365.com", 587, Security::StartTls),
)),
// Apple: IMAP takes the full address too.
Kind::ICloud => Some((
Auth::Password,
server("imap.mail.me.com", 993, Security::Tls),
server("smtp.mail.me.com", 587, Security::StartTls),
)),
Kind::Imap | Kind::Pop => None,
}
}
}
/// The usual port for a protocol under a security.
pub fn port(protocol: Option<Protocol>, security: Security) -> u16 {
match (protocol, security) {
(Some(Protocol::Imap), Security::Tls) => 993,
(Some(Protocol::Imap), Security::StartTls) => 143,
(Some(Protocol::Pop { .. }), Security::Tls) => 995,
(Some(Protocol::Pop { .. }), Security::StartTls) => 110,
(None, Security::Tls) => 465,
(None, Security::StartTls) => 587,
}
}
+236
View File
@@ -0,0 +1,236 @@
//! The window: accounts and the dialogs that change them.
use std::{
future::Future,
path::Path,
sync::{Arc, Mutex},
};
use ely_gpui_component::primitives::Severity;
use ely_mail_engine::{Account, AccountId, Filter, Store, TlsConnector, Vault};
use ely_platform::AppId;
use ely_ui::Root;
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, SharedString, WeakEntity, Window};
use tokio::runtime::Runtime;
use crate::{
adding::Adding,
boxes::{Listing, Open, Unread},
removing::Removing,
side::Side,
syncing::Syncs,
};
/// What the engine runs on and keeps to.
pub struct Parts {
pub store: Result<Store, String>,
pub vault: Vault,
pub tls: Result<TlsConnector, String>,
}
impl Parts {
pub fn system(dir: &Path) -> Self {
Self {
store: Store::open(dir).map_err(|err| err.to_string()),
vault: Vault::system(),
tls: ely_mail_engine::system_tls().map_err(|err| err.to_string()),
}
}
}
pub struct Mail {
pub store: Result<Arc<Mutex<Store>>, String>,
pub read_only: Option<String>,
pub vault: Arc<Vault>,
pub tls: Result<TlsConnector, String>,
runtime: Arc<Runtime>,
/// None until read.
pub accounts: Option<Result<Vec<(AccountId, Account)>, String>>,
/// Remove acts on its account.
pub open: Open,
pub filter: Filter,
pub listing: Listing,
pub unread: Unread,
pub adding: Option<Adding>,
pub removing: Option<Removing>,
pub syncs: Syncs,
/// Numbers every sync, even across forgotten accounts.
pub runs: u64,
/// Until resumed, no account syncs.
pub paused: bool,
/// Numbers every request across dialogs.
pub asked: u64,
pub focus: FocusHandle,
pub root: WeakEntity<Root>,
pub side: Entity<Side>,
}
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
ely_ui::i18n::text(cx, key, args)
}
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let dir = match ely_platform::data_dir(app) {
Ok(dir) => dir,
Err(error) => ely_platform::startup::fail(app, &format!("{error:#}")),
};
let parts = Parts::system(&dir);
let mail = cx.new(|cx| Mail::new(parts, cx));
let root = ely_ui::tool_root(app, mail.clone().into(), crate::menu::menus(cx), window, cx);
mail.update(cx, |mail, cx| {
mail.root = root.downgrade();
window.focus(&mail.focus, cx);
});
root
}
impl Mail {
pub fn new(parts: Parts, cx: &mut Context<Self>) -> Self {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("mail")
.enable_all()
.build()
.unwrap_or_else(|error| {
ely_platform::startup::fail(AppId::Mail, &format!("no mail runtime: {error}"))
});
let read_only = match &parts.store {
Ok(store) => store.read_only().map(str::to_owned),
Err(error) => {
log::error!("mail: the store did not open: {error}");
None
}
};
if let Err(error) = &parts.tls {
log::error!("mail: no system TLS: {error}");
}
let this = cx.weak_entity();
let mut mail = Mail {
store: parts.store.map(|store| Arc::new(Mutex::new(store))),
read_only,
vault: Arc::new(parts.vault),
tls: parts.tls,
runtime: Arc::new(runtime),
accounts: None,
open: Open::Inboxes,
filter: Filter::default(),
listing: Listing::default(),
unread: Unread::new(),
adding: None,
removing: None,
syncs: Syncs::new(),
runs: 0,
paused: false,
asked: 0,
focus: cx.focus_handle(),
root: WeakEntity::new_invalid(),
side: cx.new(|_| Side::new(this)),
};
mail.read_accounts(cx);
Self::sync_on_a_timer(cx);
mail
}
pub fn read_accounts(&mut self, cx: &mut Context<Self>) {
self.with_store(
cx,
|store| store.accounts(),
|mail, read, cx| {
let first = mail.accounts.is_none();
if let Ok(accounts) = &read {
let still = |id| accounts.iter().any(|(each, _)| *each == id);
if mail.open.account().is_some_and(|id| !still(id)) {
mail.open = Open::Inboxes;
}
}
mail.accounts = Some(read);
if first {
mail.sync_all(cx);
}
mail.read_mail(cx);
},
);
}
pub fn accounts(&self) -> &[(AccountId, Account)] {
match &self.accounts {
Some(Ok(accounts)) => accounts,
_ => &[],
}
}
/// Redraws the window and its account list.
pub fn notify(&self, cx: &mut Context<Self>) {
self.side.update(cx, |_, cx| cx.notify());
cx.notify();
}
/// Store work off the window's thread.
pub fn with_store<R: Send + 'static>(
&self,
cx: &mut Context<Self>,
work: impl FnOnce(&mut Store) -> ely_mail_engine::Result<R> + Send + 'static,
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + 'static,
) {
let store = match &self.store {
Ok(store) => store.clone(),
Err(error) => {
self.failed("mail.error.store", error, cx);
return;
}
};
let read = cx.background_spawn(async move {
let mut store = store
.lock()
.map_err(|_| "a store task panicked".to_owned())?;
work(&mut store).map_err(|err| err.to_string())
});
cx.spawn(async move |this, cx| {
let read = read.await;
this.update(cx, |mail, cx| {
then(mail, read, cx);
mail.notify(cx);
})
.ok();
})
.detach();
}
/// Network work on the mail runtime; abort stops it.
pub fn on_runtime<R: Send + 'static>(
&self,
cx: &mut Context<Self>,
work: impl Future<Output = R> + Send + 'static,
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + 'static,
) -> tokio::task::AbortHandle {
let task = self.runtime.spawn(work);
let abort = task.abort_handle();
cx.spawn(async move |this, cx| {
let done = task.await;
if done.as_ref().is_err_and(|err| err.is_cancelled()) {
return;
}
let done = done.map_err(|err| format!("a mail task failed: {err}"));
this.update(cx, |mail, cx| {
then(mail, done, cx);
mail.notify(cx);
})
.ok();
})
.detach();
abort
}
/// Not logged: the words may name an address.
pub fn toast(&self, text: SharedString, severity: Severity, cx: &mut App) {
if let Some(root) = self.root.upgrade() {
root.update(cx, |root, cx| root.toast(text, severity, cx));
}
}
pub fn failed(&self, key: &str, error: &str, cx: &mut App) {
log::warn!("mail: {key}");
let words = text(cx, key, &[("error", error)]);
self.toast(words, Severity::Danger, cx);
}
}
+18 -1
View File
@@ -1,8 +1,25 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod add_view;
mod adding;
mod boxes;
mod controls;
mod flow;
mod kinds;
mod mail;
mod menu;
mod panes;
mod removing;
mod side;
mod syncing;
mod view;
#[cfg(test)]
mod tests;
use ely_platform::AppId;
fn main() {
ely_ui::run(AppId::Mail, ely_ui::blank);
ely_ui::run(AppId::Mail, mail::open);
}
+20
View File
@@ -0,0 +1,20 @@
//! The Account and Sync menus.
use gpui::{App, Menu, MenuItem, actions};
use crate::mail::text;
actions!(mail, [AddAccount, RemoveAccount, SyncAll]);
pub const CONTEXT: &str = "mail";
pub fn menus(cx: &App) -> Vec<Menu> {
vec![
Menu::new(text(cx, "mail.menu.account", &[])).items([
MenuItem::action(text(cx, "mail.add.open", &[]), AddAccount),
MenuItem::action(text(cx, "mail.remove.open", &[]), RemoveAccount),
]),
Menu::new(text(cx, "mail.menu.sync", &[]))
.items([MenuItem::action(text(cx, "mail.sync.now", &[]), SyncAll)]),
]
}
+121
View File
@@ -0,0 +1,121 @@
//! The message list and the reading pane (C12.9).
use ely_gpui_component::{
buttons::{ToggleGroup, ToggleItem},
data_display::DescriptionList,
feedback::{EmptyState, ErrorView},
layout::SplitPane,
mail::MailList,
primitives::IconName,
};
use ely_mail_engine::Filter;
use gpui::{
AnyElement, Axis, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div, px,
};
use crate::{
mail::{Mail, text},
view::local_time,
};
type Filtered = fn(Filter) -> bool;
const FILTERS: [(&str, &str, IconName, Filtered); 3] = [
("unread", "mail.filter.unread", IconName::Mail, |f| f.unread),
("flagged", "mail.filter.flagged", IconName::Star, |f| {
f.flagged
}),
(
"attachments",
"mail.filter.attachments",
IconName::Paperclip,
|f| f.attachments,
),
];
impl Mail {
pub fn panes(&self, cx: &mut Context<Self>) -> AnyElement {
SplitPane::new("mail-panes", Axis::Horizontal, px(240.))
.pane(self.list_pane(cx))
.pane(self.reader(cx))
.into_any_element()
}
fn list_pane(&self, cx: &mut Context<Self>) -> AnyElement {
let on: Vec<&str> = FILTERS
.iter()
.filter(|(_, _, _, on)| on(self.filter))
.map(|(key, _, _, _)| *key)
.collect();
let toggles = FILTERS.iter().fold(
ToggleGroup::new("mail-filters").multiple().selected(on),
|group, (key, label, icon, _)| {
let words = text(cx, label, &[]);
group.item(ToggleItem::new(*key).icon(*icon).label(words))
},
);
let this = cx.weak_entity();
let toggles = toggles.on_change(move |keys, _, cx| {
let has = |key: &str| keys.iter().any(|each| each == key);
let filter = Filter {
unread: has("unread"),
flagged: has("flagged"),
attachments: has("attachments"),
};
this.update(cx, |mail, cx| mail.set_filter(filter, cx)).ok();
});
let list = match &self.listing.mails {
None => div().into_any_element(),
Some(Err(detail)) => ErrorView::new(
"mail-list-error",
IconName::TriangleAlert,
text(cx, "mail.list.unread", &[]),
)
.detail(detail.clone())
.into_any_element(),
Some(Ok(mails)) => {
let this = cx.weak_entity();
MailList::new("mail-list", mails.clone())
.selected(self.listing.selected.clone())
.on_select(move |keys, _, cx| {
this.update(cx, |mail, cx| mail.select(keys, cx)).ok();
})
.into_any_element()
}
};
div()
.size_full()
.flex()
.flex_col()
.child(div().p_2().child(toggles))
.child(div().flex_1().min_h_0().child(list))
.into_any_element()
}
fn reader(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(row) = &self.listing.chosen else {
return EmptyState::new(
"mail-reader-empty",
IconName::Mail,
text(cx, "mail.reader.none", &[]),
)
.into_any_element();
};
let label = |key: &str| text(cx, key, &[]);
let date: SharedString = match row.at {
Some(at) => local_time(at.as_second()).into(),
None => label("mail.reader.no_date"),
};
let details = DescriptionList::new()
.text(label("mail.reader.from"), row.from.clone())
.text(label("mail.reader.date"), date);
div()
.p_6()
.flex()
.flex_col()
.gap_4()
.child(div().text_xl().child(row.subject.clone()))
.child(details)
.into_any_element()
}
}
+97
View File
@@ -0,0 +1,97 @@
//! Removing an account: its local data and secret only.
use ely_gpui_component::primitives::Severity;
use ely_mail_engine::{AccountId, Cached, Error, Kept};
use gpui::Context;
use crate::mail::{Mail, text};
#[derive(Clone)]
pub struct Removing {
pub id: AccountId,
pub address: String,
pub cached: Cached,
}
impl Mail {
/// Measures what goes, then asks.
pub fn ask_remove(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.open.account() else {
let words = text(cx, "mail.remove.none", &[]);
return self.toast(words, Severity::Info, cx);
};
self.with_store(
cx,
move |store| store.cached(id),
move |mail, cached, cx| {
let address = mail
.accounts()
.iter()
.find(|(each, _)| *each == id)
.map(|(_, account)| account.address.clone());
match (cached, address) {
(Ok(cached), Some(address))
if mail.adding.is_none() && mail.removing.is_none() =>
{
mail.removing = Some(Removing {
id,
address,
cached,
});
}
(Ok(_), _) => {
log::info!("mail: removal asked of a gone account or over a dialog")
}
(Err(error), _) => mail.failed("mail.remove.failed", &error, cx),
}
},
);
}
/// The secret goes first, and back if the rest fails.
pub fn remove(&mut self, removing: Removing, cx: &mut Context<Self>) {
let Some((_, account)) = self
.accounts()
.iter()
.find(|(each, _)| *each == removing.id)
else {
return log::info!("mail: the account to remove is already gone");
};
let (id, name, vault) = (removing.id, account.secret_name(), self.vault.clone());
self.stop_sync(id);
let address = removing.address;
self.with_store(
cx,
move |store| {
let secret = vault.get(&name)?;
vault.remove(&name)?;
store.remove_account(id).map_err(|error| {
match secret.map(|secret| vault.put(&name, &secret)) {
Some(Kept::Session(reason)) => Error::Vault(format!(
"{error}; its secret is back for this session only: {reason}"
)),
_ => error,
}
})
},
move |mail, removed, cx| {
match removed {
Ok(()) => {
log::info!("mail: account {} removed", id.0);
mail.toast(
text(cx, "mail.remove.done", &[("address", &address)]),
Severity::Success,
cx,
);
}
Err(error) => {
mail.failed("mail.remove.failed", &error, cx);
mail.sync(id, cx);
}
}
mail.read_accounts(cx);
},
);
cx.notify();
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Every inbox, then each account's folders, down the left.
use ely_gpui_component::{
layout::ScrollArea,
mail::{Mailbox, MailboxList},
primitives::IconName,
};
use ely_mail_engine::{LocalFolder, Role};
use gpui::{
App, Context, IntoElement, ParentElement as _, Render, SharedString, Styled as _, WeakEntity,
Window, div,
};
use crate::{
boxes::Open,
mail::{Mail, text},
};
pub struct Side {
mail: WeakEntity<Mail>,
}
impl Side {
pub fn new(mail: WeakEntity<Mail>) -> Self {
Self { mail }
}
}
impl Render for Side {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(mail) = self.mail.upgrade() else {
return div().into_any_element();
};
let mail = mail.read(cx);
let inboxes = Mailbox::new(
Open::Inboxes.key(),
text(cx, "mail.boxes.all", &[]),
IconName::Inbox,
)
.unread(mail.inboxes_unread());
let mut list =
MailboxList::new("mail-boxes").section(text(cx, "mail.boxes.unified", &[]), [inboxes]);
for (id, account) in mail.accounts() {
let folders = mail.syncs.get(id).map_or(&[][..], |each| &each.folders);
let mut sorted: Vec<&LocalFolder> = folders.iter().collect();
sorted.sort_by(|a, b| rank(a.role).cmp(&rank(b.role)).then(a.name.cmp(&b.name)));
let boxes = sorted.into_iter().map(|folder| {
let open = Open::Mailbox(*id, folder.id);
// Boxes with nothing unread are absent.
let unread = mail.unread.get(&folder.id).copied().unwrap_or(0);
Mailbox::new(open.key(), name(folder, cx), icon(folder.role)).unread(unread)
});
let settings = Mailbox::new(
Open::Account(*id).key(),
text(cx, "mail.boxes.settings", &[]),
IconName::Settings,
);
list = list.section(account.address.clone(), boxes.chain([settings]));
}
let this = self.mail.clone();
let list = list.open(mail.open.key()).on_open(move |key, _, cx| {
this.update(cx, |mail, cx| match mail.opened(key) {
Some(open) => mail.open_box(open, cx),
None => log::error!("mail: the mailbox list sent an unknown box"),
})
.ok();
});
ScrollArea::new("mail-side-scroll")
.vertical()
.size_full()
.child(div().p_2().child(list))
.into_any_element()
}
}
impl Mail {
/// The box a side key names, if still here.
fn opened(&self, key: &SharedString) -> Option<Open> {
if *key == Open::Inboxes.key() {
return Some(Open::Inboxes);
}
self.accounts().iter().find_map(|(id, _)| {
let folders = self.syncs.get(id).map_or(&[][..], |each| &each.folders);
let boxes = folders.iter().map(|folder| Open::Mailbox(*id, folder.id));
boxes
.chain([Open::Account(*id)])
.find(|open| open.key() == *key)
})
}
}
/// Inbox in the reader's words; others as named.
fn name(folder: &LocalFolder, cx: &App) -> SharedString {
match folder.role {
Some(Role::Inbox) => text(cx, "mail.boxes.inbox", &[]),
_ => folder.name.clone().into(),
}
}
fn rank(role: Option<Role>) -> u8 {
match role {
Some(Role::Inbox) => 0,
Some(Role::Flagged) => 1,
Some(Role::Drafts) => 2,
Some(Role::Sent) => 3,
Some(Role::Archive) => 4,
Some(Role::All) => 5,
Some(Role::Junk) => 6,
Some(Role::Trash) => 7,
None => 8,
}
}
fn icon(role: Option<Role>) -> IconName {
match role {
Some(Role::Inbox) => IconName::Inbox,
Some(Role::Flagged) => IconName::Star,
Some(Role::Drafts) => IconName::File,
Some(Role::Sent) => IconName::Send,
Some(Role::Archive) => IconName::Archive,
Some(Role::All) => IconName::Mail,
Some(Role::Junk) => IconName::TriangleAlert,
Some(Role::Trash) => IconName::Trash2,
None => IconName::Folder,
}
}
+187
View File
@@ -0,0 +1,187 @@
//! Accounts sync on open, on demand and on a timer.
use std::{
collections::HashMap,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Protocol, Synced, sync};
use gpui::Context;
use tokio::task::AbortHandle;
use crate::mail::Mail;
/// How often every account syncs while the window is open.
pub const EVERY: Duration = Duration::from_secs(10 * 60);
/// One account's sync as the window shows it.
#[derive(Default)]
pub struct Syncing {
pub task: Option<AbortHandle>,
/// Which sync `task` is; older completions are stale.
pub run: u64,
/// A folder changed mid-sync: sync once more.
pub again: bool,
pub synced: Option<Synced>,
pub failed: Option<String>,
pub folders: Vec<LocalFolder>,
}
pub type Syncs = HashMap<AccountId, Syncing>;
impl Mail {
pub fn sync(&mut self, id: AccountId, cx: &mut Context<Self>) {
if self.paused {
return log::info!("mail: account {} not synced: paused", id.0);
}
let Some((_, account)) = self.accounts().iter().find(|(each, _)| *each == id) else {
return log::info!("mail: sync asked of a gone account");
};
if self.syncs.get(&id).is_some_and(|each| each.task.is_some()) {
return log::info!("mail: account {} is already syncing", id.0);
}
let Ok(store) = self.store.clone() else {
return log::error!("mail: no store to sync with");
};
let tls = match self.tls.clone() {
Ok(tls) => tls,
Err(error) => {
log::warn!("mail: account {} cannot sync without TLS", id.0);
self.syncs.entry(id).or_default().failed = Some(error);
return self.read_synced(id, cx);
}
};
let (account, vault) = (account.clone(), self.vault.clone());
self.runs += 1;
let run = self.runs;
self.syncs.entry(id).or_default().run = run;
let work = async move {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| ely_mail_engine::Error::Store(format!("the clock: {err}")))?
.as_secs() as i64;
let secret = sync::secret(&account, &vault).await?;
match account.protocol {
Protocol::Imap => sync::imap(&account, id, secret, &tls, &store, now).await,
Protocol::Pop { .. } => sync::pop(&account, id, secret, &tls, &store, now).await,
}
};
let task = self.on_runtime(cx, work, move |mail, done, cx| {
let failed = match done {
Ok(Ok(_)) => None,
Ok(Err(error)) => Some(error.to_string()),
Err(error) => Some(error),
};
if failed.is_some() {
log::warn!("mail: account {} did not sync", id.0);
}
let Some(syncing) = mail.syncs.get_mut(&id) else {
return log::info!("mail: a sync ended for a removed account");
};
if syncing.run != run {
return log::info!("mail: account {} sync {run} ended after a newer one", id.0);
}
syncing.task = None;
syncing.failed = failed;
let again = std::mem::take(&mut syncing.again);
mail.read_synced(id, cx);
if again {
log::info!("mail: account {} syncs again for a changed folder", id.0);
mail.sync(id, cx);
}
});
self.syncs.entry(id).or_default().task = Some(task);
self.read_synced(id, cx);
}
pub fn sync_all(&mut self, cx: &mut Context<Self>) {
if self.paused {
return log::info!("mail: sync is paused");
}
let ids: Vec<_> = self.accounts().iter().map(|(id, _)| *id).collect();
for id in ids {
self.sync(id, cx);
}
}
/// Pausing stops running syncs; resuming syncs every account.
pub fn set_paused(&mut self, paused: bool, cx: &mut Context<Self>) {
log::info!("mail: sync paused: {paused}");
self.paused = paused;
if paused {
for syncing in self.syncs.values_mut() {
syncing.again = false;
if let Some(task) = syncing.task.take() {
task.abort();
}
}
} else {
self.sync_all(cx);
}
self.notify(cx);
}
/// Syncs every account each `EVERY` until the window goes.
pub fn sync_on_a_timer(cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
loop {
cx.background_executor().timer(EVERY).await;
if this.update(cx, |mail, cx| mail.sync_all(cx)).is_err() {
break;
}
}
})
.detach();
}
/// Takes the folder's mail whole, then syncs to fetch it.
pub fn set_offline(
&mut self,
id: AccountId,
mailbox: MailboxId,
offline: bool,
cx: &mut Context<Self>,
) {
self.with_store(
cx,
move |store| store.set_offline(mailbox, offline),
move |mail, done, cx| match done {
Ok(()) => {
mail.read_synced(id, cx);
match mail.syncs.get_mut(&id) {
Some(syncing) if offline && syncing.task.is_some() => {
log::info!("mail: account {} syncs again after this one", id.0);
syncing.again = true;
}
_ if offline => mail.sync(id, cx),
_ => {}
}
}
Err(error) => mail.failed("mail.sync.offline_failed", &error, cx),
},
);
}
/// Stops the account's sync and forgets it.
pub fn stop_sync(&mut self, id: AccountId) {
if let Some(task) = self.syncs.remove(&id).and_then(|each| each.task) {
task.abort();
}
}
fn read_synced(&mut self, id: AccountId, cx: &mut Context<Self>) {
self.with_store(
cx,
move |store| Ok((store.synced(id)?, store.folders(id)?)),
move |mail, read, cx| match (read, mail.syncs.get_mut(&id)) {
(Ok((synced, folders)), Some(syncing)) => {
syncing.synced = Some(synced);
syncing.folders = folders;
mail.read_mail(cx);
}
(Err(error), Some(_)) => mail.failed("mail.sync.unread", &error, cx),
(_, None) => {}
},
);
}
}
+192
View File
@@ -0,0 +1,192 @@
//! Accounts in the window: add, check, keep, remove (C12.5).
use std::{
collections::HashMap,
path::Path,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
use ely_mail_engine::{
Account, Auth, Backend, Checked, Protocol, Security, Server, Store, TlsConnector, Vault,
};
use gpui::{App, Entity, TestAppContext, VisualTestContext};
use crate::{
adding::Step,
kinds::Kind,
mail::{Mail, Parts},
};
mod controls;
mod flows;
mod panes;
mod syncs;
/// A credential store that keeps or refuses.
#[derive(Clone, Default)]
pub(crate) struct Keys {
pub kept: Arc<Mutex<HashMap<String, String>>>,
pub refuse: Option<&'static str>,
pub deletes: Arc<AtomicUsize>,
}
impl Backend for Keys {
fn get(&self, name: &str) -> Result<Option<String>, String> {
Ok(self.kept.lock().unwrap().get(name).cloned())
}
fn set(&self, name: &str, secret: &str) -> Result<(), String> {
if let Some(reason) = self.refuse {
return Err(reason.into());
}
self.kept.lock().unwrap().insert(name.into(), secret.into());
Ok(())
}
fn delete(&self, name: &str) -> Result<(), String> {
self.deletes.fetch_add(1, Ordering::SeqCst);
if let Some(reason) = self.refuse {
return Err(reason.into());
}
self.kept.lock().unwrap().remove(name);
Ok(())
}
}
pub(crate) fn settle(cx: &mut VisualTestContext, done: impl Fn(&App) -> bool) {
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
cx.run_until_parked();
cx.update(|window, _| window.refresh());
if cx.update(|_, app| done(app)) {
return;
}
}
panic!("the state never settled");
}
pub(crate) fn window<'a>(
cx: &'a mut TestAppContext,
dir: &Path,
keys: Keys,
) -> (Entity<Mail>, &'a mut VisualTestContext) {
let tls = ely_mail_engine::system_tls().map_err(|err| err.to_string());
window_with(cx, dir, keys, tls)
}
pub(crate) fn window_with<'a>(
cx: &'a mut TestAppContext,
dir: &Path,
keys: Keys,
tls: Result<TlsConnector, String>,
) -> (Entity<Mail>, &'a mut VisualTestContext) {
cx.executor().allow_parking();
cx.update(ely_gpui_component::init_for_tests);
cx.update(|cx| {
ely_ui::settings::install_defaults(cx);
ely_ui::i18n::install_as("en-US", cx);
// As tool_root does: menus before the window.
let menus = crate::menu::menus(cx);
let menus = ely_ui::commands::menus(
ely_platform::AppId::Mail,
&ely_ui::commands::OPENING,
false,
menus,
cx,
);
cx.set_menus(menus);
});
let parts = Parts {
store: Store::open(dir).map_err(|err| err.to_string()),
vault: Vault::new(Box::new(keys)),
tls,
};
let (mail, cx) = cx.add_window_view(move |_, cx| Mail::new(parts, cx));
settle(cx, |app| mail.read(app).accounts.is_some());
(mail, cx)
}
/// A port nothing listens on.
pub(crate) fn closed_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
/// On this machine's discard port, so syncs end at once.
pub(crate) fn account(address: &str) -> Account {
let server = |port| Server {
host: "127.0.0.1".into(),
port,
security: Security::Tls,
};
Account {
address: address.into(),
user: address.into(),
auth: Auth::Password,
protocol: Protocol::Imap,
incoming: server(9),
outgoing: server(9),
}
}
/// Opens the dialog on `kind`.
pub(crate) fn adding(mail: &Entity<Mail>, kind: Kind, cx: &mut VisualTestContext) {
cx.update(|window, app| {
mail.update(app, |mail, cx| {
mail.start_adding(window, cx);
mail.adding.as_mut().unwrap().set_kind(kind, cx);
})
});
}
pub(crate) fn type_in(
mail: &Entity<Mail>,
cx: &mut VisualTestContext,
pick: fn(&crate::adding::Fields) -> &Entity<ely_gpui_component::forms::TextInput>,
words: &str,
) {
cx.update(|_, app| {
let field = pick(&mail.read(app).adding.as_ref().unwrap().fields).clone();
field.update(app, |field, cx| field.set_text(words.to_owned(), cx));
});
cx.run_until_parked();
}
/// A passed check, as servers would answer.
pub(crate) fn passed(
mail: &Entity<Mail>,
account: Account,
kept: &str,
cx: &mut VisualTestContext,
) {
cx.update(|_, app| {
mail.update(app, |mail, _| {
mail.adding.as_mut().unwrap().step = Step::Checked {
account,
kept: kept.into(),
checked: Checked {
incoming: Ok(()),
outgoing: Ok(()),
},
};
})
});
}
pub(crate) fn step_words(mail: &Entity<Mail>, cx: &mut VisualTestContext) -> String {
cx.update(
|_, app| match &mail.read(app).adding.as_ref().unwrap().step {
Step::Failed(words) => words.to_string(),
Step::Editing => "editing".into(),
Step::Signing => "signing".into(),
Step::Checking => "checking".into(),
Step::Saving => "saving".into(),
Step::Checked { .. } => "checked".into(),
},
)
}
+338
View File
@@ -0,0 +1,338 @@
use std::{
net::TcpListener,
sync::mpsc,
time::{Duration, Instant},
};
use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Role, Store};
use gpui::{
Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px, size,
};
use super::{Keys, account, settle, window};
use crate::{boxes::Open, mail::Mail, syncing::EVERY, view::imap_range};
/// An account on a server that never answers.
fn silent(dir: &std::path::Path, port: u16) -> AccountId {
let mut ann = account("ann@example.com");
ann.incoming.port = port;
let mut store = Store::open(dir).unwrap();
let id = store.add_account(&ann).unwrap();
store
.set_folders(id, &[("INBOX", Some(Role::Inbox))])
.unwrap();
id
}
fn with_secret() -> Keys {
let keys = Keys::default();
let secret = ("account:ann@example.com".to_owned(), "pw".to_owned());
keys.kept.lock().unwrap().extend([secret]);
keys
}
/// Waits on the network task alone, not the window.
fn finished(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
let deadline = Instant::now() + Duration::from_secs(20);
let done = |cx: &mut VisualTestContext| {
cx.update(|_, app| {
let task = mail.read(app).syncs[&id].task.as_ref();
task.unwrap().is_finished()
})
};
while !done(cx) {
assert!(Instant::now() < deadline, "the sync never ended");
std::thread::sleep(Duration::from_millis(10));
}
}
/// An account whose sync fails at once: no secret saved.
fn failing(dir: &std::path::Path) -> AccountId {
let mut store = Store::open(dir).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
store
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
.unwrap();
ann
}
fn failed(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) -> bool {
cx.update(|_, app| mail.read(app).syncs[&id].failed.is_some())
}
fn forget(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
cx.update(|_, app| {
mail.update(app, |mail, _| {
mail.syncs.get_mut(&id).unwrap().failed = None
})
});
}
fn settled_failure(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
settle(cx, |app| {
mail.read(app)
.syncs
.get(&id)
.is_some_and(|each| each.failed.is_some() && each.task.is_none())
});
}
#[test]
fn every_account_syncs_again_on_the_timer() {
let dir = tempfile::tempdir().unwrap();
let ann = failing(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
settled_failure(&mail, ann, cx);
forget(&mail, ann, cx);
cx.executor().advance_clock(EVERY);
settled_failure(&mail, ann, cx);
}
#[test]
fn a_paused_sync_waits_until_resumed() {
let dir = tempfile::tempdir().unwrap();
let ann = failing(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
settled_failure(&mail, ann, cx);
forget(&mail, ann, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx)));
cx.executor().advance_clock(EVERY);
cx.run_until_parked();
assert!(!failed(&mail, ann, cx), "a paused account synced");
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(false, cx)));
settled_failure(&mail, ann, cx);
}
#[test]
fn a_folder_kept_offline_is_saved_and_synced() {
let dir = tempfile::tempdir().unwrap();
let ann = failing(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
settle(cx, |app| {
mail.read(app)
.syncs
.get(&ann)
.is_some_and(|each| each.failed.is_some() && !each.folders.is_empty())
});
let inbox = cx.update(|_, app| mail.read(app).syncs[&ann].folders[0].clone());
assert!(!inbox.offline);
forget(&mail, ann, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox.id, true, cx)));
settle(cx, |app| {
let syncing = &mail.read(app).syncs[&ann];
syncing.folders[0].offline && syncing.failed.is_some() && syncing.task.is_none()
});
assert!(Store::open(dir.path()).unwrap().offline(inbox.id).unwrap());
}
#[test]
fn an_old_end_queued_across_a_resume_keeps_the_new_sync() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let dir = tempfile::tempdir().unwrap();
let ann = silent(dir.path(), listener.local_addr().unwrap().port());
let keys = Keys::default();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
settled_failure(&mail, ann, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx)));
finished(&mail, ann, cx);
keys.kept
.lock()
.unwrap()
.insert("account:ann@example.com".into(), "pw".into());
let resumed = cx.update(|_, app| {
mail.update(app, |mail, cx| {
mail.set_paused(true, cx);
mail.set_paused(false, cx);
mail.syncs[&ann].task.clone().unwrap()
})
});
cx.run_until_parked();
assert!(
cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()),
"an old end cleared the new sync"
);
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
let deadline = Instant::now() + Duration::from_secs(5);
while !resumed.is_finished() {
assert!(Instant::now() < deadline, "pausing left the sync running");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn an_old_end_after_a_failed_removal_keeps_the_new_sync() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let dir = tempfile::tempdir().unwrap();
let ann = silent(dir.path(), listener.local_addr().unwrap().port());
let keys = Keys::default();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
settled_failure(&mail, ann, cx);
cx.update(|_, app| {
mail.update(app, |mail, cx| {
mail.stop_sync(ann);
mail.sync(ann, cx);
})
});
finished(&mail, ann, cx);
keys.kept
.lock()
.unwrap()
.insert("account:ann@example.com".into(), "pw".into());
let restarted = cx.update(|_, app| {
mail.update(app, |mail, cx| {
mail.stop_sync(ann);
mail.sync(ann, cx);
mail.syncs[&ann].task.clone().unwrap()
})
});
cx.run_until_parked();
assert!(
cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()),
"an old end cleared the restarted sync"
);
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
let deadline = Instant::now() + Duration::from_secs(5);
while !restarted.is_finished() {
assert!(Instant::now() < deadline, "pausing left the sync running");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn a_folder_kept_offline_mid_sync_syncs_once_more() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (hang_up, hung) = mpsc::channel::<()>();
let server = std::thread::spawn(move || {
let held = listener.accept().unwrap();
hung.recv().unwrap();
drop((held, listener));
});
let dir = tempfile::tempdir().unwrap();
let ann = silent(dir.path(), port);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), with_secret());
settle(cx, |app| {
mail.read(app)
.syncs
.get(&ann)
.is_some_and(|each| each.task.is_some() && !each.folders.is_empty())
});
let (inbox, run) = cx.update(|_, app| {
let syncing = &mail.read(app).syncs[&ann];
(syncing.folders[0].id, syncing.run)
});
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox, true, cx)));
settle(cx, |app| mail.read(app).syncs[&ann].again);
hang_up.send(()).unwrap();
server.join().unwrap();
settle(cx, |app| {
let syncing = &mail.read(app).syncs[&ann];
syncing.run == run + 1 && syncing.task.is_none() && syncing.failed.is_some()
});
}
#[test]
fn the_account_page_scrolls_to_its_last_folder() {
let dir = tempfile::tempdir().unwrap();
let ann = failing(dir.path());
let names: Vec<String> = (0..40).map(|n| format!("Folder {n:02}")).collect();
let folders: Vec<_> = names.iter().map(|name| (name.as_str(), None)).collect();
let last = *Store::open(dir.path())
.unwrap()
.set_folders(ann, &folders)
.unwrap()
.last()
.unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(ann), cx)));
cx.simulate_resize(size(px(1100.), px(800.)));
settle(cx, |app| {
mail.read(app)
.syncs
.get(&ann)
.is_some_and(|each| each.folders.len() == 40)
});
let selector: &'static str = format!("mail-offline-{}", last.0).leak();
let height = cx.update(|window, _| window.viewport_size().height);
let before = cx
.debug_bounds(selector)
.expect("the last switch is laid out");
assert!(before.bottom() > height, "the folders fit the window");
let at = point(px(400.), height / 2.0);
for _ in 0..20 {
cx.simulate_event(ScrollWheelEvent {
position: at,
delta: ScrollDelta::Pixels(point(px(0.), px(-1000.))),
..Default::default()
});
cx.run_until_parked();
}
let after = cx
.debug_bounds(selector)
.expect("the last switch is laid out");
assert!(
after.bottom() <= height,
"the last switch is out of reach: {after:?}"
);
}
#[test]
fn the_range_names_the_folders_kept_whole() {
let folder = |id, name: &str, offline| LocalFolder {
id: MailboxId(id),
name: name.into(),
role: None,
offline,
};
let cx = TestAppContext::single();
cx.update(|app| {
ely_ui::i18n::install_as("en-US", app);
let rows = |folders: &[LocalFolder]| -> Vec<String> {
imap_range(folders, app)
.into_iter()
.map(|(key, value)| format!("{key}: {value}"))
.collect()
};
let both = [folder(1, "INBOX", true), folder(2, "Sent", false), folder(3, "Work", true)];
assert_eq!(
rows(&both),
[
"Downloaded: Other folders: headers of all mail; text from the last 90 days; attachments when asked",
"Kept whole: Every message in INBOX, Work, with attachments",
]
);
assert_eq!(rows(&[folder(1, "INBOX", true)]), ["Kept whole: Every message in INBOX, with attachments"]);
assert_eq!(
rows(&[folder(2, "Sent", false)]),
["Downloaded: Headers of all mail; text from the last 90 days; attachments when asked"]
);
});
}
#[test]
fn remove_needs_an_open_account() {
let dir = tempfile::tempdir().unwrap();
Store::open(dir.path())
.unwrap()
.add_account(&account("ann@example.com"))
.unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
settle(cx, |app| mail.read(app).accounts().len() == 1);
cx.update(|_, app| assert_eq!(mail.read(app).open, Open::Inboxes));
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
cx.run_until_parked();
cx.update(|_, app| {
let mail = mail.read(app);
assert!(mail.removing.is_none());
assert_eq!(mail.accounts().len(), 1);
});
}
+488
View File
@@ -0,0 +1,488 @@
use std::sync::atomic::Ordering;
use ely_mail_engine::{Auth, Error, Fetched, Key, Protocol, Security, Store, oauth};
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::{Keys, account, adding, closed_port, passed, settle, step_words, type_in, window};
use crate::{adding::Step, boxes::Open, kinds::Kind, mail::Mail};
#[test]
fn an_imap_form_says_what_is_missing() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Imap, cx);
let check = |cx: &mut gpui::VisualTestContext| {
cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx)))
};
check(cx);
assert_eq!(step_words(&mail, cx), "Type the email address.");
type_in(&mail, cx, |f| &f.address, "ann@example.com");
check(cx);
assert_eq!(step_words(&mail, cx), "Type the user name.");
type_in(&mail, cx, |f| &f.user, "ann");
check(cx);
assert_eq!(step_words(&mail, cx), "Type both server names.");
type_in(&mail, cx, |f| &f.incoming, "imap.example.com");
type_in(&mail, cx, |f| &f.outgoing, "smtp.example.com");
type_in(&mail, cx, |f| &f.outgoing_port, "70000");
check(cx);
assert_eq!(
step_words(&mail, cx),
"70000 is not a port; use 1 to 65535."
);
type_in(&mail, cx, |f| &f.outgoing_port, "587");
check(cx);
assert_eq!(step_words(&mail, cx), "Type the password.");
}
#[test]
fn ports_follow_security_but_a_typed_one_stays() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Pop, cx);
let ports = |cx: &mut gpui::VisualTestContext| {
cx.update(|_, app| {
let fields = &mail.read(app).adding.as_ref().unwrap().fields;
(
fields.incoming_port.read(app).text().to_owned(),
fields.outgoing_port.read(app).text().to_owned(),
)
})
};
assert_eq!(ports(cx), ("995".into(), "587".into()));
let secure = |cx: &mut gpui::VisualTestContext, outgoing, security| {
cx.update(|_, app| {
mail.update(app, |mail, cx| {
mail.adding
.as_mut()
.unwrap()
.set_security(outgoing, security, cx)
})
});
};
secure(cx, false, Security::StartTls);
assert_eq!(ports(cx), ("110".into(), "587".into()));
type_in(&mail, cx, |f| &f.outgoing_port, "2525");
secure(cx, true, Security::Tls);
assert_eq!(ports(cx), ("110".into(), "2525".into()));
cx.update(|_, app| mail.update(app, |mail, cx| mail.close_adding(cx)));
adding(&mail, Kind::Imap, cx);
assert_eq!(ports(cx), ("993".into(), "587".into()));
type_in(&mail, cx, |f| &f.incoming_port, "995");
secure(cx, true, Security::Tls);
assert_eq!(ports(cx), ("995".into(), "465".into()));
secure(cx, false, Security::StartTls);
assert_eq!(ports(cx), ("995".into(), "465".into()));
}
#[test]
fn a_check_reports_each_server() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Imap, cx);
type_in(&mail, cx, |f| &f.address, "ann@example.com");
type_in(&mail, cx, |f| &f.user, "ann");
type_in(&mail, cx, |f| &f.password, "pw");
type_in(&mail, cx, |f| &f.incoming, "127.0.0.1");
type_in(&mail, cx, |f| &f.incoming_port, &closed_port().to_string());
type_in(&mail, cx, |f| &f.outgoing, "127.0.0.1");
type_in(&mail, cx, |f| &f.outgoing_port, &closed_port().to_string());
cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx)));
assert_eq!(step_words(&mail, cx), "checking");
settle(cx, |app| {
matches!(
mail.read(app).adding.as_ref().unwrap().step,
Step::Checked { .. }
)
});
cx.update(|_, app| {
let Step::Checked {
checked,
account,
kept,
} = &mail.read(app).adding.as_ref().unwrap().step
else {
unreachable!()
};
assert!(
matches!(checked.incoming, Err(Error::Connect(_))),
"{:?}",
checked.incoming
);
assert!(
matches!(checked.outgoing, Err(Error::Connect(_))),
"{:?}",
checked.outgoing
);
assert_eq!(
(account.user.as_str(), account.protocol, kept.as_str()),
("ann", Protocol::Imap, "pw")
);
let said = crate::add_view::failure(checked.incoming.as_ref().unwrap_err(), app);
assert!(said.starts_with("cannot connect ("), "{said}");
});
type_in(&mail, cx, |f| &f.password, "pw2");
assert_eq!(step_words(&mail, cx), "editing");
}
#[test]
fn an_icloud_account_takes_apples_servers() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::ICloud, cx);
type_in(&mail, cx, |f| &f.address, "ann@icloud.com");
let account = cx
.update(|_, app| mail.read(app).adding.as_ref().unwrap().account(None, app))
.unwrap();
assert_eq!(
(account.incoming.host.as_str(), account.incoming.port),
("imap.mail.me.com", 993)
);
assert_eq!(
(
account.outgoing.host.as_str(),
account.outgoing.port,
account.outgoing.security
),
("smtp.mail.me.com", 587, Security::StartTls)
);
assert_eq!(
(account.user.as_str(), account.auth),
("ann@icloud.com", Auth::Password)
);
}
#[test]
fn a_build_without_a_client_id_says_so() {
if oauth::google().is_ok() {
return eprintln!("SKIP: this build has a Google client ID");
}
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Gmail, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.sign_in(cx)));
assert_eq!(
step_words(&mail, cx),
"Gmail sign-in is not in this build: it was built without a Google client ID."
);
}
#[test]
fn a_refused_keychain_keeps_the_account_and_says_why() {
let dir = tempfile::tempdir().unwrap();
let keys = Keys {
refuse: Some("locked"),
..Keys::default()
};
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
adding(&mail, Kind::Imap, cx);
passed(&mail, account("ann@example.com"), "pw", cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
settle(cx, |app| mail.read(app).adding.is_none());
cx.update(|_, app| {
let mail = mail.read(app);
assert_eq!(mail.accounts().len(), 1);
assert_eq!(mail.open, Open::Account(mail.accounts()[0].0));
assert_eq!(
mail.vault
.get("account:ann@example.com")
.unwrap()
.as_deref(),
Some("pw")
);
});
assert!(keys.kept.lock().unwrap().is_empty());
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
settle(cx, |app| mail.read(app).removing.is_some());
press_remove(&mail, cx);
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
settle(cx, |_| store.try_lock().is_ok());
cx.run_until_parked();
cx.update(|_, app| {
let mail = mail.read(app);
assert_eq!(mail.accounts().len(), 1);
assert_eq!(
mail.vault
.get("account:ann@example.com")
.unwrap()
.as_deref(),
Some("pw")
);
});
}
#[test]
fn an_account_is_kept_and_removed_with_its_secret() {
let dir = tempfile::tempdir().unwrap();
let keys = Keys::default();
{
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
adding(&mail, Kind::Imap, cx);
passed(&mail, account("ann@example.com"), "pw", cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
settle(cx, |app| mail.read(app).adding.is_none());
adding(&mail, Kind::Imap, cx);
passed(&mail, account("ANN@example.com"), "pw", cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
}
assert_eq!(
keys.kept
.lock()
.unwrap()
.get("account:ann@example.com")
.map(String::as_str),
Some("pw")
);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
cx.update(|_, app| assert_eq!(mail.read(app).accounts()[0].1, account("ann@example.com")));
open_first(&mail, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
settle(cx, |app| mail.read(app).removing.is_some());
cx.update(|_, app| {
let removing = mail.read(app).removing.as_ref().unwrap();
assert_eq!(
(removing.address.as_str(), removing.cached.messages),
("ann@example.com", 0)
);
});
press_remove(&mail, cx);
settle(cx, |app| mail.read(app).accounts().is_empty());
assert!(keys.kept.lock().unwrap().is_empty());
}
#[test]
fn a_refused_secret_removal_keeps_the_account() {
let dir = tempfile::tempdir().unwrap();
Store::open(dir.path())
.unwrap()
.add_account(&account("ann@example.com"))
.unwrap();
let keys = Keys {
refuse: Some("locked"),
..Keys::default()
};
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
open_first(&mail, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
settle(cx, |app| mail.read(app).removing.is_some());
press_remove(&mail, cx);
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
settle(cx, |app| mail.read(app).accounts().len() == 1);
assert_eq!(
Store::open(dir.path()).unwrap().accounts().unwrap().len(),
1
);
}
#[test]
fn a_failed_removal_puts_the_secret_back() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path()).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
let inbox = store.mailbox(ann, "INBOX").unwrap();
let raw: &[u8] = b"Subject: Kept
still here
";
let key = Key::Imap {
uidvalidity: 1,
uid: 1,
};
let kept = store.put(inbox, &key, &[], Fetched::Whole(raw)).unwrap();
drop(store);
let keys = Keys::default();
keys.kept
.lock()
.unwrap()
.insert("account:ann@example.com".into(), "pw".into());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
// A folder where the original would be set aside.
let shard = std::fs::read_dir(dir.path().join("content"))
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let name = std::fs::read_dir(&shard)
.unwrap()
.next()
.unwrap()
.unwrap()
.file_name();
std::fs::create_dir(shard.join(format!(".{}.gone", name.to_str().unwrap()))).unwrap();
open_first(&mail, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
settle(cx, |app| mail.read(app).removing.is_some());
press_remove(&mail, cx);
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
settle(cx, |_| store.try_lock().is_ok());
cx.run_until_parked();
assert_eq!(
keys.kept
.lock()
.unwrap()
.get("account:ann@example.com")
.map(String::as_str),
Some("pw")
);
let store = store.lock().unwrap();
assert_eq!(store.accounts().unwrap().len(), 1);
assert_eq!(store.original(kept).unwrap().unwrap(), raw);
}
#[test]
fn two_saves_in_flight_add_an_address_once() {
let dir = tempfile::tempdir().unwrap();
let keys = Keys::default();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
adding(&mail, Kind::Imap, cx);
passed(&mail, account("ann@example.com"), "pw", cx);
cx.update(|window, app| {
mail.update(app, |mail, cx| {
mail.save(cx);
mail.close_adding(cx);
mail.start_adding(window, cx);
})
});
passed(&mail, account("ANN@example.com"), "pw2", cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
// Either save may reach the store first.
settle(cx, |app| {
let mail = mail.read(app);
mail.accounts().len() == 1
&& !matches!(
mail.adding.as_ref().map(|adding| &adding.step),
Some(Step::Saving)
)
});
let won = Store::open(dir.path()).unwrap().accounts().unwrap();
assert_eq!(won.len(), 1);
let (address, secret) = if won[0].1 == account("ann@example.com") {
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
("ann", "pw")
} else {
assert_eq!(won[0].1, account("ANN@example.com"));
cx.update(|_, app| assert!(mail.read(app).adding.is_none()));
("ANN", "pw2")
};
let kept = keys.kept.lock().unwrap();
assert_eq!(kept.len(), 1);
assert_eq!(
kept.get(&format!("account:{address}@example.com"))
.map(String::as_str),
Some(secret)
);
}
#[test]
fn the_keyboard_opens_another_accounts_settings() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path()).unwrap();
store.add_account(&account("ann@example.com")).unwrap();
let bo = store.add_account(&account("bo@example.com")).unwrap();
drop(store);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
// As `mail::open` does.
cx.update(|window, app| window.focus(&mail.read(app).focus.clone(), app));
for _ in 0..2 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
for _ in 0..8 {
cx.simulate_keystrokes("tab space");
cx.run_until_parked();
if cx.update(|_, app| mail.read(app).open) == Open::Account(bo) {
return;
}
}
panic!("no Tab stop let Space open the second account");
}
/// Through the dialog: back to its last stop, Remove.
fn press_remove(mail: &Entity<Mail>, cx: &mut VisualTestContext) {
for _ in 0..2 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
cx.simulate_keystrokes("shift-tab enter");
cx.simulate_event(gpui::KeyUpEvent {
keystroke: gpui::Keystroke::parse("enter").unwrap(),
});
settle(cx, |app| mail.read(app).removing.is_none());
}
/// Opens the first account's page, as a user would.
fn open_first(mail: &Entity<Mail>, cx: &mut VisualTestContext) {
settle(cx, |app| !mail.read(app).accounts().is_empty());
let first = cx.update(|_, app| mail.read(app).accounts()[0].0);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(first), cx)));
}
/// The save lands after a second dialog opened.
fn save_then_reopen(fail: bool) {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Imap, cx);
passed(&mail, account("ann@example.com"), "pw", cx);
if fail {
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
let added = store
.lock()
.unwrap()
.add_account(&account("ann@example.com"));
added.unwrap();
}
cx.update(|window, app| {
mail.update(app, |mail, cx| {
mail.save(cx);
mail.close_adding(cx);
mail.start_adding(window, cx);
let adding = mail.adding.as_mut().unwrap();
adding.set_kind(Kind::Pop, cx);
let address = adding.fields.address.clone();
address.update(cx, |field, cx| {
field.set_text("bob@example.com".to_owned(), cx)
});
})
});
if fail {
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
settle(cx, |_| store.try_lock().is_ok());
cx.run_until_parked();
cx.update(|_, app| assert!(mail.read(app).accounts().is_empty()));
} else {
settle(cx, |app| mail.read(app).accounts().len() == 1);
}
cx.update(|_, app| {
let adding = mail.read(app).adding.as_ref().unwrap();
assert_eq!(adding.kind, Kind::Pop);
assert!(matches!(adding.step, Step::Editing));
assert_eq!(adding.fields.address.read(app).text(), "bob@example.com");
});
}
#[test]
fn a_late_save_leaves_a_newer_dialog_alone() {
save_then_reopen(false);
}
#[test]
fn a_late_failed_save_leaves_a_newer_dialog_alone() {
save_then_reopen(true);
}
+258
View File
@@ -0,0 +1,258 @@
use std::time::{Duration, Instant};
use ely_gpui_component::mail::Mail as Row;
use ely_mail_engine::{AccountId, Fetched, Filter, Key, MailboxId, Role, Store};
use gpui::{
Entity, ScrollDelta, ScrollWheelEvent, SharedString, TestAppContext, VisualTestContext, point,
px, size,
};
use super::{Keys, account, settle, window};
use crate::{
boxes::{self, Open},
mail::Mail,
};
fn message(subject: &str, date: &str) -> Vec<u8> {
format!(
"From: Ann <ann@example.com>\r\nSubject: {subject}\r\nDate: {date}\r\n\
Content-Type: text/plain\r\n\r\nhi\r\n"
)
.into_bytes()
}
fn put(store: &mut Store, mailbox: MailboxId, uid: u32, flags: &[&str], subject: &str, day: u8) {
let key = Key::Imap {
uidvalidity: 1,
uid,
};
let bytes = message(subject, &format!("{day} Jan 2024 10:00:00 +0000"));
store
.put(mailbox, &key, flags, Fetched::Whole(&bytes))
.unwrap();
}
/// Ann has an inbox and an archive, Bob an inbox.
fn seeded(dir: &std::path::Path) -> (AccountId, MailboxId) {
let mut store = Store::open(dir).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
let bob = store.add_account(&account("bob@example.com")).unwrap();
let [ann_inbox, archive] = store
.set_folders(
ann,
&[
("INBOX", Some(Role::Inbox)),
("Archive", Some(Role::Archive)),
],
)
.unwrap()
.try_into()
.unwrap();
let [bob_inbox] = store
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
.unwrap()
.try_into()
.unwrap();
put(&mut store, ann_inbox, 1, &["\\Seen"], "old", 1);
put(&mut store, ann_inbox, 2, &[], "new", 3);
put(&mut store, archive, 3, &[], "kept", 2);
put(&mut store, bob_inbox, 4, &["\\Flagged"], "bob", 2);
(ann, archive)
}
/// The listed subjects, once a list is read.
fn listed(mail: &Entity<Mail>, cx: &mut VisualTestContext) -> Option<Vec<String>> {
cx.update(|_, app| match &mail.read(app).listing.mails {
Some(Ok(rows)) => Some(rows.iter().map(|row| row.subject.to_string()).collect()),
Some(Err(error)) => panic!("the list failed: {error}"),
None => None,
})
}
fn shows(mail: &Entity<Mail>, cx: &mut VisualTestContext, subjects: &[&str]) {
let want: Vec<String> = subjects.iter().map(|each| each.to_string()).collect();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while listed(mail, cx).as_ref() != Some(&want) {
assert!(
std::time::Instant::now() < deadline,
"listed {:?}, not {want:?}",
listed(mail, cx)
);
cx.run_until_parked();
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn key_of(mail: &Entity<Mail>, cx: &mut VisualTestContext, subject: &str) -> SharedString {
cx.update(|_, app| {
let mails = mail.read(app).listing.mails.clone();
let rows = mails.unwrap().unwrap();
rows.iter()
.find(|row| row.subject == subject)
.unwrap()
.key
.clone()
})
}
#[test]
fn every_inbox_is_one_list_newest_first() {
let dir = tempfile::tempdir().unwrap();
seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
cx.update(|_, app| assert_eq!(mail.read(app).open, Open::Inboxes));
shows(&mail, cx, &["new", "bob", "old"]);
settle(cx, |app| mail.read(app).inboxes_unread() == 2);
}
#[test]
fn filters_narrow_the_list() {
let dir = tempfile::tempdir().unwrap();
seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &["new", "bob", "old"]);
let unread = Filter {
unread: true,
..Filter::default()
};
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_filter(unread, cx)));
shows(&mail, cx, &["new", "bob"]);
let flagged = Filter {
unread: true,
flagged: true,
..Filter::default()
};
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_filter(flagged, cx)));
shows(&mail, cx, &["bob"]);
}
#[test]
fn a_folder_lists_its_own_mail() {
let dir = tempfile::tempdir().unwrap();
let (ann, archive) = seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &["new", "bob", "old"]);
let open = Open::Mailbox(ann, archive);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
shows(&mail, cx, &["kept"]);
}
#[test]
fn one_pick_fills_the_reader() {
let dir = tempfile::tempdir().unwrap();
let (ann, archive) = seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &["new", "bob", "old"]);
let (new, bob) = (key_of(&mail, cx, "new"), key_of(&mail, cx, "bob"));
let chosen = |cx: &mut VisualTestContext| {
cx.update(|_, app| {
let chosen = mail.read(app).listing.chosen.as_ref();
chosen.map(|row| row.subject.to_string())
})
};
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(std::slice::from_ref(&new), cx)));
assert_eq!(chosen(cx).as_deref(), Some("new"));
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&[new, bob], cx)));
assert_eq!(chosen(cx), None);
let open = Open::Mailbox(ann, archive);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
cx.update(|_, app| assert!(mail.read(app).listing.selected.is_empty()));
}
#[test]
fn a_rebuilt_mailbox_drops_the_pick() {
let dir = tempfile::tempdir().unwrap();
let (ann, archive) = seeded(dir.path());
// The newest row, whose id SQLite would hand out again.
let mut store = Store::open(dir.path()).unwrap();
put(&mut store, archive, 9, &[], "picked", 4);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
let open = Open::Mailbox(ann, archive);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
shows(&mail, cx, &["picked", "kept"]);
let picked = key_of(&mail, cx, "picked");
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&[picked], cx)));
store.open_mailbox(archive, 2).unwrap();
let key = Key::Imap {
uidvalidity: 2,
uid: 3,
};
let bytes = message("other", "2 Jan 2024 10:00:00 +0000");
store
.put(archive, &key, &[], Fetched::Whole(&bytes))
.unwrap();
drop(store);
cx.update(|_, app| mail.update(app, |mail, cx| mail.read_mail(cx)));
shows(&mail, cx, &["other"]);
cx.update(|_, app| {
let listing = &mail.read(app).listing;
assert!(listing.selected.is_empty(), "kept {:?}", listing.selected);
assert!(listing.chosen.is_none());
});
}
#[test]
fn picking_every_one_of_many_messages_is_quick() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &[]);
let rows: Vec<Row> = (0..100_000)
.map(|n| Row::undated(n.to_string(), "Ann", "hi"))
.collect();
let keys: Vec<SharedString> = rows.iter().map(|row| row.key.clone()).collect();
let listed = boxes::index(rows);
cx.update(|_, app| mail.update(app, |mail, _| mail.listing.show(Some(Ok(listed)))));
let started = Instant::now();
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&keys, cx)));
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&keys, cx)));
let took = started.elapsed();
assert!(took < Duration::from_secs(1), "picking took {took:?}");
cx.update(|_, app| assert_eq!(mail.read(app).listing.selected.len(), 100_000));
}
#[test]
fn the_side_scrolls_past_many_folders_to_the_next_account() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path()).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
let bob = store.add_account(&account("bob@example.com")).unwrap();
let names: Vec<String> = (0..40).map(|n| format!("Folder {n:02}")).collect();
let folders: Vec<_> = names.iter().map(|name| (name.as_str(), None)).collect();
store.set_folders(ann, &folders).unwrap();
store
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
.unwrap();
drop(store);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(bob), cx)));
cx.simulate_resize(size(px(1100.), px(800.)));
settle(cx, |app| {
let syncs = &mail.read(app).syncs;
syncs.get(&ann).is_some_and(|each| each.folders.len() == 40) && syncs.contains_key(&bob)
});
let selector = "item-selected-Account settings";
let height = cx.update(|window, _| window.viewport_size().height);
let before = cx.debug_bounds(selector).expect("Bob's settings row");
assert!(before.bottom() > height, "the side fits the window");
let at = point(px(100.), height / 2.0);
for _ in 0..20 {
cx.simulate_event(ScrollWheelEvent {
position: at,
delta: ScrollDelta::Pixels(point(px(0.), px(-1000.))),
..Default::default()
});
cx.run_until_parked();
}
let after = cx.debug_bounds(selector).expect("Bob's settings row");
assert!(
after.bottom() <= height,
"Bob's settings are out of reach: {after:?}"
);
}
+115
View File
@@ -0,0 +1,115 @@
use ely_mail_engine::{Protocol, Store};
use gpui::TestAppContext;
use super::{Keys, account, adding, closed_port, settle, type_in, window, window_with};
use crate::kinds::Kind;
#[test]
fn every_account_syncs_on_open_and_says_why_it_failed() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path()).unwrap();
let mut ann = account("ann@example.com");
ann.incoming.port = closed_port();
let ann = store.add_account(&ann).unwrap();
let mut pop = ely_mail_engine::Account {
protocol: Protocol::Pop { keep: true },
..account("bo@example.com")
};
pop.incoming.port = closed_port();
let bo = store.add_account(&pop).unwrap();
drop(store);
let keys = Keys::default();
keys.kept
.lock()
.unwrap()
.extend(["ann", "bo"].map(|name| (format!("account:{name}@example.com"), "pw".into())));
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys);
settle(cx, |app| {
[ann, bo].iter().all(|id| {
mail.read(app)
.syncs
.get(id)
.is_some_and(|each| each.task.is_none() && each.synced.is_some())
})
});
cx.update(|_, app| {
for id in [ann, bo] {
let syncing = &mail.read(app).syncs[&id];
let failed = syncing.failed.as_deref().unwrap();
assert!(failed.starts_with("cannot connect"), "{failed}");
assert_eq!(syncing.synced.unwrap().at, None);
}
});
}
#[test]
fn an_account_without_its_secret_says_so() {
let dir = tempfile::tempdir().unwrap();
let ann = Store::open(dir.path())
.unwrap()
.add_account(&account("ann@example.com"))
.unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
settle(cx, |app| {
mail.read(app)
.syncs
.get(&ann)
.is_some_and(|each| each.failed.is_some())
});
cx.update(|_, app| {
let failed = mail.read(app).syncs[&ann].failed.clone().unwrap();
assert_eq!(
failed,
"credential store failed: no saved secret for account:ann@example.com"
);
});
}
#[test]
fn a_missing_tls_is_the_sync_failure() {
let dir = tempfile::tempdir().unwrap();
let ann = Store::open(dir.path())
.unwrap()
.add_account(&account("ann@example.com"))
.unwrap();
let mut cx = TestAppContext::single();
let tls = Err("platform trust store unavailable".to_owned());
let (mail, cx) = window_with(&mut cx, dir.path(), Keys::default(), tls);
settle(cx, |app| {
mail.read(app)
.syncs
.get(&ann)
.is_some_and(|each| each.synced.is_some())
});
cx.update(|_, app| {
let syncing = &mail.read(app).syncs[&ann];
assert_eq!(
syncing.failed.as_deref(),
Some("platform trust store unavailable")
);
assert!(syncing.task.is_none());
});
}
#[test]
fn a_pop_account_keeps_a_server_copy_unless_told() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Pop, cx);
type_in(&mail, cx, |f| &f.address, "bo@example.com");
type_in(&mail, cx, |f| &f.user, "bo");
type_in(&mail, cx, |f| &f.incoming, "pop.example.com");
type_in(&mail, cx, |f| &f.outgoing, "smtp.example.com");
let protocol = |cx: &mut gpui::VisualTestContext| {
cx.update(|_, app| {
let adding = mail.read(app).adding.as_ref().unwrap();
adding.account(None, app).unwrap().protocol
})
};
assert_eq!(protocol(cx), Protocol::Pop { keep: true });
cx.update(|_, app| mail.update(app, |mail, _| mail.adding.as_mut().unwrap().set_keep(false)));
assert_eq!(protocol(cx), Protocol::Pop { keep: false });
}
+284
View File
@@ -0,0 +1,284 @@
//! What the window draws.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
feedback::{Banner, EmptyState, ErrorView},
layout::ScrollArea,
overlays::ConfirmDialog,
primitives::{IconName, Severity},
typography::format,
};
use ely_mail_engine::{AccountId, Auth, LocalFolder, Protocol, Security, Server, sync};
use ely_platform::AppId;
use ely_ui::Shell;
use gpui::{
AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, Window, div,
};
use crate::{
boxes::Open,
mail::{Mail, text},
menu,
};
fn server(server: &Server, cx: &App) -> SharedString {
let security = match server.security {
Security::Tls => "TLS",
Security::StartTls => "STARTTLS",
};
text(
cx,
"mail.account.server",
&[
("host", &server.host),
("port", &server.port.to_string()),
("security", security),
],
)
}
pub fn auth(auth: Auth, cx: &App) -> SharedString {
let key = match auth {
Auth::Password => "mail.auth.password",
Auth::Google => "mail.auth.google",
Auth::Microsoft => "mail.auth.microsoft",
};
text(cx, key, &[])
}
impl Mail {
fn add_button(&self, cx: &App) -> Button {
Button::new("mail-add", text(cx, "mail.add.open", &[]))
.variant(ButtonVariant::Primary)
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::AddAccount), cx))
}
fn body(&self, cx: &mut Context<Self>) -> AnyElement {
let error = |key: &str, detail: &str, cx: &App| {
ErrorView::new("mail-error", IconName::TriangleAlert, text(cx, key, &[]))
.detail(detail.to_owned())
.into_any_element()
};
if let Err(detail) = &self.store {
return error("mail.store.unopened", detail, cx);
}
let accounts = match &self.accounts {
None => return div().into_any_element(),
Some(Err(detail)) => return error("mail.store.unread", detail, cx),
Some(Ok(accounts)) => accounts,
};
if accounts.is_empty() {
return EmptyState::new("mail-empty", IconName::Mail, text(cx, "mail.empty", &[]))
.action(self.add_button(cx))
.into_any_element();
}
let Open::Account(open) = self.open else {
return self.panes(cx);
};
let Some((id, account)) = accounts.iter().find(|(id, _)| *id == open) else {
log::error!("mail: account {} open but not listed", open.0);
return error("mail.store.unread", "", cx);
};
let label = |key: &str| text(cx, key, &[]);
let incoming = format!(
"{} · {}",
protocol_name(account.protocol),
server(&account.incoming, cx)
);
let details = DescriptionList::new()
.text(label("mail.account.address"), account.address.clone())
.text(label("mail.account.user"), account.user.clone())
.text(label("mail.account.incoming"), incoming)
.text(
label("mail.account.outgoing"),
server(&account.outgoing, cx),
)
.text(label("mail.account.auth"), auth(account.auth, cx));
let (id, protocol) = (*id, account.protocol);
let (details, failed) = self.sync_rows(id, protocol, details, cx);
let remove = Button::new("mail-remove", label("mail.remove.open"))
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::RemoveAccount), cx));
let page = div()
.p_6()
.flex()
.flex_col()
.gap_4()
.max_w_128()
.children(self.paused_banner(cx))
.children(failed)
.child(details)
.child(self.sync_buttons(id, cx))
.children(self.offline_switches(id, protocol, cx))
.child(div().child(remove));
ScrollArea::new("mail-account-scroll")
.vertical()
.size_full()
.child(page)
.into_any_element()
}
/// When it last synced, what is here, why it failed.
fn sync_rows(
&self,
id: AccountId,
protocol: Protocol,
details: DescriptionList,
cx: &App,
) -> (DescriptionList, Option<Banner>) {
let Some(syncing) = self.syncs.get(&id) else {
return (details, None);
};
let label = |key: &str| text(cx, key, &[]);
let last = match (&syncing.task, syncing.synced.and_then(|synced| synced.at)) {
(Some(_), _) => label("mail.sync.running"),
(None, Some(at)) => local_time(at).into(),
(None, None) => label("mail.sync.never"),
};
let mut details = details.text(label("mail.sync.last"), last);
if let Protocol::Pop { keep } = protocol {
let server = label(if keep {
"mail.sync.kept"
} else {
"mail.sync.deleted"
});
details = details
.text(label("mail.sync.range"), label("mail.sync.range_pop"))
.text(label("mail.sync.server"), server);
} else {
for (key, value) in imap_range(&syncing.folders, cx) {
details = details.text(key, value);
}
}
if let (Some(synced), Protocol::Imap) = (syncing.synced, protocol) {
for (key, n) in [
("mail.sync.uncached", synced.uncached),
("mail.sync.partial", synced.partial),
] {
let value = format!("{key}_value");
let words = ely_ui::i18n::counted(cx, &value, n, &[("n", &n.to_string())]);
details = details.text(label(key), words);
}
}
let failed = syncing.failed.as_ref().map(|error| {
Banner::new(
"mail-sync-failed",
Severity::Danger,
text(cx, "mail.sync.failed", &[("error", error)]),
)
});
(details, failed)
}
fn remove_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let removing = self.removing.as_ref()?;
let this = cx.weak_entity();
let dismiss = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |mail, cx| {
mail.removing = None;
cx.notify();
})
.ok();
}
};
let size = format::file_size(removing.cached.bytes, false);
let count = removing.cached.messages;
let args = [
("address", removing.address.as_str()),
("n", &count.to_string()),
("size", &size),
];
let message = ely_ui::i18n::counted(cx, "mail.remove.message", count, &args);
let dialog = ConfirmDialog::new(
"mail-remove",
text(cx, "mail.remove.title", &args),
message,
dismiss,
)
.confirm(text(cx, "mail.remove.go", &[]))
.destructive()
.on_confirm({
let removing = removing.clone();
move |_, cx| {
let removing = removing.clone();
this.update(cx, |mail, cx| mail.remove(removing, cx)).ok();
}
});
Some(dialog.into_any_element())
}
}
/// What the rest download, then folders kept whole.
pub fn imap_range(folders: &[LocalFolder], cx: &App) -> Vec<(SharedString, SharedString)> {
let (whole, rest): (Vec<_>, Vec<_>) = folders.iter().partition(|folder| folder.offline);
let label = |key: &str| text(cx, key, &[]);
let days = sync::BODY_DAYS.to_string();
let mut rows = Vec::new();
if whole.is_empty() || !rest.is_empty() {
let key = match whole.is_empty() {
true => "mail.sync.range_value",
false => "mail.sync.range_rest",
};
rows.push((label("mail.sync.range"), text(cx, key, &[("days", &days)])));
}
if !whole.is_empty() {
let sep = label("mail.sync.whole_sep");
let names: Vec<&str> = whole.iter().map(|folder| folder.name.as_str()).collect();
let value = text(cx, "mail.sync.whole_value", &[("names", &names.join(&sep))]);
rows.push((label("mail.sync.whole"), value));
}
rows
}
pub fn protocol_name(protocol: Protocol) -> &'static str {
match protocol {
Protocol::Imap => "IMAP",
Protocol::Pop { .. } => "POP3",
}
}
impl Render for Mail {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let left = (!self.accounts().is_empty()).then(|| self.side.clone().into());
let read_only = self.read_only.as_ref().map(|reason| {
Banner::new(
"mail-read-only",
Severity::Warning,
text(cx, "mail.store.read_only", &[("error", reason)]),
)
});
let shell = Shell::new(AppId::Mail.name(), &self.focus)
.left(left)
.children(read_only)
.child(self.body(cx));
div()
.size_full()
.key_context(menu::CONTEXT)
.track_focus(&self.focus)
.on_action(
cx.listener(|mail, _: &menu::AddAccount, window, cx| mail.start_adding(window, cx)),
)
.on_action(cx.listener(|mail, _: &menu::RemoveAccount, _, cx| mail.ask_remove(cx)))
.on_action(cx.listener(|mail, _: &menu::SyncAll, _, cx| mail.sync_all(cx)))
.child(shell)
.children(self.add_dialog(cx))
.children(self.remove_dialog(cx))
}
}
/// Local time to the minute.
pub fn local_time(at: i64) -> String {
match jiff::Timestamp::from_second(at) {
Ok(at) => at
.to_zoned(jiff::tz::TimeZone::system())
.strftime("%Y-%m-%d %H:%M")
.to_string(),
Err(error) => {
log::error!("mail: a sync time out of range: {error}");
at.to_string()
}
}
}
+2 -3
View File
@@ -10,7 +10,7 @@ mod tests;
use std::{io, path::Path};
use ely_gpui_component::documents::markdown_options;
use ely_gpui_component::documents::markdown_tree;
pub use files::{Format, Target, write};
pub use pictures::{Pictures, picture_file};
@@ -43,8 +43,7 @@ pub fn body(text: &str, beside: Option<&Path>, pictures: &Pictures) -> io::Resul
/// `text`'s body; `placed` keeps what it copied.
fn page_of(text: &str, placed: &mut pictures::Placed) -> io::Result<Page> {
let tree = markdown::to_mdast(text, &markdown_options())
.map_err(|error| io::Error::other(error.to_string()))?;
let tree = markdown_tree(text);
let mut page = Page::default();
html::write(&tree, text, placed, &mut page)?;
log::info!(
+11 -4
View File
@@ -3,14 +3,13 @@
use std::io;
use ely_gpui_component::documents::{
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_options,
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_tree,
};
use markdown::mdast::Node;
/// `text` as plain text, a blank line between blocks.
pub fn plain(text: &str) -> io::Result<String> {
let tree = markdown::to_mdast(text, &markdown_options())
.map_err(|error| io::Error::other(error.to_string()))?;
let tree = markdown_tree(text);
let reader = MarkdownReader::joined(text, &tree);
let mut blocks = Vec::new();
// Alerts and `[toc]` live only at the root.
@@ -75,7 +74,15 @@ fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec<String>) {
block(reader, child, blocks);
}
}
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(reader, node)),
Node::Paragraph(_) => {
let mut read = Vec::new();
reader.block(node, &mut read);
match read.as_slice() {
[MarkdownBlock::Math(tex)] => blocks.push(tex.clone()),
_ => blocks.push(words(reader, node)),
}
}
Node::Heading(_) => blocks.push(words(reader, node)),
Node::Html(_) => {
let mut read = Vec::new();
reader.block(node, &mut read);
+16 -2
View File
@@ -1,7 +1,7 @@
//! Exports read the file as the visual mode does (R4.4).
use super::{Pictures, page, plain};
use crate::output::{Page, html::write, markdown_options, pictures::Placed};
use crate::output::{Page, html::write, markdown_tree, pictures::Placed};
fn html(text: &str) -> String {
page(text, None, &Pictures::Inline).html
@@ -160,7 +160,7 @@ fn notes_under_deep_marks_write_in_linear_time() {
"<b>".repeat(count),
"x[^n] ".repeat(count)
);
let tree = markdown::to_mdast(&source, &markdown_options()).expect("parsed");
let tree = markdown_tree(&source);
let mut placed = Placed::new(None, &Pictures::Inline);
let start = std::time::Instant::now();
write(&tree, &source, &mut placed, &mut Page::default()).expect("written");
@@ -237,3 +237,17 @@ fn a_soft_break_joins_in_exports_as_typora_exports_it() {
assert!(page.contains("<p>你好 测试<br>end</p>"), "{page}");
assert_eq!(text("你好\n测试\n"), "你好 测试\n");
}
#[test]
fn tex_delimiters_export_as_formulas() {
let source = r"a \(x^2\) b
\[
y+1
\]
";
let page = page(source, None, &Pictures::Inline);
assert_eq!(page.html.matches("<svg").count(), 2, "{}", page.html);
assert!(page.formulas.is_empty(), "{:?}", page.formulas);
assert_eq!(text(source), "a x^2 b\n\ny+1\n");
}
+3 -3
View File
@@ -36,7 +36,7 @@ fn fraction(key: &TileKey, size: (f32, f32)) -> [f32; 4] {
impl PdfView {
/// The pages in view and the tiles each needs.
pub fn showing(&self, sf: f32, dark: bool) -> Vec<Showing> {
pub fn showing(&self, sf: f32) -> Vec<Showing> {
let laid = self.laid_out();
let (room_w, room_h) = (f32::from(self.room.size.width), f32::from(self.room.size.height));
let (left, top) = (-f32::from(self.scroll.offset().x), -f32::from(self.scroll.offset().y));
@@ -50,7 +50,7 @@ impl PdfView {
let keys = ys
.flat_map(|ty| xs.clone().map(move |tx| (tx, ty)))
.map(|(tx, ty)| {
let key = TileKey { page: place.page, scale, rotate: self.rotate, dark, tx, ty };
let key = TileKey { page: place.page, scale, rotate: self.rotate, tx, ty };
(key, [tx * TILE, ty * TILE, TILE.min(dw - tx * TILE), TILE.min(dh - ty * TILE)])
})
.collect();
@@ -123,7 +123,7 @@ mod tests {
#[test]
fn a_tile_keeps_its_place_as_a_fraction_of_its_page() {
let key = TileKey { page: 0, scale: 2000, rotate: 0, dark: false, tx: 1, ty: 0 };
let key = TileKey { page: 0, scale: 2000, rotate: 0, tx: 1, ty: 0 };
// A 612 by 792 page at 2x: 1224 by 1584.
assert_eq!(fraction(&key, (612.0, 792.0)), [512.0 / 1224.0, 0.0, 512.0 / 1224.0, 512.0 / 1584.0]);
let turned = TileKey { rotate: 90, ..key };
+5 -9
View File
@@ -16,14 +16,13 @@ use crate::view::{PdfView, Reported};
/// A tile's side in device pixels.
pub const TILE: i32 = 512;
/// A page tile at one scale, turn and theme.
/// A page tile at one scale and turn.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct TileKey {
pub page: usize,
/// Device pixels a point, times a thousand.
pub scale: u32,
pub rotate: i32,
pub dark: bool,
pub tx: i32,
pub ty: i32,
}
@@ -123,17 +122,14 @@ impl PdfView {
/// Pages in view and their tiles; missing ones asked.
pub fn page_view(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let (sf, dark) = (window.scale_factor(), cx.theme().is_dark());
let (sunken, paper) = {
let colors = &cx.theme().colors;
(colors.sunken, if dark { colors.surface } else { colors.paper })
};
let sf = window.scale_factor();
let (sunken, paper) = (cx.theme().colors.sunken, gpui::white());
let laid = self.laid_out();
if let Some((x, y)) = self.go_to.take() {
self.scroll.set_offset(point(px(-x), px(-y)));
}
let top = -f32::from(self.scroll.offset().y);
let showing = self.showing(sf, dark);
let showing = self.showing(sf);
// The current page: first showing more than a sliver.
if let Some(first) = showing.iter().map(|s| s.place).find(|p| p.y + p.h > top + GAP * 3.0) {
let paired = showing.iter().any(|s| s.place.page == self.current) && first.page / 2 == self.current / 2;
@@ -266,7 +262,7 @@ impl PdfView {
self.painting = true;
let (doc, scale, version) = (self.doc, key.scale as f32 / 1000.0, self.version);
self.off(
move |worker| worker.render(doc, key.page, (scale, key.rotate), rect, key.dark),
move |worker| worker.render(doc, key.page, (scale, key.rotate), rect),
move |view, painted, cx| {
view.painting = false;
if view.version != version {
+13 -13
View File
@@ -100,11 +100,11 @@ impl PdfView {
}
/// Asks for the first missing picture in `rows`.
fn ask_thumb(&mut self, rows: Range<usize>, (sf, dark): (f32, bool), cx: &mut Context<Self>) {
fn ask_thumb(&mut self, rows: Range<usize>, sf: f32, cx: &mut Context<Self>) {
if self.thumbing {
return;
}
let due = |page: &usize| !self.thumbs.contains_key(&(*page, dark)) || self.thumbs_stale.contains(&(*page, dark));
let due = |page: &usize| !self.thumbs.contains_key(page) || self.thumbs_stale.contains(page);
let Some(page) = rows.clone().find(|page| due(page) && !self.thumbs_failed.contains(page)) else {
return;
};
@@ -114,7 +114,7 @@ impl PdfView {
let (doc, version) = (self.doc, self.version);
self.thumbing = true;
self.off(
move |worker| worker.render(doc, page, (scale, 0), tile, dark),
move |worker| worker.render(doc, page, (scale, 0), tile),
move |view, painted, cx| {
view.thumbing = false;
if view.version != version {
@@ -128,8 +128,8 @@ impl PdfView {
});
match painted.map(|bgra| crate::pages::picture((tile[2] as u32, tile[3] as u32), bgra)) {
Ok(Some(image)) => {
view.thumbs_stale.remove(&(page, dark));
if let Some(old) = view.thumbs.insert((page, dark), image) {
view.thumbs_stale.remove(&page);
if let Some(old) = view.thumbs.insert(page, image) {
view.retired.push(old);
}
}
@@ -149,18 +149,18 @@ impl PdfView {
impl PdfView {
/// The picture a row draws; an old one until repainted.
pub fn picture(&self, page: usize, dark: bool) -> Option<Arc<RenderImage>> {
self.thumbs.get(&(page, dark)).cloned()
pub fn picture(&self, page: usize) -> Option<Arc<RenderImage>> {
self.thumbs.get(&page).cloned()
}
}
/// One row: the page number and its picture.
fn row(view: &Entity<PdfView>, page: usize, dark: bool, cx: &App) -> gpui::AnyElement {
fn row(view: &Entity<PdfView>, page: usize, cx: &App) -> gpui::AnyElement {
let read = view.read(cx);
let colors = &cx.theme().colors;
let (w, h) = read.sizes[page];
let picked = read.picked.contains(&page);
let picture = read.picture(page, dark);
let picture = read.picture(page);
let count = read.sizes.len();
let label = Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]);
let (clicked, pressed, dropped) = (view.clone(), view.clone(), view.clone());
@@ -210,7 +210,7 @@ fn row(view: &Entity<PdfView>, page: usize, dark: bool, cx: &App) -> gpui::AnyEl
.h(px(height))
.border_1()
.border_color(if picked { colors.focus } else { colors.border })
.bg(if dark { colors.surface } else { colors.paper })
.bg(gpui::white())
.when_some(picture, |frame, picture| frame.child(img(picture).size_full())),
)
.child(div().text_xs().text_color(colors.fg_subtle).child((page + 1).to_string()))
@@ -258,12 +258,12 @@ impl Render for Side {
Pane::Notes => crate::notelist::list(&view, cx),
Pane::Files => crate::attach::list(&view, cx),
Pane::Pages => {
let shown = (window.scale_factor(), cx.theme().is_dark());
let sf = window.scale_factor();
let count = view.read(cx).sizes.len();
let rows = view.clone();
let list = uniform_list("pdf-thumbs", count, move |range, _, cx| {
rows.update(cx, |view, cx| view.ask_thumb(range.clone(), shown, cx));
range.map(|page| row(&rows, page, shown.1, cx)).collect()
rows.update(cx, |view, cx| view.ask_thumb(range.clone(), sf, cx));
range.map(|page| row(&rows, page, cx)).collect()
})
.size_full();
div()
+8 -9
View File
@@ -188,13 +188,13 @@ fn tiles_beside_the_view_are_neither_asked_nor_kept() {
/// Flags any notify leaving a shown page with nothing drawn.
fn never_bare(cx: &mut VisualTestContext, view: &Entity<PdfView>) -> (gpui::Subscription, std::rc::Rc<std::cell::Cell<bool>>) {
let (sf, dark) = cx.update(|window, app| (window.scale_factor(), ely_gpui_component::theme::ActiveTheme::theme(app).is_dark()));
let sf = cx.update(|window, _| window.scale_factor());
let bare = std::rc::Rc::new(std::cell::Cell::new(false));
let seen = bare.clone();
let watch = cx.update(|_, app| {
app.observe(view, move |view, app| {
let read = view.read(app);
if read.showing(sf, dark).iter().any(|page| read.pieces(page).is_empty()) {
if read.showing(sf).iter().any(|page| read.pieces(page).is_empty()) {
seen.set(true);
}
})
@@ -248,10 +248,10 @@ fn a_new_scale_keeps_the_old_tiles_until_new_ones_land() {
cx.simulate_resize(gpui::size(px(700.), px(800.)));
// The new size lays out before any new tile lands.
cx.run_until_parked();
let (sf, dark) = cx.update(|window, app| (window.scale_factor(), ely_gpui_component::theme::ActiveTheme::theme(app).is_dark()));
let sf = cx.update(|window, _| window.scale_factor());
let now = cx.update(|_, app| {
let read = view.read(app);
read.showing(sf, dark).iter().map(|page| read.pieces(page).len()).collect::<Vec<_>>()
read.showing(sf).iter().map(|page| read.pieces(page).len()).collect::<Vec<_>>()
});
assert!(now.iter().all(|count| *count > 0), "drawn right after the resize: {now:?}");
settle(cx, |app| view.read(app).laid_out().scale != scale && repainted(view.read(app)));
@@ -315,7 +315,7 @@ fn old_tiles_are_painted_and_freed_with_their_tab() {
let shown = cx.update(|window, app| {
let read = view.read(app);
let room = read.room;
read.showing(window.scale_factor(), false).iter().map(|s| {
read.showing(window.scale_factor()).iter().map(|s| {
let (left, top) = (-f32::from(read.scroll.offset().x), -f32::from(read.scroll.offset().y));
let w = (s.place.x + s.place.w).min(left + f32::from(room.size.width)) - s.place.x.max(left);
let h = (s.place.y + s.place.h).min(top + f32::from(room.size.height)) - s.place.y.max(top);
@@ -333,9 +333,8 @@ fn an_edit_keeps_page_pictures_until_they_repaint() {
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["Hello"])])], true);
let view = shown(cx, &viewer);
let dark = cx.update(|_, app| ely_gpui_component::theme::ActiveTheme::theme(app).is_dark());
settle(cx, |app| view.read(app).picture(0, dark).is_some() && repainted(view.read(app)));
let old = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
settle(cx, |app| view.read(app).picture(0).is_some() && repainted(view.read(app)));
let old = cx.update(|_, app| view.read(app).picture(0).expect("a picture"));
// A picture on its way holds back the repaint.
cx.update(|_, app| view.update(app, |view, _| view.thumbing = true));
cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![note()], cx)));
@@ -349,7 +348,7 @@ fn an_edit_keeps_page_pictures_until_they_repaint() {
cx.notify()
}));
settle(cx, |app| view.read(app).thumbs_stale.is_empty());
let new = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
let new = cx.update(|_, app| view.read(app).picture(0).expect("a picture"));
assert!(!std::sync::Arc::ptr_eq(&new, &old), "the new picture replaces it");
super::frames(cx);
assert!(cx.update(|window, _| window.has_image_atlas_entry(&new)), "and is drawn");
+1 -1
View File
@@ -158,7 +158,7 @@ fn scanned(dir: &std::path::Path) -> std::path::PathBuf {
let doc = worker.open(&source).expect("opens").doc;
let s: f32 = 200.0 / 72.0;
let (w, h) = ((612.0 * s).ceil() as i32, (792.0 * s).ceil() as i32);
let bgra = worker.render(doc, 0, (s, 0), [0, 0, w, h], false).expect("draws").bgra;
let bgra = worker.render(doc, 0, (s, 0), [0, 0, w, h]).expect("draws").bgra;
let gray: Vec<u8> = bgra.chunks_exact(4).map(|p| ((u16::from(p[0]) + u16::from(p[1]) + u16::from(p[2])) / 3) as u8).collect();
let path = dir.join("scan.pdf");
std::fs::write(&path, scan((612.0, 792.0), (w as u32, h as u32), &gray)).expect("writes");
+19 -1
View File
@@ -1,6 +1,6 @@
//! Password prompts, zoomed pairs, jumps across (C11.1).
use ely_office::{Dialog, Dialogs as _, Tabs as _};
use ely_office::{Dialog, Dialogs as _, Opening as _, Tabs as _};
use gpui::{Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px};
use super::{file, frames, settle, shown, windowed};
@@ -144,3 +144,21 @@ fn ctrl_and_a_wheel_notch_step_the_zoom_once() {
frames(cx);
assert_eq!(read(cx).0, Zoom::Scale(crate::keys::stepped(1.0, false)));
}
#[test]
fn a_dark_app_shows_the_page_as_the_file_paints_it() {
use ely_gpui_component::theme::{Mode, Theme};
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[], true);
cx.update(|_, app| Theme::set_mode_now(Mode::Dark, app));
let path = file(dir.path(), "one.pdf", &[(612.0, 792.0, &["alpha"])]);
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.open_file(path, cx)));
settle(cx, |app| viewer.read(app).active_view().is_some());
let view = shown(cx, &viewer);
let corner = |app: &gpui::App| view.read(app).tiles.iter().find(|(key, _)| key.tx == 0 && key.ty == 0).map(|(_, image)| image.clone());
settle(cx, |app| corner(app).is_some());
let tile = cx.update(|_, app| corner(app)).expect("the first tile");
let bytes = tile.as_bytes(0).expect("its pixels");
assert_eq!(&bytes[..3], &[255, 255, 255], "the paper stays white");
}
+3 -3
View File
@@ -87,11 +87,11 @@ pub struct PdfView {
pub saved: String,
/// Saves so far: a read from before one is stale.
pub saves: u64,
/// Page pictures for the side panel, by page and darkness.
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
/// Page pictures for the side panel, by page.
pub thumbs: HashMap<usize, Arc<RenderImage>>,
pub thumbs_failed: HashSet<usize>,
/// Pictures from before an edit; shown until repainted.
pub thumbs_stale: HashSet<(usize, bool)>,
pub thumbs_stale: HashSet<usize>,
/// A picture is on its way from the worker.
pub thumbing: bool,
/// Pages picked in the side panel, and the anchor.
+4 -4
View File
@@ -100,12 +100,12 @@ impl Host for Deck {
view.turn_parts();
});
}
// Pictures follow the kernel's light or dark scheme.
let dark = view.read(cx).dark();
// Pictures before the scheme landed are repainted.
let lit = view.read(cx).lit();
if let Some(tab) = self.tab_mut(id)
&& tab.extra.dark != dark
&& tab.extra.lit != lit
{
tab.extra.dark = dark;
tab.extra.lit = lit;
self.repaint_all(id, cx);
}
self.offer_menu(id, view, cx);
+2 -2
View File
@@ -50,8 +50,8 @@ pub struct Extra {
pub painting: bool,
/// The parts are masters.
pub masters: bool,
/// The scheme the pictures were painted with.
pub dark: Option<bool>,
/// The pictures were painted with Ely's scheme.
pub lit: bool,
/// The view was fitted to the slide once.
pub fitted: bool,
}
+1 -1
View File
@@ -267,7 +267,7 @@ impl Deck {
let range = output.pages();
let (w, h) = tab.extra.size;
let size = (PICTURE, (f64::from(PICTURE) * h as f64 / w.max(1) as f64).round() as u32);
let (copy, light) = (tab.work.join("output.odp"), ely_lok::scheme(false));
let (copy, light) = (tab.work.join("output.odp"), ely_lok::SCHEME);
let (shown, opened) = (folder.display().to_string(), folder.clone());
log::info!("show: writes slide pictures");
self.read_in(
+2 -2
View File
@@ -252,7 +252,7 @@ impl Deck {
// A show starts from black; effects play on the page.
let behind = match &preview.playing {
Playing::Transition { .. } => gpui::black(),
Playing::Effects { .. } => ely_lok::paper(cx),
Playing::Effects { .. } => ely_lok::paper(),
};
let content = match &preview.playing {
Playing::Effects {
@@ -266,7 +266,7 @@ impl Deck {
} else {
(t / preview.length) as f32
};
let paper = ely_lok::paper(cx);
let paper = ely_lok::paper();
// Exits are not read; the old slide shows everything.
let from = (from.as_deref())
.map(|from| compose::slide(from, |_| true, frame, paper).into_any_element());
+1 -1
View File
@@ -159,7 +159,7 @@ impl Render for Presenter {
let big = stage::fit(show.slide, (room.width * 0.6, room.height * 0.8));
let small = stage::fit(show.slide, (room.width * 0.3, room.height * 0.35));
let next = match show.upcoming() {
Some(part) => stage::still(show, part, small, cx),
Some(part) => stage::still(show, part, small),
None => div().w(small.0).h(small.1).into_any_element(),
};
let notes = show.part().map(|part| show.slides[part].notes.clone());
+3 -3
View File
@@ -27,7 +27,7 @@ pub fn stage(show: &Show, (w, h): (Pixels, Pixels), cx: &App) -> AnyElement {
let Some(stack) = show.stack(part) else {
return black((w, h));
};
let paper = ely_lok::paper(cx);
let paper = ely_lok::paper();
let t = show.entered.elapsed().as_secs_f64();
let slide = &show.slides[part];
let shown = match &show.leaving {
@@ -76,9 +76,9 @@ pub fn stage(show: &Show, (w, h): (Pixels, Pixels), cx: &App) -> AnyElement {
}
/// A slide as it first shows, still.
pub fn still(show: &Show, part: usize, size: (Pixels, Pixels), cx: &App) -> AnyElement {
pub fn still(show: &Show, part: usize, size: (Pixels, Pixels)) -> AnyElement {
match show.stack(part) {
Some(stack) => compose::slide(stack, |visible| visible, size, ely_lok::paper(cx))
Some(stack) => compose::slide(stack, |visible| visible, size, ely_lok::paper())
.into_any_element(),
None => black(size),
}
+4 -7
View File
@@ -59,16 +59,14 @@ pub fn discard(worker: Arc<Mutex<Worker>>, doc: Doc, copy: PathBuf, cx: &App) {
fn copy(
worker: &mut Worker,
doc: &Doc,
(copy, scheme): (&std::path::Path, Option<&'static str>),
copy: &std::path::Path,
) -> io::Result<(usize, Doc, Vec<ShowSlide>)> {
let current = doc.sheets(worker)?.current;
let url = ely_lok::file_url(copy);
doc.save_as(worker, &url, "odp")?;
let shown = worker.load(&url)?;
let read = |worker: &mut Worker| -> io::Result<Vec<ShowSlide>> {
if let Some(scheme) = scheme {
worker.run(&shown, "view.theme", &[scheme])?;
}
worker.run(&shown, "view.theme", &[ely_lok::SCHEME])?;
ely_lok::show::slide_show(&worker.values(&shown, ely_lok::show::SLIDE_SHOW)?)
};
match read(worker) {
@@ -161,14 +159,13 @@ impl Deck {
self.show_starting = false;
return;
};
let scheme = tab.view.read(cx).dark().map(ely_lok::scheme);
let path = tab.work.join(format!("show-{}.odp", self.shows));
self.shows += 1;
let into = path.clone();
let worker = self.core.worker.clone();
self.read_in(
id,
move |worker, doc| copy(worker, doc, (&into, scheme)),
move |worker, doc| copy(worker, doc, &into),
move |deck, read, _, cx| {
deck.show_starting = false;
let (current, doc, slides) = match read {
@@ -382,7 +379,7 @@ mod tests {
let deck = deck.read(app);
let tab = deck.active_tab().expect("a tab");
let mut worker = deck.core.worker.lock().expect("the worker lock");
let (_, doc, slides) = super::copy(&mut worker, &tab.doc, (&path, None)).expect("a copy");
let (_, doc, slides) = super::copy(&mut worker, &tab.doc, &path).expect("a copy");
super::Starting { tab: tab.id, slides, first: 0, doc, copy: path.clone(), audience: 99, presenter: false }
});
update(&mut cx, &deck, |deck, cx| {
+1 -1
View File
@@ -363,7 +363,7 @@ impl Book {
Ok(Some(Clip::Html { html, .. })) => Some(html),
Ok(_) => None,
Err(error) => {
let text = Book::text(cx, "table.data.clipboard", &[("error", &error.to_string())]);
let text = Book::text(cx, "table.data.clipboard", &[("error", &format!("{error:#}"))]);
return self.toast(text, Severity::Danger, cx);
}
};
+5
View File
@@ -65,6 +65,11 @@ impl Book {
.bg(sunken)
.on_mouse_move(cx.listener(Self::on_drag_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_drag_end))
.on_mouse_up_out(MouseButton::Left, cx.listener(|book, _, _, cx| {
if book.drag.take().is_some() {
cx.notify();
}
}))
.child(div().flex().child(corner).child(column_strip))
.child(
div().flex_1().min_h_0().flex().child(row_strip).child(
+6 -3
View File
@@ -9,8 +9,8 @@ use ely_gpui_component::{
use ely_lok::{LokView, sheet};
use ely_office::prelude::*;
use gpui::{
AnyElement, Context, Entity, IntoElement, ParentElement as _, Pixels, Point, Styled as _, div,
px,
AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
Point, Styled as _, div, px,
};
use crate::book::Book;
@@ -112,6 +112,7 @@ impl Book {
.absolute()
.left(corner.x)
.top(corner.y)
.block_mouse_except_scroll()
.child(
IconButton::new("table-choices-open", IconName::ChevronDown)
.variant(ButtonVariant::Ghost)
@@ -121,7 +122,8 @@ impl Book {
book.offer_choices(id, (column, row), cx)
})),
)
.h(px(size.max(16.0)))
.h(px(size))
.min_h(cx.theme().control_height(ControlSize::Sm))
.into_any_element(),
);
}
@@ -137,6 +139,7 @@ impl Book {
.absolute()
.left(below.x)
.top(below.y)
.block_mouse_except_scroll()
.max_w(theme.tooltip_max_width())
.px_2()
.py_1()
+15 -8
View File
@@ -109,18 +109,12 @@ impl Book {
let Some(tab) = self.active_tab() else {
return div().into_any_element();
};
let Some(choices) = visible(&tab.extra.sheets) else {
let Some(tabs) = strip(tab.id, &tab.extra.sheets) else {
return div().into_any_element();
};
let this = cx.entity();
let picked = this.clone();
let tabs = SheetTabs::new(
"table-sheets",
choices,
tab.extra.sheets.current.to_string(),
)
.placement(TabPlacement::Bottom)
.on_change(move |value, _, cx| {
let tabs = tabs.on_change(move |value, _, cx| {
let index: usize = value.parse().expect("sheet tabs are indexes");
picked.update(cx, |book, cx| book.show_sheet(index, cx));
})
@@ -310,6 +304,15 @@ impl Book {
}
}
/// Workbook `id`'s sheet tabs; each workbook keeps its own scroll.
fn strip(id: u64, sheets: &ely_lok::Sheets) -> Option<SheetTabs> {
let choices = visible(sheets)?;
Some(
SheetTabs::new(("table-sheets", id), choices, sheets.current.to_string())
.placement(TabPlacement::Bottom),
)
}
/// Visible sheets as tabs, once the shown one is known.
fn visible(sheets: &ely_lok::Sheets) -> Option<Vec<Choice>> {
let shown = sheets.list.get(sheets.current)?;
@@ -327,6 +330,10 @@ fn visible(sheets: &ely_lok::Sheets) -> Option<Vec<Choice>> {
)
}
#[cfg(test)]
#[path = "tests/strip.rs"]
mod strip_tests;
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -394,6 +394,7 @@ fn a_delete_stays_with_its_workbook() {
}
mod analysis;
mod aids;
mod data;
mod menu;
mod names;
+126
View File
@@ -0,0 +1,126 @@
use super::data::{cells, open, run};
use super::*;
use ely_gpui_component::theme::{ActiveTheme as _, ControlSize};
use ely_lok::sheet::Validation;
use gpui::{Modifiers, MouseButton, ParentElement as _, Styled as _, VisualTestContext, point, px};
struct Grid(Entity<Book>);
impl gpui::Render for Grid {
fn render(
&mut self,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
let grid = self.0.update(cx, |book, cx| book.grid(window, cx));
gpui::div()
.relative()
.size_full()
.flex()
.flex_col()
.child(grid)
}
}
fn frames(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
#[test]
fn validation_help_and_arrow_do_not_select_the_cells_behind_them() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(ely_gpui_component::init_for_tests);
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["E2"]);
let rule = Validation {
kind: "list".into(),
operator: "equal".into(),
list: vec!["Red".into(), "Green".into()],
dropdown: true,
show_input: true,
input_message: "Pick a color".into(),
error_style: "stop".into(),
..Validation::default()
};
run(&mut cx, &book, "rules.validation", &[&rule.to_json()]);
settle(&cx, |app| {
book.read(app)
.active_tab()
.and_then(|tab| tab.extra.rule.as_ref())
.is_some_and(|(at, rule)| at.1 == "E2" && rule.input_message == "Pick a color")
});
let window = cx.add_window(|_, _| Grid(book.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
frames(&mut shown);
let (view, help, arrow, handle) = shown.update(|window, app| {
let view = book.read(app).active_tab().expect("a tab").view.clone();
let read = view.read(app);
let (x, y, w, h) = read.cell().cursor.expect("the cell cursor");
let help = read.origin() + read.place((x, y + h)) + point(px(8.0), px(8.0));
// The arrow's lower corner hangs below a default row.
let tall = app
.theme()
.control_height(ControlSize::Sm)
.to_pixels(window.rem_size());
let corner = point(tall - px(1.0), tall - px(1.0));
let arrow = read.origin() + read.place((x + w, y)) + corner;
let handle = read.origin() + read.place((x + w, y + h)) - point(px(1.0), px(1.0));
(view.clone(), help, arrow, handle)
});
let sent = shown.update(|_, app| view.read(app).sent());
shown.simulate_mouse_down(handle, MouseButton::Left, Modifiers::none());
assert!(
shown.update(|_, app| matches!(book.read(app).drag, Some(crate::grid::Drag::Fill { .. })))
);
shown.simulate_mouse_move(help, MouseButton::Left, Modifiers::none());
shown.simulate_mouse_up(help, MouseButton::Left, Modifiers::none());
assert!(
shown.update(|_, app| book.read(app).drag.is_none()),
"releasing over help must end the drag"
);
shown.simulate_click(help, Modifiers::none());
assert_eq!(
shown.update(|_, app| view.read(app).sent()),
sent,
"help never sends a grid click"
);
shown.simulate_mouse_down(arrow, MouseButton::Left, Modifiers::none());
assert_eq!(
shown.update(|_, app| view.read(app).sent()),
sent,
"the arrow press stays outside the grid"
);
shown.simulate_mouse_up(arrow, MouseButton::Left, Modifiers::none());
settle(&shown, |app| book.read(app).choices.is_some());
frames(&mut shown);
shown.update(|_, app| {
assert_eq!(view.read(app).cell().address, "E2");
assert_eq!(
view.read(app).sent(),
sent,
"the arrow release also stays outside the grid"
);
assert_eq!(
book.read(app).choices.as_ref().expect("the menu").values,
["Red", "Green"]
);
});
let red = shown
.debug_bounds("menu-row-Red")
.expect("the Red menu item");
shown.simulate_click(red.center(), Modifiers::none());
// An empty F2 adds nothing after E2's tab.
assert_eq!(
cells(&mut cx, &book, "E2:F2"),
"Red",
"the value lands in E2 and F2 stays empty"
);
}
+3 -5
View File
@@ -43,11 +43,9 @@ pub(super) fn cells(cx: &mut TestAppContext, book: &Entity<Book>, range: &str) -
worker
.run(&tab.doc, "cell.goto", &[range])
.expect("the range");
tab.doc
.selection_text(&mut worker)
.expect("the text")
.trim()
.to_string()
let text = tab.doc.selection_text(&mut worker).expect("the text");
// The Windows kernel ends rows with CRLF.
text.trim().lines().collect::<Vec<_>>().join("\n")
})
}
+5 -3
View File
@@ -113,15 +113,17 @@ fn exports_write_the_shown_sheet_as_text() {
run(&mut cx, &book, "cell.goto", &["B1"]);
run(&mut cx, &book, "cell.enter", &["a;b"]);
let id = cx.update(|app| book.read(app).core.tabs[0].id);
// The kernel ends rows the system's way.
let end = if cfg!(windows) { "\r\n" } else { "\n" };
for (format, name, text) in [
(Format::Csv, "out.csv", "3,a;b\n"),
(Format::Tsv, "out.tsv", "3\ta;b\n"),
(Format::Csv, "out.csv", "3,a;b"),
(Format::Tsv, "out.tsv", "3\ta;b"),
] {
let path = dir.path().join(name);
update(&mut cx, &book, |book, cx| book.export_to(id, path.clone(), format, cx));
settle(&cx, |_| path.exists());
quiet(&cx, &book);
assert_eq!(std::fs::read_to_string(&path).expect("reads"), text);
assert_eq!(std::fs::read_to_string(&path).expect("reads"), format!("{text}{end}"));
}
let pdf = dir.path().join("out.pdf");
update(&mut cx, &book, |book, cx| book.export_to(id, pdf.clone(), Format::Pdf, cx));
+96
View File
@@ -0,0 +1,96 @@
//! Each workbook's strip shows its own chosen sheet.
use ely_lok::{Sheet, Sheets};
use gpui::{
Bounds, Context, IntoElement, Modifiers, ParentElement as _, Pixels, Render, RenderOnce as _,
ScrollDelta, ScrollHandle, ScrollWheelEvent, Styled as _, TestAppContext, TouchPhase,
VisualTestContext, Window, div, point, px,
};
use super::strip;
struct Books {
book: u64,
sheets: Sheets,
scroll: Option<ScrollHandle>,
}
impl Render for Books {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let tabs = strip(self.book, &self.sheets)
.expect("tabs")
.render(window, cx)
.into_any_element();
let id = gpui::ElementId::from(("table-sheets", self.book));
let handle = window.use_keyed_state((id, "scroll"), cx, |_, _| ScrollHandle::new());
self.scroll = Some(handle.read(cx).clone());
div()
.w(px(640.))
.child(div().flex().items_center().px_2().child(tabs))
}
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..5 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
/// The strip's viewport and the chosen tab in it.
fn place(
view: &gpui::Entity<Books>,
cx: &mut VisualTestContext,
) -> (Bounds<Pixels>, Bounds<Pixels>) {
view.read_with(cx, |view, _| {
let scroll = view.scroll.as_ref().expect("drawn");
let mut chosen = scroll
.bounds_for_item(view.sheets.current)
.expect("chosen tab");
chosen.origin += scroll.offset();
(scroll.bounds(), chosen)
})
}
fn shown(view: &gpui::Entity<Books>, cx: &mut VisualTestContext) -> bool {
let (viewport, chosen) = place(view, cx);
chosen.left() >= viewport.left() && chosen.right() <= viewport.right()
}
#[gpui::test]
fn a_switched_workbook_shows_its_chosen_sheet(cx: &mut TestAppContext) {
cx.update(ely_gpui_component::init_for_tests);
let sheets = Sheets {
current: 29,
list: (0..30)
.map(|n| Sheet {
name: format!("Sheet {n}"),
visible: true,
})
.collect(),
};
let (view, cx) = cx.add_window_view(|_, _| Books {
book: 1,
sheets,
scroll: None,
});
settle(cx);
assert!(shown(&view, cx), "the first draw shows the chosen sheet");
for book in [2, 1, 2] {
cx.simulate_event(ScrollWheelEvent {
position: point(px(60.), px(16.)),
delta: ScrollDelta::Pixels(point(px(0.), px(50_000.))),
modifiers: Modifiers::none(),
touch_phase: TouchPhase::Moved,
});
settle(cx);
assert!(!shown(&view, cx), "the wheel moves the chosen sheet away");
view.update(cx, |view, cx| {
view.book = book;
cx.notify();
});
settle(cx);
assert!(shown(&view, cx), "workbook {book} shows its chosen sheet");
}
}
+20 -6
View File
@@ -76,12 +76,26 @@ impl Doc {
Unpack::new(&payload).str()
}
/// Completes the function name being typed (TABLE-03).
pub fn complete_function(&self, worker: &mut Worker, name: &str) -> io::Result<()> {
log::info!("lok: completes a function");
let request = Pack::new().u64(worker.doc_id(self)?).str(name).bytes();
worker.request(tag::COMPLETE_FUNCTION, &request)?;
Ok(())
/// Completes `name` if offered; a key takes any, else goes in (TABLE-03).
pub fn complete_function(
&self,
worker: &mut Worker,
name: Option<&str>,
key: Option<(u32, i32)>,
) -> io::Result<bool> {
let pack = Pack::new().u64(worker.doc_id(self)?);
let pack = match name {
Some(name) => pack.u64(1).str(name),
None => pack.u64(0),
};
// Key code 0: no key, as for a click.
let (char_code, key_code) = key.unwrap_or((0, 0));
let request = pack
.i64(i64::from(char_code))
.i64(i64::from(key_code))
.bytes();
let reply = worker.request(tag::COMPLETE_FUNCTION, &request)?;
Ok(Unpack::new(&reply).u64()? == 1)
}
/// Shows sheet `part`, from 0.
+1
View File
@@ -29,6 +29,7 @@ mod sheet;
pub(crate) mod show;
mod slides;
mod tools;
mod word;
mod workbook;
/// The pinned LibreOffice commit the build must report.
+27
View File
@@ -152,3 +152,30 @@ fn one_cell_in_another_block_filters_that_block() {
let entries: Vec<String> = sheet::filter_values(&json).expect("parses").entries.into_iter().map(|e| e.text).collect();
assert_eq!(entries, ["9", "10"], "an AutoFilter from D1 is D's");
}
/// A text-only column: no cell looks like a header.
const WORDS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S">
<table:table-row><table:table-cell><text:p>Name</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>a</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document>"#;
#[test]
fn a_whole_column_without_a_clear_header_filters_from_its_first_row() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_book(WORDS);
worker.run(&doc, "cell.goto", &["A:A"]).expect("marks the column");
worker.run(&doc, "data.autofilter", &[]).expect("filters without asking");
doc.paint_tile(&mut worker, (0, 0, 3840, 3840), (256, 256))
.expect("the buttons paint");
// The whole column holds blanks too.
assert_eq!(entries(&mut worker, &doc), ["", "a", "b"], "the first row heads");
}
+2 -2
View File
@@ -50,8 +50,8 @@ fn typing_a_formula_offers_functions_usage_and_marks() {
sum.is_some_and(|f| f.signature.contains(", ")),
"the signature takes the Excel separator: {sum:?}"
);
doc.complete_function(&mut worker, "SUMIF")
.expect("the completion");
let taken = doc.complete_function(&mut worker, Some("SUMIF"), None);
assert!(taken.expect("the completion"), "SUMIF is on offer");
let done = poll_until(&mut worker, &doc, &offered, SETTLE, |seen| {
seen.cell.formula == "=SUMIF()"
});
+6 -18
View File
@@ -350,7 +350,7 @@ fn brightness(tile: &Tile) -> u32 {
}
#[test]
fn the_page_follows_a_dark_theme_and_back() {
fn ely_scheme_paints_the_page_white() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
@@ -363,23 +363,11 @@ fn the_page_follows_a_dark_theme_and_back() {
let doc = worker
.load(&crate::file_url(&source))
.expect("the document loads");
let area = (1440, 1440, 3840, 3840);
let seen = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false);
let light = brightness(&doc.paint_tile(&mut worker, area, (64, 64)).expect("a tile"));
let paint = |worker: &mut Worker, scheme: &str, seen: &Events| {
worker.run(&doc, "view.theme", &[scheme]).expect("a theme");
let repainted = poll_until(worker, &doc, seen, SETTLE, |seen| seen.all);
assert!(repainted.all, "{name}: {scheme} repaints the view");
let tile = doc.paint_tile(worker, area, (64, 64)).expect("a tile");
(brightness(&tile), repainted)
};
let (dark, seen) = paint(&mut worker, "COLOR_SCHEME_ELY_DARK", &seen);
let (back, _) = paint(&mut worker, "COLOR_SCHEME_ELY_LIGHT", &seen);
eprintln!("THEME {name} light {light} dark {dark} back {back}");
assert!(
light > 200 && dark < 100 && back > 200,
"{name}: {light} {dark} {back}"
);
poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false);
worker.run(&doc, "view.theme", &["COLOR_SCHEME_ELY_LIGHT"]).expect("the scheme");
let tile = doc.paint_tile(&mut worker, (1440, 1440, 3840, 3840), (64, 64)).expect("a tile");
let shade = brightness(&tile);
assert!(shade > 200, "{name}: the page is light, {shade}");
worker.close_doc(doc).expect("the document closes");
}
}
+5 -7
View File
@@ -182,18 +182,16 @@ pub(super) fn open_book(text: &str) -> (tempfile::TempDir, Worker, Doc) {
}
#[test]
fn a_theme_switch_is_no_edit() {
fn the_scheme_is_no_edit() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_book(FODS);
for scheme in ["COLOR_SCHEME_ELY_DARK", "COLOR_SCHEME_ELY_LIGHT"] {
worker.run(&doc, "view.theme", &[scheme]).expect("the theme runs");
let undo = worker.values(&doc, ".uno:Undo").expect("the undo stack");
let undo: serde_json::Value = serde_json::from_str(&undo).expect("undo JSON");
assert_eq!(undo["actions"], "", "{scheme} left undo steps");
}
worker.run(&doc, "view.theme", &["COLOR_SCHEME_ELY_LIGHT"]).expect("the scheme runs");
let undo = worker.values(&doc, ".uno:Undo").expect("the undo stack");
let undo: serde_json::Value = serde_json::from_str(&undo).expect("undo JSON");
assert_eq!(undo["actions"], "", "the scheme left undo steps");
}
#[test]
+18
View File
@@ -103,6 +103,24 @@ fn size_layout_master_and_theme_change_in_one_step() {
);
}
/// Issue #2: more masters than slides, a slide hidden.
#[test]
fn the_master_view_lists_its_masters() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
worker.run(&doc, "slide.hide", &[]).expect("hides One");
worker.run(&doc, "master.edit", &[]).expect("the master view");
worker.run(&doc, "master.insert", &[]).expect("a master");
worker.run(&doc, "master.insert", &[]).expect("another");
let masters = design(&mut worker, &doc).masters;
assert_eq!(masters.len(), 3, "more masters than slides");
let shown: Vec<(String, bool)> = masters.into_iter().map(|name| (name, true)).collect();
assert_eq!(slides(&mut worker, &doc), shown, "masters, all shown");
}
#[test]
fn a_master_edit_shows_on_its_slides() {
if install().is_none() {
+121
View File
@@ -0,0 +1,121 @@
//! Gitea #9: Word's EQ fields and lowered inline objects.
use super::input::saved;
use super::*;
/// `tests/fixtures/office/word.docx` open in a fresh worker.
fn word() -> (tempfile::TempDir, Worker, Doc) {
let dir = tempfile::tempdir().expect("a temp dir");
let install = install().expect("a pinned LOK build");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/office/word.docx");
let doc = worker.load(&crate::file_url(&fixture)).expect("the document loads");
(dir, worker, doc)
}
/// The text position of the span around the picture's frame.
fn picture_position(fodt: &str) -> String {
let named = fodt.find("draw:name=\"red\"").expect("the picture");
let frame = fodt[..named].rfind("<draw:frame").expect("the picture's frame");
let open = fodt[..frame].rfind("<text:span").expect("a span around the picture");
let style = fodt[open..].split('"').nth(1).expect("the span's style");
let named = format!("style:name=\"{style}\"");
let at = fodt.find(&named).expect("the span's style is written");
let props = &fodt[at..at + fodt[at..].find("</style:style>").expect("the style ends")];
let position = props.find("style:text-position=\"").expect("the span is raised or lowered");
props[position + 21..].split('"').next().expect("a position").to_owned()
}
#[test]
fn eq_fields_open_as_formulas() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = word();
let fodt = saved(&dir, &mut worker, &doc, "word.fodt");
assert!(
fodt.contains("{{a}_{&quot;10&quot;}} over {{a}_{&quot;3&quot;}}"),
"the fraction keeps its italics and subscripts"
);
assert!(fodt.contains("nroot{&quot;3&quot;}{&quot;x&quot;}"), "the radical");
assert!(fodt.contains("sum from{&quot;1&quot;} to{&quot;5&quot;}"), "an uppercase \\SU is a sum");
assert!(
fodt.contains("matrix{{&quot;a&quot;} # {&quot;b&quot;} ## {&quot;c&quot;} # {&quot;d&quot;}}"),
"an uppercase \\CO2 makes two columns"
);
assert!(fodt.contains("eq \\f(a)</text:span>"), "an unreadable field shows its code");
assert!(fodt.contains("eq \\b\\lc\\★(x)"), "an unknown bracket shows its code");
let short = formulas(&fodt).into_iter().find(|math| math.contains("# {}}")).expect("the short array");
assert!(short.contains(">c<"), "a short last row keeps its operand");
assert_eq!(fodt.matches('∑').count(), 2, "\\SU and \\fc\\∑ both draw a sum");
assert!(fodt.contains('⌊') && fodt.contains('⌋'), "floor brackets draw");
assert!(!fodt.contains("MERGEFORMAT"), "a general switch is no formula text");
assert!(fodt.contains("{&quot;m&quot;} over {&quot;n&quot;}<"), "nothing after the fraction");
assert_eq!(subscripts(&fodt, ">p<"), 1, "a style change keeps one subscript");
assert!(fodt.contains("&quot;u&quot; stack{{&quot;v&quot;}}"), "\\ai only spaces lines");
assert!(fodt.contains("&quot;w&quot; stack{{&quot;k&quot;}}"), "\\di only spaces lines");
assert!(fodt.contains("_{{&quot;g&quot;}} &quot;+h&quot;"), "a lowercase \\s\\do keeps its tail");
assert!(fodt.contains("_{{{&quot;r&quot;} over {&quot;s&quot;}}}"), "and its nested fraction");
assert!(fodt.contains(">lowered</text:span>"), "a plain \\s\\do stays lowered text");
}
#[test]
fn eq_formulas_survive_docx() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = word();
let docx = dir.path().join("again.docx");
doc.save_as(&mut worker, &crate::file_url(&docx), "docx").expect("the DOCX saves");
let back = worker.load(&crate::file_url(&docx)).expect("the DOCX reopens");
let fodt = saved(&dir, &mut worker, &back, "again.fodt");
assert!(
fodt.contains("{{a} rsub {&quot;10&quot;}} over {{a} rsub {&quot;3&quot;}}"),
"the italic letters stay italic"
);
assert!(!fodt.contains("mathvariant=\"normal\">a<"), "no upright a");
assert_eq!(subscripts(&fodt, ">p<"), 1, "one subscript still");
assert!(fodt.contains("{&quot;&quot;} rsub {&quot;g&quot;} &quot;+h&quot;"), "the tail survives");
assert!(!fodt.contains("&lt;?&gt;"), "no placeholder for a missing base");
}
/// Subscripts in the formula holding `mark`.
fn subscripts(fodt: &str, mark: &str) -> usize {
formulas(fodt).into_iter().find(|math| math.contains(mark)).expect("the formula").matches("<msub>").count()
}
/// Each embedded formula's MathML.
fn formulas(fodt: &str) -> Vec<&str> {
fodt.split("<math xmlns").skip(1).map(|math| &math[..math.find("</math>").expect("closed")]).collect()
}
#[test]
fn a_lowered_picture_moves_with_its_run_and_keeps_it() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = word();
let fodt = saved(&dir, &mut worker, &doc, "word.fodt");
// 11 points down, against the 11 point anchor.
assert_eq!(picture_position(&fodt), "-100% 100%");
let docx = dir.path().join("again.docx");
doc.save_as(&mut worker, &crate::file_url(&docx), "docx").expect("the DOCX saves");
let back = worker.load(&crate::file_url(&docx)).expect("the DOCX reopens");
let again = saved(&dir, &mut worker, &back, "again.fodt");
assert_eq!(picture_position(&again), "-100% 100%", "the position survives DOCX");
// The square's red sits below the line's text.
let tile = back.paint_tile(&mut worker, (1440, 1440, 3840, 1920), (256, 128)).expect("a tile");
let rows = |hit: fn(&[u8]) -> bool| {
let mut lit: Vec<usize> = tile.pixels.chunks_exact(4).enumerate().filter(|(_, px)| hit(px)).map(|(at, _)| at / 256).collect();
lit.dedup();
let first = *lit.first().expect("some pixels");
let line = lit.iter().zip(first..).take_while(|(row, want)| **row <= *want).last().expect("a row");
(first, *line.0)
};
let red = rows(|px| px[2] > 200 && px[1] < 60 && px[0] < 60);
let ink = rows(|px| px[0] < 80 && px[1] < 80 && px[2] < 80);
assert!(red.1 > ink.1 + 4, "the picture hangs below the text: red {red:?}, text {ink:?}");
}
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::ffi::Document;
mod cell;
mod parse;
pub use cell::{Cell, Function, Mark, Request};
pub use cell::{Cell, Function, Mark, Request, offered};
use cell::{functions_of, marks_of, request_of, usage_of};
pub use parse::A11y;
use parse::{
+13
View File
@@ -5,6 +5,19 @@ use serde_json::Value;
use super::Rect;
use crate::ipc::{Pack, Unpack};
/// The function names `id` offers now.
pub fn offered(id: u64) -> Vec<String> {
let slots = super::slots().lock().expect("the events lock");
slots.get(&id).map_or_else(Vec::new, |slot| {
slot.batch
.cell
.functions
.iter()
.map(|function| function.name.clone())
.collect()
})
}
/// What a spreadsheet reports about the current cell.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Cell {
+1 -1
View File
@@ -21,7 +21,7 @@ pub use client::{
#[cfg(feature = "view")]
pub use view::{
CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, ZOOM_STEPS, notch,
Zoomed, init, layout, paper, read_off, scheme,
SCHEME, Zoomed, init, layout, paper, read_off,
};
#[cfg(feature = "view")]
mod view;
+7 -4
View File
@@ -32,7 +32,7 @@ mod scroll;
mod sheet;
pub use input::{CONTEXT, Copy, Cut, Paste, init};
pub use off::read_off;
pub use render::{paper, scheme};
pub use render::{SCHEME, paper};
mod turn;
pub use turn::notch;
@@ -123,8 +123,9 @@ pub struct LokView {
pointer: gpui::CursorStyle,
selection: Vec<Rect>,
cell: crate::Cell,
/// Whether the kernel was last told to paint dark.
dark: Option<bool>,
completion: formula::Completion,
/// Whether the kernel was told Ely's scheme.
lit: bool,
/// The zoom follows the viewport so a page fits.
fit: bool,
/// Set when the wheel and Up, Down turn parts.
@@ -218,7 +219,8 @@ impl LokView {
pointer: gpui::CursorStyle::Arrow,
selection: Vec::new(),
cell: crate::Cell::default(),
dark: None,
completion: formula::Completion::default(),
lit: false,
fit: false,
turning: None,
part: 0,
@@ -392,6 +394,7 @@ impl LokView {
self.picked = 0;
}
self.cell = batch.cell;
self.completion.reported(batch.revision);
self.graphic = batch.graphic;
self.read_borders(batch.table, batch.revision);
self.review = batch.review;
+1 -1
View File
@@ -82,7 +82,7 @@ pub(super) fn mark_of(html: &str) -> Option<&str> {
/// Pastes the system clipboard.
pub(super) fn paste(worker: &mut Worker, doc: &Doc) -> io::Result<()> {
let Some(clip) = clipboard::read().map_err(io::Error::other)? else {
let Some(clip) = clipboard::read().map_err(|error| io::Error::other(format!("{error:#}")))? else {
return Ok(());
};
let (mime, data) = flavor_of(clip);
+37 -17
View File
@@ -5,11 +5,17 @@ use ely_gpui_component::{
primitives::raise,
};
use gpui::{
AnyElement, Bounds, Context, Hsla, IntoElement, Keystroke, Modifiers, ParentElement, Pixels,
anchored, rgb,
AnyElement, Bounds, Context, Hsla, InteractiveElement, IntoElement, Keystroke, Modifiers,
ParentElement, Pixels, anchored, div, rgb,
};
use super::{LokView, input::Input};
use super::{
LokView,
input::{Input, vcl_key},
};
mod completion;
pub(super) use completion::Completion;
impl LokView {
/// The sheet shown, which reference marks follow.
@@ -48,7 +54,7 @@ impl LokView {
/// Up and Down walk the offered functions.
pub(super) fn walk(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
let count = self.cell.functions.len();
if count == 0 || keystroke.modifiers != Modifiers::none() {
if count == 0 || self.completion.pending() || keystroke.modifiers != Modifiers::none() {
return false;
}
self.picked = match keystroke.key.as_str() {
@@ -60,32 +66,46 @@ impl LokView {
true
}
/// Takes the chosen function, in order with typing.
pub(super) fn complete(&mut self) -> bool {
let Some(item) = self.offered().into_iter().nth(self.picked) else {
return false;
};
log::info!("lok: completes {}", item.label);
self.send(Input::Complete(item.label.to_string()));
true
/// Enter or Tab: the kernel's function then, else the key.
pub(super) fn complete_or_press(&mut self, keystroke: &Keystroke) {
let key = vcl_key(keystroke).expect("Enter and Tab are vcl keys");
let name = (!self.completion.pending())
.then(|| self.offered().into_iter().nth(self.picked))
.flatten()
.map(|item| item.label.to_string());
self.take(name, Some(key));
}
/// A clicked function, if the kernel still offers it then.
fn complete_function(&mut self, name: &str) {
if !self.completion.pending() {
self.take(Some(name.to_string()), None);
}
}
/// Queues the choice in order with typing; the worker decides.
fn take(&mut self, name: Option<String>, key: Option<(u32, i32)>) {
self.send(Input::Complete { name, key });
self.completion.queued(self.sent);
self.picked = 0;
}
/// The offered functions, else the call's usage, under the cell.
pub(super) fn hints(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.completion.pending() {
return None;
}
let cell = self.screen_rect(self.cell.cursor?);
let hint = if self.cell.functions.is_empty() {
SignatureHelp::new("lok-view-usage", [usage(&self.cell.usage)?]).into_any_element()
} else {
let this = cx.entity();
let offered = self.offered();
let labels: Vec<_> = offered.iter().map(|item| item.label.clone()).collect();
CompletionMenu::new("lok-view-functions", offered)
.selected(self.picked)
.on_pick(move |item, _, cx| {
let at = labels.iter().position(|label| *label == item.label);
this.update(cx, |view, cx| {
view.picked = at.expect("a picked item is offered");
view.complete();
view.complete_function(&item.label);
cx.notify();
})
})
@@ -94,7 +114,7 @@ impl LokView {
let place = anchored()
.position(cell.bottom_left())
.snap_to_window()
.child(hint);
.child(div().block_mouse_except_scroll().child(hint));
Some(raise("lok-view-hints", place).into_any_element())
}
}
@@ -0,0 +1,116 @@
#[derive(Default)]
pub(crate) struct Completion {
pending: Option<(u64, Option<u64>)>,
reported: u64,
}
impl Completion {
pub(crate) fn pending(&self) -> bool {
self.pending.is_some()
}
pub(crate) fn queued(&mut self, seq: u64) {
self.pending = Some((seq, None));
}
pub(crate) fn reported(&mut self, revision: u64) {
self.reported = revision;
self.finish();
}
pub(crate) fn landed(&mut self, seq: u64, revision: Option<u64>) {
if self.pending.is_some_and(|(waiting, _)| waiting == seq) {
self.pending = revision.map(|revision| (seq, Some(revision)));
self.finish();
}
}
fn finish(&mut self) {
// Inline callbacks and serialized output preserve completion revisions.
if self
.pending
.is_some_and(|(_, revision)| revision.is_some_and(|revision| self.reported >= revision))
{
self.pending = None;
}
}
}
#[cfg(test)]
mod tests {
use super::Completion;
#[test]
fn an_old_snapshot_cannot_reopen_an_accepted_menu() {
let mut completion = Completion::default();
completion.reported(10);
completion.queued(7);
completion.reported(10);
assert!(completion.pending());
completion.landed(7, Some(11));
assert!(completion.pending());
completion.reported(10);
assert!(completion.pending());
completion.reported(11);
assert!(!completion.pending());
}
#[test]
fn a_snapshot_before_the_reply_waits_for_the_reply() {
let mut completion = Completion::default();
completion.queued(7);
completion.reported(11);
assert!(completion.pending());
completion.landed(7, Some(11));
assert!(!completion.pending());
}
#[test]
fn a_reply_before_the_snapshot_waits_for_the_snapshot() {
let mut completion = Completion::default();
completion.queued(7);
completion.landed(7, Some(11));
assert!(completion.pending());
completion.reported(11);
assert!(!completion.pending());
}
#[test]
fn another_input_cannot_acknowledge_the_completion() {
let mut completion = Completion::default();
completion.queued(7);
completion.reported(20);
completion.landed(6, Some(20));
assert!(completion.pending());
completion.landed(8, None);
assert!(completion.pending());
completion.landed(7, Some(19));
assert!(!completion.pending());
}
#[test]
fn a_failed_completion_allows_retry() {
let mut completion = Completion::default();
completion.queued(7);
completion.landed(7, None);
assert!(!completion.pending());
completion.queued(8);
assert!(completion.pending());
}
#[test]
fn the_next_completion_requires_its_own_snapshot() {
let mut completion = Completion::default();
completion.reported(11);
completion.queued(7);
completion.landed(7, Some(11));
assert!(!completion.pending());
completion.queued(8);
completion.landed(8, Some(12));
assert!(completion.pending());
completion.reported(11);
assert!(completion.pending());
completion.reported(12);
assert!(!completion.pending());
}
}
+28 -8
View File
@@ -45,8 +45,11 @@ pub(super) enum Input {
Paste,
/// Values or formulas alone: `paste.values` or `paste.formulas`.
PasteOnly(&'static str),
/// A function the kernel offered, taken.
Complete(String),
/// A function the kernel offers then, else Enter or Tab.
Complete {
name: Option<String>,
key: Option<(u32, i32)>,
},
/// The kernel's color scheme; not an edit.
Theme(&'static str),
/// Commands in order, from one read.
@@ -72,6 +75,8 @@ pub(super) enum Input {
pub(super) enum Back {
Copied(String),
Shown(usize),
/// Whether a function was taken.
Completed(bool),
}
impl Input {
@@ -92,7 +97,7 @@ impl Input {
| Input::Paste
| Input::PasteOnly(_)
| Input::Drag { .. }
| Input::Complete(_) => true,
| Input::Complete { .. } => true,
}
}
}
@@ -201,7 +206,10 @@ fn apply(worker: &mut Worker, doc: &Doc, input: Input) -> io::Result<Option<Back
Input::Drag { from, to } => worker.drag_graphic(doc, from, to),
Input::Visible(area, scale) => doc.visible_area(worker, area, scale),
Input::Part(part) => doc.show_sheet(worker, part),
Input::Complete(name) => doc.complete_function(worker, &name),
Input::Complete { name, key } => {
let taken = doc.complete_function(worker, name.as_deref(), key)?;
return Ok(Some(Back::Completed(taken)));
}
Input::Theme(scheme) => worker.run(doc, "view.theme", &[scheme]),
Input::Text { commit, text } => worker.post_text(doc, commit, &text),
Input::Copy { cut } => {
@@ -270,6 +278,10 @@ impl LokView {
/// Posts a non-text key; text waits for the IME.
pub(super) fn key_down(&mut self, keystroke: &Keystroke) -> bool {
if keystroke.key == "enter" && keystroke.modifiers == Modifiers::none() {
self.complete_or_press(keystroke);
return true;
}
let Some((char_code, key_code)) = vcl_key(keystroke) else {
return false;
};
@@ -307,10 +319,14 @@ impl LokView {
_: &mut Window,
cx: &mut Context<Self>,
) {
let completing = self.completion.pending();
if self.walk(&event.keystroke, cx)
|| self.turn_key(&event.keystroke)
|| self.key_down(&event.keystroke)
{
if !completing && self.completion.pending() {
cx.notify();
}
cx.stop_propagation();
}
}
@@ -425,10 +441,14 @@ impl LokView {
self.send(Input::Paste);
}
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, _: &mut Context<Self>) {
if !self.complete() {
self.key_down(&Keystroke::parse("tab").expect("a keystroke"));
}
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, cx: &mut Context<Self>) {
self.next_field();
cx.notify();
}
/// Tab takes the offered function, else moves on.
pub(super) fn next_field(&mut self) {
self.complete_or_press(&Keystroke::parse("tab").expect("a keystroke"));
}
pub(super) fn on_previous_field(
+4 -1
View File
@@ -107,6 +107,9 @@ impl LokView {
}
self.edited += u64::from(edits);
let (outcome, now) = outcome;
// A plain key took nothing; the menu may return.
let taken = matches!(outcome, Ok(Some(Back::Completed(true))));
self.completion.landed(seq, if taken { now } else { None });
if edits {
self.clock.landed(now);
}
@@ -118,7 +121,7 @@ impl LokView {
self.part = part;
self.invalidate(cx);
}
Ok(Some(Back::Shown(_))) => {}
Ok(Some(Back::Shown(_) | Back::Completed(_))) => {}
Ok(None) => {}
Err(error) => {
log::error!("lok: input failed ({:?})", error.kind());
+3 -3
View File
@@ -6,9 +6,9 @@ use super::LokView;
use crate::Rect;
impl LokView {
/// Whether the kernel paints dark; None before the first frame.
pub fn dark(&self) -> Option<bool> {
self.dark
/// Whether the kernel was told Ely's scheme.
pub fn lit(&self) -> bool {
self.lit
}
/// The kernel's state for `uno`, as LOK reports it (LOK-05).
+16 -26
View File
@@ -15,33 +15,21 @@ use gpui::{Pixels, RenderImage, point};
use super::{CONTEXT, LokView, TILE_TWIPS, TWIPS_PER_PIXEL, handles, input};
use crate::Rect;
/// Ely's kernel color schemes, from `ely.xcd`.
const DARK: &str = "COLOR_SCHEME_ELY_DARK";
const LIGHT: &str = "COLOR_SCHEME_ELY_LIGHT";
/// Ely's kernel color scheme, from `ely.xcd`.
pub const SCHEME: &str = "COLOR_SCHEME_ELY_LIGHT";
/// The kernel scheme for a light or dark app.
pub fn scheme(dark: bool) -> &'static str {
if dark { DARK } else { LIGHT }
}
/// A page's colour; the kernel's Ely schemes share it.
pub fn paper(cx: &gpui::App) -> gpui::Hsla {
let theme = cx.theme();
if theme.is_dark() {
theme.colors.surface
} else {
theme.colors.paper
}
/// A page's colour, the scheme's `DocColor`.
pub fn paper() -> gpui::Hsla {
gpui::white()
}
impl LokView {
/// Paints with the app's light or dark scheme.
pub(super) fn follow_theme(&mut self, dark: bool) {
if self.dark != Some(dark) {
self.dark = Some(dark);
let scheme = scheme(dark);
log::info!("lok: paints with {scheme}");
self.send(input::Input::Theme(scheme));
/// Content keeps the file's colours in any app theme.
pub(super) fn paint_light(&mut self) {
if !self.lit {
self.lit = true;
log::info!("lok: paints with {SCHEME}");
self.send(input::Input::Theme(SCHEME));
}
}
}
@@ -53,12 +41,14 @@ impl Render for LokView {
self.keep_scroll(|view| view.geom.dpi = dpi);
self.retire_tiles();
}
self.follow_theme(cx.theme().is_dark());
self.paint_light();
let this = cx.entity().clone();
let focus = self.focus.clone();
let colors = &cx.theme().colors;
let paper = super::paper(cx);
let (selection_color, caret_color) = (colors.selection, colors.focus);
let paper = super::paper();
// Opaque overlays hide glyphs already painted in the tiles.
let selection_color = colors.selection.alpha(colors.selection.a.min(0.3));
let caret_color = colors.focus;
let canvas_color = colors.sunken;
let caret_width = cx.theme().caret_width().to_pixels(window.rem_size());
self.handle_size = cx.theme().handle_hit().to_pixels(window.rem_size());
+1
View File
@@ -4,6 +4,7 @@ mod borders;
mod clip;
mod commands;
mod fit;
mod formula;
mod input;
mod large;
mod show_borders;
+272
View File
@@ -0,0 +1,272 @@
use super::*;
use gpui::{Keystroke, TestAppContext};
fn settle(cx: &TestAppContext, done: impl Fn(&App) -> bool) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if !cx.executor().tick() {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
}
if cx.update(|app| done(app)) {
return true;
}
}
false
}
#[test]
fn enter_completes_the_selected_function_without_leaving_the_cell() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let (view, _) = cx.update(|app| {
live_view_of(
app,
&install,
"book.fods",
crate::client::tests::input::FODS,
)
});
let (_, window) = cx.add_window_view(|_, _| Host(view.clone()));
window.run_until_parked();
window.update(|window, app| {
let focus = view.read(app).focus_handle().clone();
focus.focus(window, app);
});
assert!(settle(window, |app| !view
.read(app)
.cell
.address
.is_empty()));
let address = window.update(|_, app| view.read(app).cell.address.clone());
view.update(window, |view, _| view.commit(None, "=lo"));
assert!(settle(window, |app| {
let functions = &view.read(app).cell.functions;
functions.iter().any(|item| item.name == "LOOKUP")
&& functions.iter().any(|item| item.name == "LOG")
}));
// Keep two valid offers to exercise keyboard selection.
view.update(window, |view, cx| {
view.cell
.functions
.retain(|item| item.name == "LOG" || item.name == "LOOKUP");
cx.notify();
});
window.simulate_keystrokes("down enter");
assert!(
settle(window, |app| {
let view = view.read(app);
view.settled() && view.cell.formula == "=LOOKUP()" && view.cell.functions.is_empty()
}),
"Enter inserts the selected function"
);
window.update(|_, app| {
assert_eq!(
view.read(app).cell.address,
address,
"completion stays in the cell"
);
view.update(app, |view, _| {
view.commit(None, "1,1,1");
});
});
assert!(settle(window, |app| {
let view = view.read(app);
view.settled() && view.cell.formula == "=LOOKUP(1,1,1)"
}));
window.simulate_keystrokes("enter");
assert!(
settle(window, |app| {
let view = view.read(app);
view.settled() && view.cell.address != address
}),
"Enter without candidates commits and moves to the next cell"
);
}
#[test]
fn accepting_a_candidate_consumes_it_before_the_worker_answers() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, worker) = cx.update(|app| {
live_view_of(
app,
&install,
"book.fods",
crate::client::tests::input::FODS,
)
});
cx.update(|app| view.update(app, |view, _| view.commit(None, "=look")));
assert!(settle(&cx, |app| view
.read(app)
.cell
.functions
.iter()
.any(|item| item.name == "LOOKUP")));
let held = worker.lock().expect("the worker lock");
cx.update(|app| {
view.update(app, |view, cx| {
let old = Events {
revision: view.doc.rev,
cell: view.cell.clone(),
..Events::default()
};
let before = view.sent();
assert!(view.key_down(&Keystroke::parse("enter").expect("Enter")));
view.apply_batch(old, None, cx);
assert!(
view.completion.pending(),
"an old event must not reoffer an accepted candidate"
);
assert_eq!(view.sent(), before + 1);
assert!(view.key_down(&Keystroke::parse("enter").expect("Enter")));
assert_eq!(
view.sent(),
before + 2,
"the second Enter is queued normally"
);
})
});
drop(held);
assert!(
settle(&cx, |app| {
let view = view.read(app);
view.settled() && view.cell.address == "A2"
}),
"the second Enter commits and moves down"
);
}
#[test]
fn enter_or_tab_after_queued_typing_follows_the_kernel_not_the_menu() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
for (key, to) in [("enter", "A2"), ("tab", "B1")] {
let cx = TestAppContext::single();
let (view, worker) = cx.update(|app| {
live_view_of(
app,
&install,
"book.fods",
crate::client::tests::input::FODS,
)
});
cx.update(|app| view.update(app, |view, _| view.commit(None, "=sum")));
assert!(settle(&cx, |app| view
.read(app)
.cell
.functions
.iter()
.any(|item| item.name == "SUM")));
// The worker waits; the menu still shows SUM.
let held = worker.lock().expect("the worker lock");
cx.update(|app| {
view.update(app, |view, _| {
for text in ["(", "1", ")"] {
view.commit(None, text);
}
match key {
"enter" => assert!(view.key_down(&Keystroke::parse(key).expect("a key"))),
_ => view.next_field(),
}
})
});
drop(held);
assert!(
settle(&cx, |app| {
let view = view.read(app);
view.settled() && view.cell.address == to
}),
"{key} commits =sum(1) and moves to {to}"
);
}
}
struct Host(Entity<LokView>);
impl gpui::Render for Host {
fn render(
&mut self,
_: &mut gpui::Window,
_: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
self.0.clone()
}
}
#[test]
fn clicking_a_function_does_not_send_mouse_input_to_the_sheet() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let (view, _) = cx.update(|app| {
live_view_of(
app,
&install,
"book.fods",
crate::client::tests::input::FODS,
)
});
let (_, window) = cx.add_window_view(|_, _| Host(view.clone()));
window.run_until_parked();
view.update(window, |view, _| view.commit(None, "=look"));
assert!(settle(window, |app| {
let view = view.read(app);
view.settled() && view.cell.functions.len() == 1 && view.cell.functions[0].name == "LOOKUP"
}));
window.run_until_parked();
let (on, address, sent) = window.update(|_, app| {
let view = view.read(app);
let cell = view.screen_rect(view.cell.cursor.expect("the cell cursor"));
(
cell.bottom_left() + point(px(12.0), px(12.0)),
view.cell.address.clone(),
view.sent(),
)
});
window.simulate_mouse_down(on, gpui::MouseButton::Left, Default::default());
window.update(|_, app| {
let view = view.read(app);
assert_eq!(view.sent(), sent, "the sheet never receives the press");
assert!(
!view.dragging,
"the candidate press never starts a sheet drag"
);
});
// Change candidates without repainting the already pressed row.
view.update(window, |view, _| {
view.cell.functions.insert(
0,
crate::Function {
name: "LOG".into(),
signature: String::new(),
description: String::new(),
},
);
});
window.simulate_mouse_up(on, gpui::MouseButton::Left, Default::default());
assert!(
settle(window, |app| {
let view = view.read(app);
view.settled() && view.cell.formula == "=LOOKUP()"
}),
"the candidate receives the click"
);
window.update(|_, app| {
let view = view.read(app);
assert_eq!(view.cell.address, address, "the active cell stays put");
assert_eq!(view.sent(), sent + 1, "only completion reaches the sheet");
});
}
+6
View File
@@ -247,6 +247,12 @@ fn the_clipboard_moves_html_tables_and_images() {
text.contains(&"left".to_string()) && text.contains(&"right".to_string()),
"{text:?}"
);
// Plain text from another app, with no HTML beside it.
clipboard::write(&Clip::Text("outside words".into())).expect("the text writes");
cx.update(|app| view.update(app, |view, _| view.send(crate::view::input::Input::Paste)));
flush(&cx);
let text = saved(&view, &mut cx);
assert!(text.iter().any(|line| line.contains("outside words")), "{text:?}");
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]))
+135 -22
View File
@@ -24,37 +24,150 @@ fn area() -> Bounds<Pixels> {
}
}
/// A user profile whose dark scheme paints the page black.
const DARK_PROFILE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<item oor:path="/org.openoffice.Office.UI/ColorScheme/ColorSchemes"><node oor:name="TEST_DARK" oor:op="replace"><node oor:name="DocColor"><prop oor:name="Color" oor:op="fuse"><value>0</value></prop></node></node></item>
</oor:items>
"#;
/// Shows a view in a window.
struct Host(Entity<LokView>);
impl gpui::Render for Host {
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
self.0.clone()
}
}
#[test]
fn the_view_paints_the_app_theme_and_back() {
fn a_dark_app_paints_the_page_white() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let mut cx = TestAppContext::single();
cx.update(|app| {
ely_gpui_component::init_for_tests(app);
ely_gpui_component::theme::Theme::set_mode_now(ely_gpui_component::theme::Mode::Dark, app);
});
// A dark profile, as an old Ely left.
let dir = tempfile::tempdir().expect("a temp dir");
let profile = dir.path().join("profile");
std::fs::create_dir_all(profile.join("user")).expect("a profile");
std::fs::write(profile.join("user/registrymodifications.xcu"), DARK_PROFILE).expect("the scheme writes");
let source = dir.path().join("note.fodt");
std::fs::write(&source, crate::client::tests::FODT).expect("the fixture writes");
let mut started = crate::Worker::spawn(&install, &profile).expect("the worker");
let doc = started.load(&crate::file_url(&source)).expect("the document loads");
let laid = super::super::layout(&mut started, &doc).expect("the layout reads");
let worker = Arc::new(Mutex::new(started));
let shade = || {
let mut worker = worker.lock().expect("the worker");
let tile = doc.paint_tile(&mut worker, (1440, 1440, 3840, 3840), (64, 64)).expect("a tile");
let sum: u64 = tile.pixels.chunks_exact(4).map(|px| u64::from(px[0]) + u64::from(px[1]) + u64::from(px[2])).sum();
sum / (64 * 64 * 3)
};
worker.lock().expect("the worker").run(&doc, "view.theme", &["TEST_DARK"]).expect("the dark scheme");
let dark = shade();
assert!(dark < 100, "the kernel starts dark: {dark}");
let view = cx.update(|app| LokView::open(Arc::clone(&worker), doc.clone(), laid, app));
let shown = view.clone();
cx.add_window(move |_, _| Host(shown));
assert!(
settle(&cx, |app| view.read(app).lit() && view.read(app).settled()),
"the scheme goes out"
);
assert_eq!(paper(), gpui::white(), "the page mask is the scheme's white");
let light = shade();
assert!(light > 240, "the view paints the page white: {light}");
}
#[test]
fn selection_keeps_document_text_readable_across_themes() {
use ely_gpui_component::{
devtools::{contrast, over},
theme::{ActiveTheme, Mode, Palette, Theme},
};
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let (view, worker) = cx.update(|app| live_view(app, &install));
let doc = cx.update(|app| view.read(app).doc.clone());
let mut shades = Vec::new();
for dark in [true, false] {
cx.update(|app| view.update(app, |view, _| view.follow_theme(dark)));
assert!(
settle(&cx, |app| view.read(app).settled()),
"the theme goes out"
);
let mut worker = worker.lock().expect("the worker");
let tile = doc
.paint_tile(&mut worker, (1440, 1440, 3840, 3840), (64, 64))
.expect("a tile");
let sum: u64 = tile
.pixels
.chunks_exact(4)
.map(|px| u64::from(px[0]) + u64::from(px[1]) + u64::from(px[2]))
.sum();
shades.push(sum / (64 * 64 * 3));
}
let shown = view.clone();
let window = cx.add_window(move |_, _| Host(shown));
worker
.lock()
.expect("the worker")
.run(&doc, "edit.select_all", &[])
.expect("select all");
assert!(
shades[0] < 100 && shades[1] > 200,
"dark then light: {shades:?}"
settle(&cx, |app| !view.read(app).selection.is_empty()),
"the selection arrives"
);
let mut themes = vec![
("dark".to_string(), Mode::Dark, None),
("light".to_string(), Mode::Light, None),
];
for (name, mode, color) in [
("omarchy dark", Mode::Dark, 0x292e42),
("omarchy light", Mode::Light, 0xccd0da),
] {
let mut palette = match mode {
Mode::Dark => Palette::dark(false),
Mode::Light => Palette::light(false),
};
palette.selection = gpui::rgb(color).into();
themes.push((name.to_string(), mode, Some(palette)));
}
let mut translucent = Palette::dark(false);
translucent.selection = gpui::rgb(0x292e42).into();
translucent.selection.a = 0.15;
themes.push(("translucent".to_string(), Mode::Dark, Some(translucent)));
for (name, mode, palette) in themes {
cx.update(|app| {
Theme::set_palette(mode, palette, app);
Theme::set_mode_now(mode, app);
});
cx.update_window(window.into(), |_, window, app| {
window.draw(app).clear(app);
let source = app.theme().colors.selection;
let selected: Vec<_> = window
.painted_quads()
.into_iter()
.filter_map(|quad| quad.background.as_solid())
.filter(|color| color.alpha(1.0) == source.alpha(1.0))
.collect();
assert!(!selected.is_empty(), "{name}: the selection is painted");
for color in selected {
for (text, background) in [(gpui::black(), paper()), (gpui::white(), gpui::black())]
{
let ratio = contrast(over(color, text), over(color, background));
assert!(
ratio >= 4.5,
"{name}: selected text stays readable: {ratio}"
);
}
assert!(
color.a > 0.0 && color.a <= source.a,
"{name}: the theme's opacity is respected"
);
if source.a < 0.3 {
assert_eq!(
color.a, source.a,
"{name}: a translucent theme stays unchanged"
);
}
}
})
.expect("the window lives");
}
}
#[test]
+2 -7
View File
@@ -7,6 +7,7 @@ use std::{
use crate::ffi::{Document, Lok};
use crate::ipc::{self, Pack, Unpack, tag};
mod complete;
mod events;
#[cfg(unix)]
mod fatal;
@@ -264,13 +265,7 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
.and_then(|id| with_doc(id, |doc| doc.selection_text()))
.and_then(|text| text)
.map(|text| Pack::new().str(&text).bytes()),
tag::COMPLETE_FUNCTION => unpack
.u64()
.and_then(|id| {
let name = unpack.str()?;
with_doc(id, |doc| doc.complete_function(&name)).and_then(|done| done)
})
.map(|()| Vec::new()),
tag::COMPLETE_FUNCTION => complete::complete(unpack),
tag::SET_AUTHOR => unpack
.str()
.and_then(|name| lok.set_option("author", &name))
+43
View File
@@ -0,0 +1,43 @@
//! A function the kernel offers, taken in input order (TABLE-03).
use std::io;
use super::with_doc;
use crate::ipc::{Pack, Unpack};
/// Takes an offered function, else posts Enter or Tab; 1 if taken.
pub(super) fn complete(unpack: &mut Unpack) -> io::Result<Vec<u8>> {
let id = unpack.u64()?;
let name = match unpack.u64()? {
0 => None,
_ => Some(unpack.str()?),
};
let (char_code, key_code) = (unpack.i64()?, unpack.i64()?);
// No key: a click; else Enter (1280) or Tab (1282).
if !matches!(key_code, 0 | 1280 | 1282) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"a completion takes Enter or Tab",
));
}
// Earlier input's callbacks already landed here.
let offered = crate::events::offered(id);
let take = match name {
Some(name) if offered.contains(&name) => Some(name),
// As the menu orders them: shortest first.
_ if key_code != 0 => offered.into_iter().min_by_key(String::len),
_ => None,
};
if let Some(name) = take {
log::info!("lok-worker: completes a function");
with_doc(id, |doc| doc.complete_function(&name))??;
return Ok(Pack::new().u64(1).bytes());
}
if key_code != 0 {
with_doc(id, |doc| {
doc.post_key(0, char_code as i32, key_code as i32);
doc.post_key(1, char_code as i32, key_code as i32);
})?;
}
Ok(Pack::new().u64(0).bytes())
}
+46
View File
@@ -0,0 +1,46 @@
[package]
name = "ely-mail-engine"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
base64.workspace = true
form_urlencoded = "1"
getrandom.workspace = true
html5ever.workspace = true
imap-proto.workspace = true
jiff.workspace = true
log.workspace = true
mail-builder.workspace = true
mail-parser.workspace = true
mail-send.workspace = true
nom.workspace = true
rustls.workspace = true
rustls-platform-verifier.workspace = true
rusqlite.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
smtp-proto = "0.2"
tokio.workspace = true
tokio-rustls.workspace = true
ureq.workspace = true
[dependencies.keyring]
version = "3"
features = ["apple-native", "windows-native"]
[target.'cfg(target_os = "linux")'.dependencies.keyring]
version = "3"
features = ["linux-native-sync-persistent"]
[dev-dependencies]
ely-platform.workspace = true
rcgen.workspace = true
tempfile.workspace = true
[lints]
workspace = true
+39
View File
@@ -0,0 +1,39 @@
use std::{env, fs, io::ErrorKind, path::Path};
const KEYS: [&str; 3] = [
"ELY_GOOGLE_CLIENT_ID",
"ELY_GOOGLE_CLIENT_SECRET",
"ELY_MICROSOFT_CLIENT_ID",
];
/// Client IDs: CI secrets, else the local oauth.env.
fn main() {
let file = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../oauth.env");
println!("cargo:rerun-if-changed={}", file.display());
for key in KEYS {
println!("cargo:rerun-if-env-changed={key}");
}
let text = match fs::read_to_string(&file) {
Ok(text) => text,
Err(err) if err.kind() == ErrorKind::NotFound => return,
Err(err) => panic!("oauth.env unreadable: {err}"),
};
for (number, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
panic!("oauth.env line {}: expected KEY=value", number + 1);
};
let key = key.trim();
assert!(
KEYS.contains(&key),
"oauth.env line {}: unknown key {key}",
number + 1
);
if env::var_os(key).is_none() {
println!("cargo:rustc-env={key}={}", value.trim());
}
}
}
+126
View File
@@ -0,0 +1,126 @@
//! An account's servers and how it signs in (MAIL-01, 02).
use serde::{Deserialize, Serialize};
use crate::{Error, Imap, Login, Pop, Result, Secret, Server, Smtp, TlsConnector, oauth};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
Imap,
/// `keep` leaves each message on the server (§12.3).
Pop {
keep: bool,
},
}
/// A password, or OAuth with one provider.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Auth {
Password,
Google,
Microsoft,
}
impl Auth {
/// The OAuth server; None for a password.
pub fn provider(self) -> Option<Result<oauth::Provider>> {
match self {
Auth::Password => None,
Auth::Google => Some(oauth::google()),
Auth::Microsoft => Some(oauth::microsoft()),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
pub address: String,
pub user: String,
pub auth: Auth,
pub protocol: Protocol,
pub incoming: Server,
pub outgoing: Server,
}
/// What each server answered.
pub struct Checked {
pub incoming: Result<()>,
pub outgoing: Result<()>,
}
impl Checked {
pub fn passed(&self) -> bool {
self.incoming.is_ok() && self.outgoing.is_ok()
}
}
impl Account {
/// The vault name of its password or refresh token.
pub fn secret_name(&self) -> String {
format!("account:{}", self.address)
}
/// Refuses what no server could carry.
pub fn valid(&self) -> Result<()> {
crate::smtp::mailbox(&self.address)?;
if self.user.is_empty() || self.user.contains(['\r', '\n']) {
return Err(Error::Invalid("a user name on one line is needed".into()));
}
for server in [&self.incoming, &self.outgoing] {
if server.host.is_empty() || server.host.contains(char::is_whitespace) {
return Err(Error::Invalid(format!("a server host: {:?}", server.host)));
}
if server.port == 0 {
return Err(Error::Invalid("port 0".into()));
}
}
Ok(())
}
pub fn login(&self, secret: Secret) -> Login {
Login {
user: self.user.clone(),
secret,
}
}
/// Logs in to both servers at once, then leaves.
pub async fn check(&self, secret: Secret, tls: &TlsConnector) -> Checked {
let login = self.login(secret);
let (incoming, outgoing) = tokio::join!(
self.check_incoming(&login, tls),
check_outgoing(&self.outgoing, &login, tls)
);
log::info!(
"mail: check: incoming {}, outgoing {}",
outcome(&incoming),
outcome(&outgoing)
);
Checked { incoming, outgoing }
}
async fn check_incoming(&self, login: &Login, tls: &TlsConnector) -> Result<()> {
match self.protocol {
Protocol::Imap => {
Imap::connect(&self.incoming, login, tls)
.await?
.logout()
.await
}
Protocol::Pop { .. } => Pop::connect(&self.incoming, login, tls).await?.quit().await,
}
}
}
async fn check_outgoing(server: &Server, login: &Login, tls: &TlsConnector) -> Result<()> {
Smtp::connect(server, login, tls).await?.quit().await
}
fn outcome(result: &Result<()>) -> &'static str {
match result {
Ok(()) => "passed",
Err(_) => "failed",
}
}
+74
View File
@@ -0,0 +1,74 @@
use std::{fmt, io};
pub type Result<T> = std::result::Result<T, Error>;
/// Why a mail server conversation failed; server words kept.
#[derive(Debug)]
pub enum Error {
/// No TCP connection: name lookup, refusal, reset.
Connect(io::Error),
/// TLS handshake or certificate check failed.
Tls(String),
/// The server refused the credentials.
Rejected(String),
/// The server refused a command after login.
Server(String),
/// The server said something the client cannot read.
Protocol(String),
/// The server did not answer in time.
Timeout(&'static str),
/// An address or login the protocol cannot carry.
Invalid(String),
/// A message that cannot be read or written.
Mime(String),
/// The local store failed: database, disk or damage.
Store(String),
/// An account with this address is already kept.
Taken(String),
/// The store refuses writes; the reason says why.
ReadOnly(String),
/// The system credential store failed.
Vault(String),
/// This build has no client ID for the provider.
Unavailable(&'static str),
/// Sign-in did not finish; the reason says why.
SignIn(String),
/// The provider revoked the refresh token.
SignInAgain(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Connect(err) => write!(f, "cannot connect: {err}"),
Self::Tls(text) => write!(f, "TLS failed: {text}"),
Self::Rejected(text) => write!(f, "login refused: {text}"),
Self::Server(text) => write!(f, "server refused: {text}"),
Self::Protocol(text) => write!(f, "unreadable reply: {text}"),
Self::Timeout(step) => write!(f, "no answer while {step}"),
Self::Invalid(text) => write!(f, "cannot be sent: {text}"),
Self::Mime(text) => write!(f, "unreadable message: {text}"),
Self::Store(text) => write!(f, "local store failed: {text}"),
Self::Taken(address) => write!(f, "{address} is already added"),
Self::ReadOnly(text) => write!(f, "local store is read-only: {text}"),
Self::Vault(text) => write!(f, "credential store failed: {text}"),
Self::Unavailable(name) => write!(f, "{name} sign-in is not in this build"),
Self::SignIn(text) => write!(f, "sign-in failed: {text}"),
Self::SignInAgain(text) => write!(f, "sign in again: {text}"),
}
}
}
impl std::error::Error for Error {}
impl From<rusqlite::Error> for Error {
fn from(err: rusqlite::Error) -> Self {
Self::Store(err.to_string())
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Self::Store(format!("a stored field: {err}"))
}
}
+458
View File
@@ -0,0 +1,458 @@
use std::collections::HashMap;
use imap_proto::{AttributeValue, Capability, MailboxDatum, NameAttribute, Response, ResponseCode};
use tokio_rustls::TlsConnector;
use crate::{
Error, Result,
net::{self, Login, Secret, Security, Server, Stream, within},
};
mod conn;
mod parts;
use conn::{Arg, Conn};
pub use parts::Node;
/// A logged-in IMAP session over TLS.
pub struct Imap {
conn: Conn<Stream>,
}
/// What a folder is for (RFC 6154; INBOX by name).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
Inbox,
Sent,
Drafts,
Trash,
Junk,
Archive,
All,
Flagged,
}
impl Role {
pub fn name(self) -> &'static str {
match self {
Role::Inbox => "inbox",
Role::Sent => "sent",
Role::Drafts => "drafts",
Role::Trash => "trash",
Role::Junk => "junk",
Role::Archive => "archive",
Role::All => "all",
Role::Flagged => "flagged",
}
}
pub fn from_name(name: &str) -> Option<Self> {
[
Role::Inbox,
Role::Sent,
Role::Drafts,
Role::Trash,
Role::Junk,
Role::Archive,
Role::All,
Role::Flagged,
]
.into_iter()
.find(|role| role.name() == name)
}
}
/// One listed folder.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Folder {
pub name: String,
pub role: Option<Role>,
/// False for `\Noselect` and `\NonExistent`.
pub selectable: bool,
}
/// A folder opened read-only.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Opened {
pub uidvalidity: u32,
pub exists: u32,
}
/// One fetched message: header, text or whole.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Got {
pub uid: u32,
pub flags: Vec<String>,
pub bytes: Vec<u8>,
}
impl Imap {
pub async fn connect(server: &Server, login: &Login, tls: &TlsConnector) -> Result<Self> {
let tcp = net::tcp(server).await?;
let stream = match server.security {
Security::Tls => net::tls(tcp, server, tls).await?,
Security::StartTls => {
let mut plain = Conn::new(tcp);
within("waiting for the greeting", plain.greeting()).await??;
within("starting TLS", plain.run("STARTTLS", &[], |_, _| {}))
.await??
.map_err(Error::Server)?;
net::tls(plain.wire.into_inner()?, server, tls).await?
}
};
let mut conn = Conn::new(stream);
if server.security == Security::Tls {
within("waiting for the greeting", conn.greeting()).await??;
}
let outcome = match &login.secret {
Secret::Password(password) => {
let args = [Arg::Text(&login.user), Arg::Text(password)];
within("logging in", conn.run("LOGIN", &args, |_, _| {})).await??
}
Secret::Token(token) => {
within("logging in", conn.xoauth2(&login.xoauth2(token))).await??
}
};
outcome.map_err(Error::Rejected)?;
log::info!("mail: IMAP login on {}", server.host);
Ok(Self { conn })
}
/// Every folder the server lists, with its role.
pub async fn folders(&mut self) -> Result<Vec<Folder>> {
let can = self.capabilities().await?;
let has = |name: &str| can.iter().any(|it| it.eq_ignore_ascii_case(name));
// RFC 6154 §2: roles may come only on request.
let extended = has("LIST-EXTENDED") && has("SPECIAL-USE");
let mut folders = Vec::new();
let all = [
Arg::Text(""),
Arg::Atom("*"),
Arg::Atom("RETURN (SPECIAL-USE)"),
];
let args = if extended { &all[..] } else { &all[..2] };
within(
"listing mailboxes",
self.conn.run("LIST", args, |response, raw| {
if let Response::MailboxData(MailboxDatum::List(list)) = response {
let name = mailbox_name(&list.name, raw);
folders.push(folder(name, &list.name_attributes));
}
}),
)
.await??
.map_err(Error::Server)?;
Ok(folders)
}
async fn capabilities(&mut self) -> Result<Vec<String>> {
let mut can = Vec::new();
within(
"reading capabilities",
self.conn.run("CAPABILITY", &[], |response, _| {
if let Response::Capabilities(list) = response {
can.extend(list.iter().filter_map(|it| match it {
Capability::Atom(atom) => Some(atom.to_string()),
_ => None,
}));
}
}),
)
.await??
.map_err(Error::Server)?;
Ok(can)
}
pub async fn examine(&mut self, mailbox: &str) -> Result<Opened> {
let (mut uidvalidity, mut exists) = (None, None);
within(
"opening the mailbox",
self.conn.run(
"EXAMINE",
&[Arg::Text(mailbox)],
|response, _| match response {
Response::Data { outcome, .. } => {
if let Some(ResponseCode::UidValidity(value)) = &outcome.code {
uidvalidity = Some(*value);
}
}
Response::MailboxData(MailboxDatum::Exists(count)) => exists = Some(*count),
_ => {}
},
),
)
.await??
.map_err(Error::Server)?;
let uidvalidity = uidvalidity
.ok_or_else(|| Error::Protocol("the mailbox opened without a UIDVALIDITY".into()))?;
// RFC 3501 §6.3.1: EXISTS is required.
let exists =
exists.ok_or_else(|| Error::Protocol("the mailbox opened without EXISTS".into()))?;
Ok(Opened {
uidvalidity,
exists,
})
}
/// Every UID in the open mailbox with its flags.
pub async fn flags(&mut self) -> Result<Vec<(u32, Vec<String>)>> {
let got = self
.uid_fetch("1:*", "(UID FLAGS)", "reading flags")
.await?;
Ok(got.into_iter().map(|(uid, it)| (uid, it.flags)).collect())
}
/// Headers of `uids`; a UID gone meanwhile is absent.
pub async fn headers(&mut self, uids: &[u32]) -> Result<Vec<Got>> {
let what = "(UID FLAGS BODY.PEEK[HEADER])";
let got = self.uid_fetch(&set(uids), what, "fetching headers").await?;
asked(got, uids, "HEADER")
}
/// Whole messages of `uids`; a UID gone meanwhile is absent.
pub async fn bodies(&mut self, uids: &[u32]) -> Result<Vec<Got>> {
let what = "(UID FLAGS BODY.PEEK[])";
let got = self
.uid_fetch(&set(uids), what, "fetching messages")
.await?;
asked(got, uids, "")
}
/// Each UID's MIME tree; a UID gone meanwhile is absent.
pub async fn structures(&mut self, uids: &[u32]) -> Result<Vec<(u32, Node)>> {
let what = "(UID BODYSTRUCTURE)";
let mut got = self
.uid_fetch(&set(uids), what, "reading structures")
.await?;
let mut out = Vec::new();
for &uid in uids {
let Some(it) = got.remove(&uid) else {
continue;
};
let node = it
.structure
.ok_or_else(|| Error::Protocol(format!("UID {uid} came without a structure")))?;
out.push((uid, node?));
}
Ok(out)
}
/// The message without its left-out parts; `None` once gone.
pub async fn text(&mut self, uid: u32, node: &Node) -> Result<Option<Got>> {
let peeks: Vec<String> = node
.sections()
.iter()
.map(|section| format!("BODY.PEEK[{section}]"))
.collect();
let what = format!("(UID FLAGS {})", peeks.join(" "));
let mut got = self
.uid_fetch(&uid.to_string(), &what, "fetching message text")
.await?;
let Some(it) = got.remove(&uid) else {
return Ok(None);
};
Ok(Some(Got {
uid,
bytes: node.build(&it.sections)?,
flags: it.flags,
}))
}
/// UIDs of messages that arrived on or after `day` (`19-Jul-2026`).
pub async fn since(&mut self, day: &str) -> Result<Vec<u32>> {
let mut uids = Vec::new();
let args = [Arg::Atom("SINCE"), Arg::Atom(day)];
within(
"searching by date",
self.conn.run("UID SEARCH", &args, |response, _| {
if let Response::MailboxData(MailboxDatum::Search(found)) = response {
uids.extend_from_slice(found);
}
}),
)
.await??
.map_err(Error::Server)?;
uids.sort_unstable();
Ok(uids)
}
/// The raw message; `None` once the UID is gone.
pub async fn fetch(&mut self, mailbox: &str, uid: u32) -> Result<Option<Vec<u8>>> {
self.examine(mailbox).await?;
Ok(self.bodies(&[uid]).await?.pop().map(|got| got.bytes))
}
pub async fn logout(mut self) -> Result<()> {
within("logging out", self.conn.run("LOGOUT", &[], |_, _| {}))
.await??
.map_err(Error::Server)
}
/// Every FETCH for a UID, merged; flags may come apart.
async fn uid_fetch(
&mut self,
uids: &str,
what: &str,
step: &'static str,
) -> Result<HashMap<u32, Fetch>> {
let mut got: HashMap<u32, Fetch> = HashMap::new();
let args = [Arg::Atom(uids), Arg::Atom(what)];
within(
step,
self.conn.run("UID FETCH", &args, |response, _| {
if let Response::Fetch(_, values) = response {
merge(&mut got, values);
}
}),
)
.await??
.map_err(Error::Server)?;
Ok(got)
}
}
#[derive(Default)]
struct Fetch {
flags: Vec<String>,
sections: HashMap<String, Vec<u8>>,
structure: Option<Result<Node>>,
}
/// Unsolicited FETCH responses without a UID are skipped.
fn merge(got: &mut HashMap<u32, Fetch>, values: &[AttributeValue]) {
let Some(uid) = values.iter().find_map(|value| match value {
AttributeValue::Uid(uid) => Some(*uid),
_ => None,
}) else {
return;
};
let it = got.entry(uid).or_default();
for value in values {
match value {
AttributeValue::Flags(flags) => {
// \Recent belongs to one session.
it.flags = flags
.iter()
.filter(|flag| !flag.eq_ignore_ascii_case(r"\Recent"))
.map(|flag| flag.to_string())
.collect();
it.flags.sort();
}
AttributeValue::BodySection {
section,
data: Some(data),
..
} => {
it.sections
.insert(parts::section_key(section), data.to_vec());
}
AttributeValue::BodyStructure(structure) => {
it.structure = Some(Node::read(structure));
}
_ => {}
}
}
}
/// The asked UIDs in order; each needs `section`.
fn asked(mut got: HashMap<u32, Fetch>, uids: &[u32], section: &str) -> Result<Vec<Got>> {
let mut out = Vec::new();
for &uid in uids {
let Some(mut it) = got.remove(&uid) else {
continue;
};
let bytes = it
.sections
.remove(section)
.ok_or_else(|| Error::Protocol(format!("UID {uid} came without {section:?}")))?;
out.push(Got {
uid,
flags: it.flags,
bytes,
});
}
Ok(out)
}
/// `1:3,7` from sorted UIDs.
fn set(uids: &[u32]) -> String {
let mut out = String::new();
let mut at = 0;
while at < uids.len() {
let start = uids[at];
let mut end = start;
while at + 1 < uids.len() && uids[at + 1] == end + 1 {
at += 1;
end = uids[at];
}
if !out.is_empty() {
out.push(',');
}
match start == end {
true => out.push_str(&start.to_string()),
false => out.push_str(&format!("{start}:{end}")),
}
at += 1;
}
out
}
fn folder(name: String, attributes: &[NameAttribute]) -> Folder {
let mut role = name.eq_ignore_ascii_case("INBOX").then_some(Role::Inbox);
let mut selectable = true;
for attribute in attributes {
match attribute {
NameAttribute::NoSelect => selectable = false,
NameAttribute::Extension(other) if other.eq_ignore_ascii_case("\\NonExistent") => {
selectable = false
}
NameAttribute::Sent => role = Some(Role::Sent),
NameAttribute::Drafts => role = Some(Role::Drafts),
NameAttribute::Trash => role = Some(Role::Trash),
NameAttribute::Junk => role = Some(Role::Junk),
NameAttribute::Archive => role = Some(Role::Archive),
NameAttribute::All => role = Some(Role::All),
NameAttribute::Flagged => role = Some(Role::Flagged),
_ => {}
}
}
Folder {
name,
role,
selectable,
}
}
/// imap-proto keeps quoted escapes; a literal is as sent.
fn mailbox_name(name: &str, raw: &[u8]) -> String {
let line = raw.strip_suffix(b"\r\n").unwrap_or(raw);
let name_end = |end: &[u8], mark: &[u8]| {
end.strip_suffix(name.as_bytes())
.is_some_and(|rest| rest.ends_with(mark))
};
let literal = name_end(line, b"}\r\n");
let quoted = line
.strip_suffix(b"\"")
.is_some_and(|end| name_end(end, b"\""));
if literal || !quoted {
return name.to_owned();
}
let mut plain = String::with_capacity(name.len());
let mut chars = name.chars();
while let Some(char) = chars.next() {
match char {
'\\' => plain.extend(chars.next()),
_ => plain.push(char),
}
}
plain
}
#[cfg(test)]
mod tests {
#[test]
fn a_uid_set_joins_runs() {
assert_eq!(super::set(&[1, 2, 3, 7, 9, 10]), "1:3,7,9:10");
assert_eq!(super::set(&[5]), "5");
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Tagged commands, literals and replies on one stream.
use base64::{Engine, engine::general_purpose::STANDARD};
use imap_proto::{Response, Status};
use tokio::io::{AsyncRead, AsyncWrite};
use crate::{
Error, Result,
wire::{Wire, words},
};
/// One command argument.
pub(super) enum Arg<'a> {
/// Written as is.
Atom(&'a str),
/// Quoted, or a literal when quotes cannot carry it.
Text(&'a str),
}
/// OK, or the words of NO or BAD.
pub(super) type Outcome = std::result::Result<(), String>;
pub(super) struct Conn<S> {
pub(super) wire: Wire<S>,
tags: u32,
}
impl<S: AsyncRead + AsyncWrite + Unpin> Conn<S> {
pub(super) fn new(io: S) -> Self {
Self {
wire: Wire::new(io),
tags: 0,
}
}
/// `* BYE` ends with the server's words.
pub(super) async fn greeting(&mut self) -> Result<()> {
self.wire
.next(|response, raw| match response {
Response::Data {
status: Status::Ok, ..
} => Ok(()),
Response::Data {
status: Status::Bye,
..
} => Err(Error::Server(words(raw))),
Response::Data { status, .. } => Err(Error::Protocol(format!(
"the greeting was {status:?}, not OK"
))),
_ => Err(Error::Protocol("the first line was not a greeting".into())),
})
.await?
}
fn tag(&mut self) -> String {
self.tags += 1;
format!("A{}", self.tags)
}
/// Sends a command; untagged responses go to `each`.
pub(super) async fn run(
&mut self,
verb: &str,
args: &[Arg<'_>],
mut each: impl FnMut(&Response, &[u8]),
) -> Result<Outcome> {
let tag = self.tag();
log::debug!("mail: IMAP {tag} {verb}");
let mut line = format!("{tag} {verb}").into_bytes();
for arg in args {
line.push(b' ');
match arg {
Arg::Atom(atom) => line.extend_from_slice(atom.as_bytes()),
Arg::Text(text) => match quoted(text)? {
Some(quoted) => line.extend_from_slice(quoted.as_bytes()),
None => {
line.extend_from_slice(format!("{{{}}}\r\n", text.len()).as_bytes());
self.wire.write(&line).await?;
line.clear();
if let Err(words) = self.proceed(&tag, &mut each).await? {
return Ok(Err(words));
}
line.extend_from_slice(text.as_bytes());
}
},
}
}
line.extend_from_slice(b"\r\n");
self.wire.write(&line).await?;
loop {
match self.step(&tag, &mut each).await? {
Step::Done(outcome) => return Ok(outcome),
Step::Untagged => {}
Step::Continue => {
return Err(Error::Protocol(
"the server asked for more than was sent".into(),
));
}
}
}
}
/// Waits for `+` before a literal.
async fn proceed(
&mut self,
tag: &str,
each: &mut impl FnMut(&Response, &[u8]),
) -> Result<Outcome> {
loop {
match self.step(tag, each).await? {
Step::Continue => return Ok(Ok(())),
Step::Done(Ok(())) => {
return Err(Error::Protocol(
"the command ended before its literal".into(),
));
}
Step::Done(refused) => return Ok(refused),
Step::Untagged => {}
}
}
}
/// Answers the first challenge; empty after, so a refusal ends.
pub(super) async fn xoauth2(&mut self, initial: &str) -> Result<Outcome> {
let tag = self.tag();
self.wire
.write(format!("{tag} AUTHENTICATE XOAUTH2\r\n").as_bytes())
.await?;
let mut answer = Some(STANDARD.encode(initial));
loop {
match self.step(&tag, &mut |_, _| {}).await? {
Step::Continue => {
let line = answer.take().unwrap_or_default() + "\r\n";
self.wire.write(line.as_bytes()).await?;
}
Step::Done(outcome) => return Ok(outcome),
Step::Untagged => {}
}
}
}
async fn step(&mut self, tag: &str, each: &mut impl FnMut(&Response, &[u8])) -> Result<Step> {
self.wire
.next(|response, raw| match response {
Response::Continue(_) => Ok(Step::Continue),
Response::Done {
tag: done, status, ..
} => {
if done.0 != tag {
return Err(Error::Protocol(format!(
"a reply for {} came while waiting for {tag}",
done.0
)));
}
match status {
Status::Ok => Ok(Step::Done(Ok(()))),
Status::No | Status::Bad => Ok(Step::Done(Err(words(raw)))),
other => Err(Error::Protocol(format!("{tag} ended with {other:?}"))),
}
}
untagged => {
each(untagged, raw);
Ok(Step::Untagged)
}
})
.await?
}
}
enum Step {
Continue,
Untagged,
Done(Outcome),
}
/// `None` when only a literal can carry the text.
fn quoted(text: &str) -> Result<Option<String>> {
if text.contains('\0') {
return Err(Error::Invalid("IMAP cannot carry a NUL byte".into()));
}
if !text
.bytes()
.all(|byte| byte.is_ascii() && byte != b'\r' && byte != b'\n')
{
return Ok(None);
}
let mut quoted = String::with_capacity(text.len() + 2);
quoted.push('"');
for char in text.chars() {
if char == '"' || char == '\\' {
quoted.push('\\');
}
quoted.push(char);
}
quoted.push('"');
Ok(Some(quoted))
}
+290
View File
@@ -0,0 +1,290 @@
//! Text without attachments, from BODYSTRUCTURE (§12.3).
use std::collections::HashMap;
use imap_proto::{BodyParams, BodyStructure, MessageSection, SectionPath};
use crate::{Error, Result};
/// A message's MIME tree; paths are IMAP part numbers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Node {
/// `body` parts come down; the rest wait for a request.
Leaf { path: Vec<u32>, body: bool },
Multi {
path: Vec<u32>,
boundary: String,
children: Vec<Node>,
},
}
impl Node {
/// The reader's rules: `mime/walk.rs`.
pub(super) fn read(structure: &BodyStructure) -> Result<Self> {
match structure {
BodyStructure::Multipart { .. } => body(structure, Vec::new()),
single => Ok(Node::Leaf {
path: vec![1],
body: !attachment(single) && shown(single),
}),
}
}
/// True when nothing is left out.
pub fn complete(&self) -> bool {
match self {
Node::Leaf { body, .. } => *body,
Node::Multi { children, .. } => children.iter().all(Node::complete),
}
}
/// Part numbers left out, as `2` or `1.3`.
pub fn missing(&self) -> Vec<String> {
let mut out = Vec::new();
self.walk(&mut |node| {
if let Node::Leaf { path, body: false } = node {
out.push(number(path));
}
});
out
}
/// The FETCH sections that rebuild it.
pub fn sections(&self) -> Vec<String> {
let mut out = vec!["HEADER".to_owned()];
if let Node::Leaf { body, .. } = self {
if *body {
out.push("1".into());
}
return out;
}
self.walk(&mut |node| match node {
Node::Leaf { path, body } => {
out.push(mime(path));
if *body {
out.push(number(path));
}
}
Node::Multi { path, .. } if !path.is_empty() => out.push(mime(path)),
Node::Multi { .. } => {}
});
out
}
/// The message with left-out parts empty.
pub fn build(&self, got: &HashMap<String, Vec<u8>>) -> Result<Vec<u8>> {
let mut out = header(take(got, "HEADER")?);
match self {
Node::Leaf { body: true, .. } => out.extend_from_slice(take(got, "1")?),
Node::Leaf { body: false, .. } => {}
multi => content(multi, got, &mut out)?,
}
Ok(out)
}
fn walk(&self, each: &mut impl FnMut(&Node)) {
each(self);
if let Node::Multi { children, .. } = self {
for child in children {
child.walk(each);
}
}
}
}
/// The key a FETCH section comes back under.
pub(super) fn section_key(section: &Option<SectionPath>) -> String {
let named = |part: &MessageSection| match part {
MessageSection::Header => "HEADER",
MessageSection::Mime => "MIME",
MessageSection::Text => "TEXT",
};
match section {
None => String::new(),
Some(SectionPath::Full(part)) => named(part).to_owned(),
Some(SectionPath::Part(path, None)) => number(path),
Some(SectionPath::Part(path, Some(part))) => format!("{}.{}", number(path), named(part)),
}
}
/// RFC 2183 §2.8, §2.9: the whole entity waits.
fn body(structure: &BodyStructure, path: Vec<u32>) -> Result<Node> {
if attachment(structure) {
return Ok(Node::Leaf { path, body: false });
}
inside(structure, path)
}
fn inside(structure: &BodyStructure, path: Vec<u32>) -> Result<Node> {
let BodyStructure::Multipart { common, bodies, .. } = structure else {
return Ok(Node::Leaf {
body: shown(structure),
path,
});
};
let boundary = param(&common.ty.params, "boundary")
.ok_or_else(|| Error::Protocol("a multipart came without a boundary".into()))?;
let related = common.ty.subtype.eq_ignore_ascii_case("related");
let root = related.then(|| root(&common.ty.params, bodies));
let children = bodies
.iter()
.zip(1..)
.enumerate()
.map(|(at, (child, number))| {
let mut next = path.clone();
next.push(number);
match root {
None => body(child, next),
Some(Root::At(root)) if root == at => inside(child, next),
Some(Root::Unseen) if multipart(child) => inside(child, next),
// RFC 2387 §4, §6.1: one entity, no disposition.
Some(_) => Ok(Node::Leaf {
body: id(child).is_some(),
path: next,
}),
}
})
.collect::<Result<_>>()?;
Ok(Node::Multi {
path,
boundary: boundary.to_owned(),
children,
})
}
/// The related root, or one hidden in a multipart.
#[derive(Clone, Copy)]
enum Root {
At(usize),
Unseen,
}
/// RFC 2387: `start` names the root, else the first.
fn root(params: &BodyParams, bodies: &[BodyStructure]) -> Root {
let Some(start) = param(params, "start") else {
return Root::At(0);
};
if let Some(at) = bodies
.iter()
.position(|child| id(child).is_some_and(|id| bare(id) == bare(start)))
{
return Root::At(at);
}
if bodies.iter().any(multipart) {
return Root::Unseen;
}
log::warn!("mail: related start names no part; first part is the root");
Root::At(0)
}
fn multipart(structure: &BodyStructure) -> bool {
matches!(structure, BodyStructure::Multipart { .. })
}
/// Plain and HTML text, and `cid:` targets.
fn shown(structure: &BodyStructure) -> bool {
match structure {
BodyStructure::Text { common, .. }
if ["plain", "html"]
.iter()
.any(|kind| common.ty.subtype.eq_ignore_ascii_case(kind)) =>
{
true
}
BodyStructure::Text { .. } | BodyStructure::Basic { .. } => id(structure).is_some(),
BodyStructure::Message { .. } | BodyStructure::Multipart { .. } => false,
}
}
/// RFC 2183 §2.8: unknown dispositions are attachments.
fn attachment(structure: &BodyStructure) -> bool {
let common = match structure {
BodyStructure::Basic { common, .. }
| BodyStructure::Text { common, .. }
| BodyStructure::Message { common, .. }
| BodyStructure::Multipart { common, .. } => common,
};
common
.disposition
.as_ref()
.is_some_and(|it| !it.ty.eq_ignore_ascii_case("inline"))
}
fn id<'a>(structure: &'a BodyStructure) -> Option<&'a str> {
match structure {
BodyStructure::Basic { other, .. }
| BodyStructure::Text { other, .. }
| BodyStructure::Message { other, .. } => other.id.as_deref(),
BodyStructure::Multipart { .. } => None,
}
}
fn bare(id: &str) -> &str {
id.trim().trim_start_matches('<').trim_end_matches('>')
}
fn param<'a>(params: &'a BodyParams, name: &str) -> Option<&'a str> {
params
.iter()
.flatten()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_ref())
}
fn content(node: &Node, got: &HashMap<String, Vec<u8>>, out: &mut Vec<u8>) -> Result<()> {
match node {
Node::Leaf { path, body: true } => out.extend_from_slice(take(got, &number(path))?),
Node::Leaf { body: false, .. } => {}
Node::Multi {
boundary, children, ..
} => {
for child in children {
let path = match child {
Node::Leaf { path, .. } | Node::Multi { path, .. } => path,
};
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
out.extend(header(take(got, &mime(path))?));
content(child, got, out)?;
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
}
}
Ok(())
}
/// A header block ends with a blank line.
fn header(bytes: &[u8]) -> Vec<u8> {
let mut out = bytes.to_vec();
if out.is_empty() {
return b"\r\n".to_vec();
}
while !out.ends_with(b"\r\n\r\n") {
out.extend_from_slice(b"\r\n");
}
out
}
fn take<'a>(got: &'a HashMap<String, Vec<u8>>, key: &str) -> Result<&'a [u8]> {
got.get(key)
.map(Vec::as_slice)
.ok_or_else(|| Error::Protocol(format!("section {key} did not come")))
}
/// A whole message's body is `TEXT`.
fn number(path: &[u32]) -> String {
if path.is_empty() {
return "TEXT".into();
}
path.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(".")
}
fn mime(path: &[u32]) -> String {
format!("{}.MIME", number(path))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,151 @@
use std::collections::HashMap;
use imap_proto::{AttributeValue, Response};
use super::Node;
/// The node of a `* 1 FETCH (BODYSTRUCTURE …)` line.
fn node(structure: &str) -> Node {
let line = format!("* 1 FETCH (BODYSTRUCTURE {structure})\r\n");
let (_, response) = Response::parse(line.as_bytes()).unwrap();
let Response::Fetch(_, attributes) = response else {
panic!("not a FETCH: {line}");
};
let [AttributeValue::BodyStructure(structure)] = attributes.as_slice() else {
panic!("no structure: {line}");
};
Node::read(structure).unwrap()
}
/// Reads the rebuilt message; sections not given come empty.
fn rebuilt(node: &Node, given: &[(&str, &str)]) -> crate::Mail {
let mut got: HashMap<String, Vec<u8>> = given
.iter()
.map(|(key, text)| ((*key).to_owned(), text.replace('\n', "\r\n").into_bytes()))
.collect();
for section in node.sections() {
got.entry(section).or_default();
}
crate::read(&node.build(&got).unwrap()).unwrap()
}
const TEXT: &str = "(\"TEXT\" \"PLAIN\" (\"CHARSET\" \"us-ascii\") NIL NIL \"7BIT\" 6 1)";
const HTML: &str = "(\"TEXT\" \"HTML\" (\"CHARSET\" \"us-ascii\") NIL NIL \"7BIT\" 9 1)";
#[test]
fn an_attached_multipart_waits_whole() {
let structure = format!(
"({TEXT}({TEXT}{HTML} \"ALTERNATIVE\" (\"BOUNDARY\" \"b2\") \
(\"ATTACHMENT\" (\"FILENAME\" \"note\")) NIL NIL) \"MIXED\" (\"BOUNDARY\" \"b1\") NIL NIL NIL)"
);
let node = node(&structure);
assert!(!node.complete());
assert_eq!(node.missing(), ["2"]);
assert_eq!(node.sections(), ["HEADER", "1.MIME", "1", "2.MIME"]);
let mail = rebuilt(
&node,
&[
(
"HEADER",
"MIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"b1\"\n\n",
),
("1.MIME", "Content-Type: text/plain\n\n"),
("1", "hello\n"),
(
"2.MIME",
"Content-Type: multipart/alternative; boundary=\"b2\"\n\
Content-Disposition: attachment; filename=\"note\"\n\n",
),
],
);
assert_eq!(mail.text.as_deref().map(str::trim_end), Some("hello"));
assert_eq!(mail.attachments.len(), 1);
assert_eq!(mail.attachments[0].content_type, "multipart/alternative");
}
#[test]
fn a_related_image_marked_attachment_comes_down() {
let structure = format!(
"({HTML}(\"IMAGE\" \"PNG\" NIL \"<logo>\" NIL \"BASE64\" 12 NIL \
(\"ATTACHMENT\" (\"FILENAME\" \"logo.png\")) NIL NIL) \
\"RELATED\" (\"BOUNDARY\" \"r1\") NIL NIL NIL)"
);
let node = node(&structure);
assert!(node.complete());
assert!(node.missing().is_empty());
let mail = rebuilt(
&node,
&[
(
"HEADER",
"MIME-Version: 1.0\nContent-Type: multipart/related; boundary=\"r1\"\n\n",
),
("1.MIME", "Content-Type: text/html\n\n"),
("1", "<img src=\"cid:logo\">\n"),
(
"2.MIME",
"Content-Type: image/png\nContent-ID: <logo>\n\
Content-Disposition: attachment; filename=\"logo.png\"\n\
Content-Transfer-Encoding: base64\n\n",
),
("2", "iVBORw0KGgo=\n"),
],
);
assert_eq!(mail.inline.len(), 1);
assert_eq!(mail.inline[0].bytes, b"\x89PNG\r\n\x1a\n");
}
#[test]
fn a_related_start_root_ignores_its_disposition() {
let structure = "((\"IMAGE\" \"PNG\" NIL NIL NIL \"BASE64\" 8 NIL NIL NIL NIL)\
(\"TEXT\" \"HTML\" NIL \"<root>\" NIL \"7BIT\" 9 1 NIL (\"ATTACHMENT\" NIL) NIL NIL) \
\"RELATED\" (\"BOUNDARY\" \"r1\" \"START\" \"<root>\") NIL NIL NIL)";
let node = node(structure);
assert_eq!(node.missing(), ["1"]);
}
#[test]
fn an_attached_message_root_waits_whole() {
let structure =
format!("({TEXT}{TEXT} \"MIXED\" (\"BOUNDARY\" \"b1\") (\"ATTACHMENT\" NIL) NIL NIL)");
let node = node(&structure);
assert_eq!(node.missing(), ["TEXT"]);
assert_eq!(node.sections(), ["HEADER"]);
}
#[test]
fn a_related_start_may_name_a_multipart() {
let structure = format!(
"((\"IMAGE\" \"PNG\" NIL \"<logo>\" NIL \"BASE64\" 12 NIL NIL NIL NIL)\
({TEXT}{HTML} \"ALTERNATIVE\" (\"BOUNDARY\" \"b2\") NIL NIL NIL) \
\"RELATED\" (\"BOUNDARY\" \"r1\" \"START\" \"<root>\") NIL NIL NIL)"
);
let node = node(&structure);
assert!(node.complete());
let mail = rebuilt(
&node,
&[
(
"HEADER",
"MIME-Version: 1.0\nContent-Type: multipart/related; boundary=\"r1\"; \
start=\"<root>\"\n\n",
),
(
"1.MIME",
"Content-Type: image/png\nContent-ID: <logo>\n\
Content-Transfer-Encoding: base64\n\n",
),
("1", "iVBORw0KGgo=\n"),
(
"2.MIME",
"Content-Type: multipart/alternative; boundary=\"b2\"\nContent-ID: <root>\n\n",
),
("2.1.MIME", "Content-Type: text/plain\n\n"),
("2.1", "hello\n"),
("2.2.MIME", "Content-Type: text/html\n\n"),
("2.2", "<p>hello</p>\n"),
],
);
assert_eq!(mail.text.as_deref().map(str::trim_end), Some("hello"));
assert!(mail.html.is_some());
}
+28
View File
@@ -0,0 +1,28 @@
//! Mail protocols, sync and local storage for ElyMail.
mod account;
mod error;
mod imap;
mod mime;
mod net;
pub mod oauth;
mod pop;
mod smtp;
mod store;
pub mod sync;
mod vault;
mod wire;
pub use account::{Account, Auth, Checked, Protocol};
pub use error::{Error, Result};
pub use imap::{Folder, Got, Imap, Opened, Role};
pub use mime::{Built, Draft, Mail, Mailbox, Part, build, read};
pub use net::{Login, Secret, Security, Server, system_tls};
pub use pop::Pop;
pub use smtp::Smtp;
pub use store::{
AccountId, Cached, Fetched, Filter, Held, Key, LocalFolder, MailboxId, MessageId, Scope, Sizes,
Store, Stored, Summary, Synced,
};
pub use tokio_rustls::TlsConnector;
pub use vault::{Backend, Kept, Keyring, Vault};
+275
View File
@@ -0,0 +1,275 @@
use mail_builder::{
MessageBuilder,
headers::{address::Address as Out, content_type::ContentType, message_id::MessageId},
mime::MimePart,
};
use mail_parser::{Address, HeaderValue, MessageParser};
use serde::{Deserialize, Serialize};
use crate::{Error, Result, smtp::mailbox};
mod walk;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mailbox {
pub name: Option<String>,
pub email: String,
}
/// An attachment or a `cid:` target.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Part {
pub name: Option<String>,
pub content_type: String,
/// Content-Type parameters, such as charset.
pub params: Vec<(String, String)>,
pub content_id: Option<String>,
/// Transfer-decoded; a multipart keeps its headers.
pub bytes: Vec<u8>,
}
/// A message as read; bodies are the sender's own parts.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mail {
pub subject: Option<String>,
pub from: Vec<Mailbox>,
pub to: Vec<Mailbox>,
pub cc: Vec<Mailbox>,
/// Seconds since the Unix epoch.
pub date: Option<i64>,
pub message_id: Option<String>,
pub in_reply_to: Vec<String>,
pub references: Vec<String>,
pub text: Option<String>,
pub html: Option<String>,
pub inline: Vec<Part>,
pub attachments: Vec<Part>,
/// The header block as it arrived.
pub headers: String,
}
/// A message to write; Bcc stays in the SMTP envelope.
#[derive(Clone, Debug)]
pub struct Draft {
pub from: Mailbox,
pub to: Vec<Mailbox>,
pub cc: Vec<Mailbox>,
pub subject: String,
pub text: String,
pub html: Option<String>,
pub attachments: Vec<Part>,
pub in_reply_to: Option<String>,
pub references: Vec<String>,
/// Seconds since the Unix epoch.
pub date: i64,
}
pub struct Built {
/// Without angle brackets, as `Mail::message_id` reads it.
pub message_id: String,
pub bytes: Vec<u8>,
}
pub fn read(raw: &[u8]) -> Result<Mail> {
let message = MessageParser::default()
.parse(raw)
.ok_or_else(|| Error::Mime("not a mail message".into()))?;
let problems = message
.parts
.iter()
.filter(|part| part.is_encoding_problem)
.count();
if problems > 0 {
log::warn!("mail: {problems} MIME parts did not decode cleanly");
}
if let Err(err) = walk::depth(&message) {
walk::dispose(message);
return Err(err);
}
let header_end = message.root_part().offset_body as usize;
let shape = walk::shape(&message);
Ok(Mail {
subject: message.subject().map(str::to_owned),
from: mailboxes(message.from().into_iter()),
to: mailboxes(message.all_to()),
cc: mailboxes(message.all_cc()),
date: message.date().map(|date| date.to_timestamp()),
message_id: message.message_id().map(str::to_owned),
in_reply_to: ids(message.in_reply_to()),
references: ids(message.references()),
text: joined(shape.text),
html: joined(shape.html),
inline: shape.inline,
attachments: shape.attachments,
headers: String::from_utf8_lossy(&raw[..header_end.min(raw.len())]).into_owned(),
})
}
/// Whether any header block needs SMTPUTF8.
pub(crate) fn utf8_headers(raw: &[u8]) -> Result<bool> {
let message = MessageParser::default()
.parse(raw)
.ok_or_else(|| Error::Mime("not a mail message".into()))?;
let checked = walk::depth(&message).map(|()| walk::utf8_headers(&message));
walk::dispose(message);
checked
}
pub fn build(draft: &Draft) -> Result<Built> {
let people = [&draft.from].into_iter().chain(&draft.to).chain(&draft.cc);
for person in people {
mailbox(&person.email)?;
line("a name", person.name.as_deref().unwrap_or_default())?;
}
line("the subject", &draft.subject)?;
for id in draft.in_reply_to.iter().chain(&draft.references) {
line("a message ID", id)?;
}
let message_id = message_id(&draft.from.email)?;
let mut builder = MessageBuilder::new()
.message_id(message_id.as_str())
.date(draft.date)
.from(address(&draft.from))
.subject(draft.subject.as_str())
.body(body(draft)?);
if !draft.to.is_empty() {
builder = builder.to(Out::new_list(draft.to.iter().map(address).collect()));
}
if !draft.cc.is_empty() {
builder = builder.cc(Out::new_list(draft.cc.iter().map(address).collect()));
}
if let Some(id) = &draft.in_reply_to {
builder = builder.in_reply_to(id.as_str());
}
if !draft.references.is_empty() {
let ids = draft.references.iter().map(String::as_str);
builder = builder.references(MessageId::new_list(ids));
}
let bytes = builder
.write_to_vec()
.map_err(|err| Error::Mime(err.to_string()))?;
Ok(Built { message_id, bytes })
}
/// Mixed of the alternative bodies and the attachments.
fn body(draft: &Draft) -> Result<MimePart<'_>> {
let text = MimePart::new("text/plain", draft.text.as_str());
let body = match &draft.html {
Some(html) => {
let html = MimePart::new("text/html", html.as_str());
MimePart::new("multipart/alternative", vec![text, html])
}
None => text,
};
if draft.attachments.is_empty() {
return Ok(body);
}
let mut parts = vec![body];
for part in &draft.attachments {
parts.push(attachment(part)?);
}
Ok(MimePart::new("multipart/mixed", parts))
}
fn attachment(part: &Part) -> Result<MimePart<'_>> {
let name = part
.name
.as_deref()
.ok_or_else(|| Error::Invalid("an attachment needs a file name".into()))?;
line("a file name", name)?;
line("a content type", &part.content_type)?;
let mut kind = ContentType::new(part.content_type.as_str());
for (key, value) in &part.params {
line("a content type parameter", key)?;
line("a content type parameter", value)?;
kind = kind.attribute(key.as_str(), value.as_str());
}
let composite = ["multipart/", "message/"]
.iter()
.any(|prefix| part.content_type.starts_with(prefix));
let mut attachment = if composite {
// RFC 2045 §6.4: composites stay unencoded.
let body = composite_body(part)?;
let encoding = if body.is_ascii() { "7bit" } else { "8bit" };
MimePart::new(kind, body).transfer_encoding(encoding)
} else {
MimePart::new(kind, part.bytes.as_slice())
}
.attachment(name);
if let Some(id) = &part.content_id {
line("a content ID", id)?;
attachment = attachment.cid(id.as_str());
}
Ok(attachment)
}
/// A multipart as read carries its headers.
fn composite_body(part: &Part) -> Result<&[u8]> {
if !part.content_type.starts_with("multipart/") {
return Ok(&part.bytes);
}
if !part.params.iter().any(|(key, _)| key == "boundary") {
return Err(Error::Invalid(
"a multipart attachment has no boundary".into(),
));
}
if let Some(body) = part.bytes.strip_prefix(b"\r\n") {
return Ok(body);
}
part.bytes
.windows(4)
.position(|end| end == b"\r\n\r\n")
.map(|at| &part.bytes[at + 4..])
.ok_or_else(|| Error::Invalid("a multipart attachment has no header end".into()))
}
/// A random left side at the sender's domain.
fn message_id(from: &str) -> Result<String> {
let (_, domain) = from
.rsplit_once('@')
.ok_or_else(|| Error::Invalid(format!("not a mail address: {from:?}")))?;
let mut random = [0u8; 16];
getrandom::fill(&mut random).map_err(|err| Error::Mime(err.to_string()))?;
let left: String = random.iter().map(|byte| format!("{byte:02x}")).collect();
Ok(format!("{left}@{domain}"))
}
/// A line break would start a new header.
fn line(what: &str, text: &str) -> Result<()> {
if text.contains(['\r', '\n']) {
return Err(Error::Invalid(format!("{what} has a line break")));
}
Ok(())
}
fn address(mailbox: &Mailbox) -> Out<'_> {
Out::new_address(mailbox.name.as_deref(), mailbox.email.as_str())
}
fn mailboxes<'a>(lists: impl Iterator<Item = &'a Address<'a>>) -> Vec<Mailbox> {
lists
.flat_map(Address::iter)
.filter_map(|addr| {
let Some(email) = addr.address.as_deref() else {
log::warn!("mail: an address without an email was skipped");
return None;
};
Some(Mailbox {
name: addr.name.as_deref().map(str::to_owned),
email: email.to_owned(),
})
})
.collect()
}
fn ids(value: &HeaderValue) -> Vec<String> {
match value {
HeaderValue::Text(id) => vec![id.to_string()],
HeaderValue::TextList(ids) => ids.iter().map(ToString::to_string).collect(),
_ => Vec::new(),
}
}
fn joined(parts: Vec<String>) -> Option<String> {
(!parts.is_empty()).then(|| parts.join("\n"))
}
+267
View File
@@ -0,0 +1,267 @@
use mail_parser::{
Encoding, Message, MessagePart, MimeHeaders, PartType,
decoders::{base64::base64_decode, quoted_printable::quoted_printable_decode},
};
use super::Part;
use crate::{Error, Result};
/// Deeper trees are refused before the walk.
const DEPTH: usize = 64;
/// What a MIME tree shows: bodies, `cid:` targets, attachments.
#[derive(Default)]
pub(super) struct Shape {
pub text: Vec<String>,
pub html: Vec<String>,
pub inline: Vec<Part>,
pub attachments: Vec<Part>,
}
pub(super) fn shape(message: &Message) -> Shape {
let mut shape = Shape::default();
Walk { message }.body(0, &mut shape);
shape
}
/// Iterative, so no tree can exhaust the stack.
pub(super) fn depth(message: &Message) -> Result<()> {
let mut stack = vec![(message, 0, 1)];
while let Some((message, id, level)) = stack.pop() {
if level > DEPTH {
return Err(Error::Mime(format!("MIME parts nest deeper than {DEPTH}")));
}
match &message.parts[id as usize].body {
PartType::Multipart(children) => {
stack.extend(children.iter().map(|&child| (message, child, level + 1)));
}
PartType::Message(inner) => stack.push((inner, 0, level + 1)),
_ => {}
}
}
Ok(())
}
/// Drops a deep tree without recursion.
pub(super) fn dispose(message: Message) {
let mut stack = vec![message];
while let Some(mut message) = stack.pop() {
for part in &mut message.parts {
if let PartType::Message(inner) = std::mem::take(&mut part.body) {
stack.push(inner);
}
}
}
}
/// RFC 6531 §3.4: 8-bit header bytes at any depth.
pub(super) fn utf8_headers(message: &Message) -> bool {
let mut stack = vec![message];
while let Some(message) = stack.pop() {
// Part offsets index the whole buffer.
let raw = message.raw_message.as_ref();
for part in &message.parts {
let header = &raw[part.offset_header as usize..part.offset_body as usize];
if !header.is_ascii() {
return true;
}
// Encoded parts carry no 8-bit bytes.
match &part.body {
PartType::Message(inner) if part.encoding == Encoding::None => stack.push(inner),
_ => {}
}
}
}
false
}
struct Walk<'a, 'x> {
message: &'a Message<'x>,
}
impl Walk<'_, '_> {
fn part(&self, id: u32) -> &MessagePart<'_> {
&self.message.parts[id as usize]
}
fn body(&self, id: u32, out: &mut Shape) {
let part = self.part(id);
// RFC 2183 §2.8, §2.9: the whole entity.
if !inline(part) {
out.attachments.push(self.leaf(part));
return;
}
self.content(id, out, true);
}
/// A related root is no `cid:` target.
fn content(&self, id: u32, out: &mut Shape, target: bool) {
let part = self.part(id);
match &part.body {
PartType::Multipart(children) => {
self.target(part, target, out);
match multipart(part).as_str() {
"alternative" => self.alternative(children, out),
"related" => self.related(part, children, out),
_ => children.iter().for_each(|&child| self.body(child, out)),
}
}
PartType::Text(text) if plain(part) => {
out.text.push(text.to_string());
self.target(part, target, out);
}
PartType::Html(html) => {
out.html.push(html.to_string());
self.target(part, target, out);
}
_ => {
let leaf = self.leaf(part);
let message = matches!(part.body, PartType::Message(_));
match leaf.content_id {
Some(_) if !message => out.inline.push(leaf),
_ => out.attachments.push(leaf),
}
}
}
}
/// RFC 2392: any part with a Content-ID.
fn target(&self, part: &MessagePart, target: bool, out: &mut Shape) {
if target && part.content_id().is_some() {
out.inline.push(self.leaf(part));
}
}
/// RFC 2046 §5.1.4: the last supported version wins.
fn alternative(&self, children: &[u32], out: &mut Shape) {
let versions: Vec<Shape> = children
.iter()
.map(|&child| {
let mut version = Shape::default();
self.body(child, &mut version);
version
})
.collect();
let text = versions.iter().rposition(|v| !v.text.is_empty());
let html = versions.iter().rposition(|v| !v.html.is_empty());
for (at, version) in versions.into_iter().enumerate() {
let bodiless = version.text.is_empty() && version.html.is_empty();
if Some(at) != text && Some(at) != html && !bodiless {
continue;
}
if Some(at) == text {
out.text.extend(version.text);
}
if Some(at) == html {
out.html.extend(version.html);
}
out.inline.extend(version.inline);
out.attachments.extend(version.attachments);
}
}
/// RFC 2387: `start` names the root; §4 voids dispositions.
fn related(&self, part: &MessagePart, children: &[u32], out: &mut Shape) {
let start = part
.content_type()
.and_then(|kind| kind.attribute("start"))
.map(|start| start.trim().trim_start_matches('<').trim_end_matches('>'));
let root = match start {
None => 0,
Some(start) => children
.iter()
.position(|&child| self.part(child).content_id() == Some(start))
.unwrap_or_else(|| {
log::warn!("mail: related start names no part; first part is the root");
0
}),
};
for (at, &child) in children.iter().enumerate() {
if at == root {
self.content(child, out, false);
continue;
}
// RFC 2387 §6.1: a resource is one entity.
let leaf = self.leaf(self.part(child));
match leaf.content_id {
Some(_) => out.inline.push(leaf),
None => out.attachments.push(leaf),
}
}
}
fn leaf(&self, part: &MessagePart) -> Part {
let kind = part.content_type();
let content_type = kind.map_or_else(
// RFC 2046 §5.1.5: digest children are messages.
|| match part.body {
PartType::Message(_) => "message/rfc822".to_owned(),
_ => "text/plain".to_owned(),
},
|kind| match &kind.c_subtype {
Some(subtype) => format!("{}/{subtype}", kind.c_type),
None => kind.c_type.to_string(),
},
);
let params = kind
.and_then(|kind| kind.attributes())
.into_iter()
.flatten()
.filter(|attr| !attr.name.eq_ignore_ascii_case("name"))
.map(|attr| (attr.name.to_ascii_lowercase(), attr.value.to_string()))
.collect();
Part {
name: part.attachment_name().map(str::to_owned),
content_type: content_type.to_ascii_lowercase(),
params,
content_id: part.content_id().map(str::to_owned),
bytes: self.octets(part),
}
}
/// Transfer-decoded bytes; text keeps its own charset.
fn octets(&self, part: &MessagePart) -> Vec<u8> {
let raw = self.message.raw_message();
let body = &raw[part.offset_body as usize..part.offset_end as usize];
match &part.body {
PartType::Binary(bytes) | PartType::InlineBinary(bytes) => bytes.to_vec(),
PartType::Message(message) => message.raw_message().to_vec(),
PartType::Multipart(_) => {
raw[part.offset_header as usize..part.offset_end as usize].to_vec()
}
PartType::Text(_) | PartType::Html(_) => {
let decoded = match part.encoding {
Encoding::None => Some(body.to_vec()),
Encoding::QuotedPrintable => quoted_printable_decode(body),
Encoding::Base64 => base64_decode(body),
};
decoded.unwrap_or_else(|| {
log::warn!("mail: a text attachment kept its encoded bytes");
body.to_vec()
})
}
}
}
}
/// RFC 2183 §2.8: unknown types are attachments.
fn inline(part: &MessagePart) -> bool {
part.content_disposition()
.is_none_or(|disposition| disposition.c_type.eq_ignore_ascii_case("inline"))
}
fn multipart(part: &MessagePart) -> String {
part.content_type()
.and_then(|kind| kind.c_subtype.as_deref())
.unwrap_or_default()
.to_ascii_lowercase()
}
/// RFC 2045 §5.2: no Content-Type is text/plain.
fn plain(part: &MessagePart) -> bool {
part.content_type().is_none_or(|kind| {
kind.c_subtype
.as_deref()
.is_none_or(|subtype| subtype.eq_ignore_ascii_case("plain"))
})
}
+85
View File
@@ -0,0 +1,85 @@
use std::{future::Future, sync::Arc, time::Duration};
use rustls::{ClientConfig, crypto::ring, pki_types::ServerName};
use rustls_platform_verifier::BuilderVerifierExt;
use serde::{Deserialize, Serialize};
use tokio::net::TcpStream;
use tokio_rustls::{TlsConnector, client::TlsStream};
use crate::{Error, Result};
pub(crate) type Stream = TlsStream<TcpStream>;
const WAIT: Duration = Duration::from_secs(60);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Server {
pub host: String,
pub port: u16,
pub security: Security,
}
/// No plaintext: every mode ends in TLS.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Security {
Tls,
StartTls,
}
#[derive(Clone)]
pub struct Login {
pub user: String,
pub secret: Secret,
}
#[derive(Clone)]
pub enum Secret {
Password(String),
/// An OAuth access token, sent as XOAUTH2.
Token(String),
}
impl Login {
/// The SASL XOAUTH2 initial response, before base64.
pub(crate) fn xoauth2(&self, token: &str) -> String {
format!("user={}\x01auth=Bearer {token}\x01\x01", self.user)
}
}
/// Verifies servers with the system's trust store.
pub fn system_tls() -> Result<TlsConnector> {
let config = ClientConfig::builder_with_provider(Arc::new(ring::default_provider()))
.with_safe_default_protocol_versions()
.and_then(|builder| builder.with_platform_verifier())
.map_err(|err| Error::Tls(err.to_string()))?
.with_no_client_auth();
Ok(TlsConnector::from(Arc::new(config)))
}
pub(crate) async fn within<T>(step: &'static str, work: impl Future<Output = T>) -> Result<T> {
tokio::time::timeout(WAIT, work)
.await
.map_err(|_| Error::Timeout(step))
}
pub(crate) async fn tcp(server: &Server) -> Result<TcpStream> {
let stream = within(
"connecting",
TcpStream::connect((server.host.as_str(), server.port)),
)
.await?
.map_err(Error::Connect)?;
log::info!("mail: connected to {}:{}", server.host, server.port);
Ok(stream)
}
pub(crate) async fn tls(tcp: TcpStream, server: &Server, tls: &TlsConnector) -> Result<Stream> {
let name = ServerName::try_from(server.host.clone())
.map_err(|err| Error::Tls(format!("{}: {err}", server.host)))?;
let stream = within("starting TLS", tls.connect(name, tcp))
.await?
.map_err(|err| Error::Tls(err.to_string()))?;
log::info!("mail: TLS with {}:{}", server.host, server.port);
Ok(stream)
}
+277
View File
@@ -0,0 +1,277 @@
use std::time::SystemTime;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Digest, Sha256};
use tokio::net::TcpListener;
use crate::{Error, Result};
mod loopback;
mod token;
/// One OAuth authorization server and this app's client.
pub struct Provider {
pub name: &'static str,
pub authorize: String,
pub token: String,
pub client_id: String,
/// Google's desktop clients carry one; Microsoft's do not.
pub client_secret: Option<String>,
pub scope: String,
/// Authorize parameters this server adds.
pub extra: &'static [(&'static str, &'static str)],
/// The registered loopback host.
pub redirect_host: &'static str,
}
/// Tokens from one exchange or refresh.
pub struct Tokens {
pub access: String,
pub expires_at: SystemTime,
/// A new refresh token; store it, drop the old one.
pub refresh: Option<String>,
/// The signed-in address, from the ID token.
pub email: Option<String>,
}
/// Gmail: IMAP, POP3 and SMTP under one scope.
pub fn google() -> Result<Provider> {
Ok(gmail(
built("google", option_env!("ELY_GOOGLE_CLIENT_ID"))?,
built("google", option_env!("ELY_GOOGLE_CLIENT_SECRET"))?,
))
}
fn gmail(client_id: String, secret: String) -> Provider {
Provider {
name: "google",
authorize: "https://accounts.google.com/o/oauth2/v2/auth".into(),
token: "https://oauth2.googleapis.com/token".into(),
client_id,
client_secret: Some(secret),
scope: "https://mail.google.com/ openid email".into(),
// Refresh token on every consent.
extra: &[("access_type", "offline"), ("prompt", "consent")],
redirect_host: "127.0.0.1",
}
}
/// Outlook.com and Microsoft 365.
pub fn microsoft() -> Result<Provider> {
Ok(outlook(built(
"microsoft",
option_env!("ELY_MICROSOFT_CLIENT_ID"),
)?))
}
fn outlook(client_id: String) -> Provider {
Provider {
name: "microsoft",
authorize: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize".into(),
token: "https://login.microsoftonline.com/common/oauth2/v2.0/token".into(),
client_id,
client_secret: None,
scope: [
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/POP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
"offline_access openid email profile",
]
.join(" "),
extra: &[],
// Port ignored only for localhost.
redirect_host: "localhost",
}
}
fn built(name: &'static str, value: Option<&str>) -> Result<String> {
match value {
Some(value) if !value.is_empty() => Ok(value.to_owned()),
_ => Err(Error::Unavailable(name)),
}
}
/// A sign-in waiting for the browser to come back.
pub struct SignIn {
provider: Provider,
listener: TcpListener,
url: String,
redirect: String,
state: String,
verifier: String,
}
impl SignIn {
/// Opens the loopback port and builds the browser URL.
pub async fn start(provider: Provider, hint: &str) -> Result<Self> {
let listener = TcpListener::bind(("127.0.0.1", 0))
.await
.map_err(|err| Error::SignIn(format!("no loopback port: {err}")))?;
let port = listener
.local_addr()
.map_err(|err| Error::SignIn(format!("no loopback port: {err}")))?
.port();
let redirect = format!("http://{}:{port}", provider.redirect_host);
let state = random();
let verifier = random();
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let mut query = vec![
("client_id", provider.client_id.as_str()),
("response_type", "code"),
("redirect_uri", redirect.as_str()),
("scope", provider.scope.as_str()),
("state", state.as_str()),
("code_challenge", challenge.as_str()),
("code_challenge_method", "S256"),
];
query.extend_from_slice(provider.extra);
if !hint.is_empty() {
query.push(("login_hint", hint));
}
let url = format!("{}?{}", provider.authorize, encode(&query));
log::info!("oauth: {} sign-in waits on 127.0.0.1:{port}", provider.name);
Ok(Self {
provider,
listener,
url,
redirect,
state,
verifier,
})
}
/// The page to open in the system browser.
pub fn url(&self) -> &str {
&self.url
}
/// Waits for the redirect, then trades the code for tokens.
pub async fn finish(self) -> Result<Tokens> {
let code = loopback::code(&self.listener, &self.state).await?;
log::info!("oauth: {} returned a code", self.provider.name);
drop(self.listener);
let mut form = vec![
("grant_type", "authorization_code".to_owned()),
("client_id", self.provider.client_id.clone()),
("code", code),
("code_verifier", self.verifier),
("redirect_uri", self.redirect),
];
if let Some(secret) = &self.provider.client_secret {
form.push(("client_secret", secret.clone()));
}
let tokens = token::post(&self.provider, form, false).await?;
if tokens.refresh.is_none() {
return Err(Error::SignIn("no refresh token granted".into()));
}
Ok(tokens)
}
}
/// A fresh access token; a refused refresh needs sign-in again.
pub async fn refresh(provider: &Provider, refresh: &str) -> Result<Tokens> {
let mut form = vec![
("grant_type", "refresh_token".to_owned()),
("client_id", provider.client_id.clone()),
("refresh_token", refresh.to_owned()),
];
if let Some(secret) = &provider.client_secret {
form.push(("client_secret", secret.clone()));
}
token::post(provider, form, true).await
}
/// 32 random bytes, base64url: a PKCE verifier or a state.
fn random() -> String {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("system randomness");
URL_SAFE_NO_PAD.encode(bytes)
}
fn encode(pairs: &[(&str, &str)]) -> String {
pairs
.iter()
.map(|(key, value)| format!("{}={}", escape(key), escape(value)))
.collect::<Vec<_>>()
.join("&")
}
fn escape(text: &str) -> String {
let mut out = String::new();
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_client_id_makes_the_provider_unavailable() {
assert!(matches!(
built("google", None),
Err(Error::Unavailable("google"))
));
assert!(matches!(
built("google", Some("")),
Err(Error::Unavailable("google"))
));
assert_eq!(built("google", Some("id-1")).unwrap(), "id-1");
}
async fn asked(provider: Provider) -> Vec<(String, String)> {
let sign_in = SignIn::start(provider, "").await.unwrap();
let (_, query) = sign_in.url().split_once('?').unwrap();
form_urlencoded::parse(query.as_bytes())
.into_owned()
.collect()
}
fn value<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
pairs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
#[tokio::test]
async fn gmail_asks_for_a_refresh_token_every_time() {
let pairs = asked(gmail("id".into(), "s".into())).await;
assert_eq!(value(&pairs, "access_type"), Some("offline"));
assert_eq!(value(&pairs, "prompt"), Some("consent"));
assert!(
value(&pairs, "scope")
.unwrap()
.contains("https://mail.google.com/")
);
}
#[tokio::test]
async fn outlook_asks_for_offline_access_and_the_address_claims() {
let pairs = asked(outlook("id".into())).await;
let scope: Vec<&str> = value(&pairs, "scope").unwrap().split(' ').collect();
for needed in ["offline_access", "openid", "email", "profile"] {
assert!(scope.contains(&needed), "{needed}");
}
assert_eq!(value(&pairs, "access_type"), None);
assert!(
value(&pairs, "redirect_uri")
.unwrap()
.starts_with("http://localhost:")
);
}
#[test]
fn query_values_are_escaped() {
assert_eq!(
encode(&[("scope", "a b/c"), ("x", "é~")]),
"scope=a%20b%2Fc&x=%C3%A9~"
);
}
}
@@ -0,0 +1,85 @@
use std::time::Duration;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
};
use crate::{Error, Result};
const LIMIT: usize = 8 * 1024;
const READ: Duration = Duration::from_secs(10);
const PAGE: &str = "ElyMail: sign-in finished. You can close this tab.";
/// Serves redirects until one carries a code or error.
pub(super) async fn code(listener: &TcpListener, state: &str) -> Result<String> {
loop {
let (mut stream, _) = listener
.accept()
.await
.map_err(|err| Error::SignIn(format!("loopback accept failed: {err}")))?;
let Some(target) = request(&mut stream).await else {
continue;
};
let query = target.split_once('?').map_or("", |(_, query)| query);
let pairs: Vec<(String, String)> = form_urlencoded::parse(query.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
let field = |name: &str| {
pairs
.iter()
.find(|(key, _)| key == name)
.map(|(_, v)| v.clone())
};
if field("code").is_none() && field("error").is_none() {
reply(&mut stream, "404 Not Found", "").await;
continue;
}
reply(&mut stream, "200 OK", PAGE).await;
if field("state").as_deref() != Some(state) {
return Err(Error::SignIn("redirect state does not match".into()));
}
if let Some(error) = field("error") {
return Err(Error::SignIn(match field("error_description") {
Some(text) => format!("{error}: {text}"),
None => error,
}));
}
return field("code").ok_or_else(|| Error::SignIn("redirect without a code".into()));
}
}
/// The request target of one GET, None for anything else.
async fn request(stream: &mut TcpStream) -> Option<String> {
let mut head = Vec::new();
let mut chunk = [0u8; 1024];
while !head.windows(4).any(|window| window == b"\r\n\r\n") {
let read = tokio::time::timeout(READ, stream.read(&mut chunk)).await;
match read {
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return None,
Ok(Ok(count)) => head.extend_from_slice(&chunk[..count]),
}
if head.len() > LIMIT {
log::warn!("oauth: loopback request over {LIMIT} bytes dropped");
return None;
}
}
let line = head.split(|byte| *byte == b'\n').next()?;
let line = std::str::from_utf8(line).ok()?.trim_end();
let mut parts = line.split(' ');
match (parts.next(), parts.next()) {
(Some("GET"), Some(target)) => Some(target.to_owned()),
_ => None,
}
}
async fn reply(stream: &mut TcpStream, status: &str, body: &str) {
let text = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
if let Err(err) = stream.write_all(text.as_bytes()).await {
log::warn!("oauth: loopback reply failed: {err}");
}
}
+110
View File
@@ -0,0 +1,110 @@
use std::time::{Duration, SystemTime};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::Deserialize;
use ureq::Agent;
use super::{Provider, Tokens};
use crate::{Error, Result};
const TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Deserialize)]
struct Granted {
access_token: String,
expires_in: u64,
refresh_token: Option<String>,
id_token: Option<String>,
}
#[derive(Deserialize)]
struct Refused {
error: String,
error_description: Option<String>,
}
#[derive(Deserialize)]
struct Claims {
email: Option<String>,
preferred_username: Option<String>,
}
/// Posts to the token endpoint off the async thread.
pub(super) async fn post(
provider: &Provider,
form: Vec<(&'static str, String)>,
refreshing: bool,
) -> Result<Tokens> {
let url = provider.token.clone();
let name = provider.name;
let started = SystemTime::now();
let (status, body) = tokio::task::spawn_blocking(move || send(&url, &form))
.await
.map_err(|err| Error::SignIn(format!("token request stopped: {err}")))??;
if status == 200 {
let tokens = granted(&body, started)?;
log::info!(
"oauth: {name} token granted, refresh {}",
tokens.refresh.is_some()
);
return Ok(tokens);
}
let refused: Refused = serde_json::from_str(&body)
.map_err(|_| Error::SignIn(format!("token endpoint answered {status}")))?;
let reason = match refused.error_description {
Some(text) => format!("{}: {text}", refused.error),
None => refused.error.clone(),
};
log::warn!("oauth: {name} token refused ({status}, {})", refused.error);
if refreshing
&& matches!(
refused.error.as_str(),
"invalid_grant" | "interaction_required"
)
{
return Err(Error::SignInAgain(reason));
}
Err(Error::SignIn(reason))
}
fn send(url: &str, form: &[(&'static str, String)]) -> Result<(u16, String)> {
let agent: Agent = Agent::config_builder()
.timeout_global(Some(TIMEOUT))
.http_status_as_error(false)
.build()
.into();
let mut response = agent
.post(url)
.send_form(form.iter().map(|(key, value)| (*key, value.as_str())))
.map_err(|err| Error::SignIn(format!("token endpoint unreachable: {err}")))?;
let status = response.status().as_u16();
let body = response
.body_mut()
.read_to_string()
.map_err(|err| Error::SignIn(format!("token reply unreadable: {err}")))?;
Ok((status, body))
}
fn granted(body: &str, started: SystemTime) -> Result<Tokens> {
let granted: Granted = serde_json::from_str(body)
.map_err(|err| Error::SignIn(format!("token reply unreadable: {err}")))?;
let email = match &granted.id_token {
Some(token) => email(token)?,
None => None,
};
Ok(Tokens {
access: granted.access_token,
expires_at: started + Duration::from_secs(granted.expires_in),
refresh: granted.refresh_token,
email,
})
}
/// The address claim; the token came straight from the endpoint.
fn email(token: &str) -> Result<Option<String>> {
let unreadable = || Error::SignIn("ID token unreadable".into());
let payload = token.split('.').nth(1).ok_or_else(unreadable)?;
let bytes = URL_SAFE_NO_PAD.decode(payload).map_err(|_| unreadable())?;
let claims: Claims = serde_json::from_slice(&bytes).map_err(|_| unreadable())?;
Ok(claims.email.or(claims.preferred_username))
}
+213
View File
@@ -0,0 +1,213 @@
use std::io;
use base64::{Engine, engine::general_purpose::STANDARD};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio_rustls::TlsConnector;
use crate::{
Error, Result,
net::{self, Login, Secret, Security, Server, Stream, within},
};
/// A logged-in POP3 session (RFC 1939) over TLS.
pub struct Pop {
io: BufReader<Stream>,
}
impl Pop {
pub async fn connect(server: &Server, login: &Login, tls: &TlsConnector) -> Result<Self> {
let tcp = net::tcp(server).await?;
let stream = match server.security {
Security::Tls => net::tls(tcp, server, tls).await?,
Security::StartTls => {
let mut plain = BufReader::new(tcp);
within("waiting for the greeting", status(&mut plain)).await??;
within("starting TLS", ask(&mut plain, "STLS")).await??;
// Plain bytes here would pass as TLS data.
if !plain.buffer().is_empty() {
return Err(Error::Protocol("the server sent data after STLS".into()));
}
net::tls(plain.into_inner(), server, tls).await?
}
};
let mut io = BufReader::new(stream);
if server.security == Security::Tls {
within("waiting for the greeting", status(&mut io)).await??;
}
within("logging in", authenticate(&mut io, login)).await??;
log::info!("mail: POP3 login on {}", server.host);
Ok(Self { io })
}
/// Message numbers with their unique ids.
pub async fn uids(&mut self) -> Result<Vec<(usize, String)>> {
let list = within("listing messages", async {
ask(&mut self.io, "UIDL").await?;
lines(&mut self.io).await
})
.await??;
list.split(|&byte| byte == b'\n')
.filter(|line| !line.is_empty())
.map(|line| {
let line = std::str::from_utf8(line.strip_suffix(b"\r").unwrap_or(line))
.map_err(|_| Error::Protocol("UIDL sent a line that is not text".into()))?;
let unreadable = || Error::Protocol(format!("UIDL sent {line:?}"));
let (number, id) = line.split_once(' ').ok_or_else(unreadable)?;
let number = number.parse().map_err(|_| unreadable())?;
Ok((number, id.trim_end().to_owned()))
})
.collect()
}
pub async fn fetch(&mut self, number: usize) -> Result<Vec<u8>> {
within("fetching the message", async {
ask(&mut self.io, &format!("RETR {number}")).await?;
lines(&mut self.io).await
})
.await?
}
/// Marks it deleted; QUIT carries that out.
pub async fn delete(&mut self, number: usize) -> Result<()> {
within(
"deleting the message",
ask(&mut self.io, &format!("DELE {number}")),
)
.await?
}
pub async fn quit(mut self) -> Result<()> {
within("logging out", ask(&mut self.io, "QUIT")).await??;
Ok(())
}
}
async fn authenticate<S>(io: &mut BufReader<S>, login: &Login) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
match &login.secret {
Secret::Password(password) => {
one_line(&login.user)?;
one_line(password)?;
ask(io, &format!("USER {}", login.user))
.await
.map_err(refused)?;
ask(io, &format!("PASS {password}"))
.await
.map_err(refused)?;
}
Secret::Token(token) => {
send(io, "AUTH XOAUTH2").await?;
if status(io).await.map_err(refused)?.is_none() {
return Err(Error::Protocol(
"XOAUTH2 was accepted before the token".into(),
));
}
send(io, &STANDARD.encode(login.xoauth2(token))).await?;
if status(io).await.map_err(refused)?.is_some() {
// An error challenge; the empty answer draws -ERR.
send(io, "").await?;
status(io).await.map_err(refused)?;
return Err(Error::Protocol(
"the token was challenged, then accepted".into(),
));
}
}
}
Ok(())
}
/// Text after `+OK`, or `Some` after a `+` challenge.
async fn status<S>(io: &mut BufReader<S>) -> Result<Option<String>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let line = line(io).await?;
let words = |rest: &[u8]| String::from_utf8_lossy(rest).trim().to_owned();
if line.starts_with(b"+OK") {
return Ok(None);
}
if let Some(rest) = line.strip_prefix(b"-ERR") {
return Err(Error::Server(words(rest)));
}
if let Some(rest) = line.strip_prefix(b"+") {
return Ok(Some(words(rest)));
}
Err(Error::Protocol(
"the server's line was neither +OK nor -ERR".into(),
))
}
/// One command that must end in `+OK`.
async fn ask<S>(io: &mut BufReader<S>, command: &str) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
send(io, command).await?;
match status(io).await? {
None => Ok(()),
Some(_) => Err(Error::Protocol(
"the server sent a challenge to a command".into(),
)),
}
}
async fn send<S>(io: &mut BufReader<S>, command: &str) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let stream = io.get_mut();
stream
.write_all(command.as_bytes())
.await
.map_err(Error::Connect)?;
stream.write_all(b"\r\n").await.map_err(Error::Connect)?;
stream.flush().await.map_err(Error::Connect)
}
async fn line<S>(io: &mut BufReader<S>) -> Result<Vec<u8>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let mut line = Vec::new();
io.read_until(b'\n', &mut line)
.await
.map_err(Error::Connect)?;
if line.last() != Some(&b'\n') {
return Err(Error::Connect(io::ErrorKind::UnexpectedEof.into()));
}
Ok(line)
}
/// A multi-line answer, dot-unstuffed, line ends kept.
async fn lines<S>(io: &mut BufReader<S>) -> Result<Vec<u8>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let mut body = Vec::new();
loop {
let line = line(io).await?;
if line == b".\r\n" || line == b".\n" {
return Ok(body);
}
body.extend_from_slice(line.strip_prefix(b".").unwrap_or(&line));
}
}
/// A line break would start a second command.
fn one_line(text: &str) -> Result<()> {
if text.contains(['\r', '\n']) {
return Err(Error::Invalid(
"a user name or password holds a line break".into(),
));
}
Ok(())
}
fn refused(err: Error) -> Error {
match err {
Error::Server(text) => Error::Rejected(text),
other => other,
}
}
+288
View File
@@ -0,0 +1,288 @@
use std::time::Duration;
use base64::{Engine, engine::general_purpose::STANDARD};
use mail_send::{
Credentials, Error as SendError, SmtpClient,
smtp::{AssertReply, message::Parameters},
};
use tokio::io::AsyncWriteExt;
use tokio_rustls::TlsConnector;
use crate::{
Error, Result,
net::{self, Login, Secret, Security, Server, Stream, within},
};
/// EHLO name: an address literal, so no host name leaks.
const HELLO: &str = "[127.0.0.1]";
const WAIT: Duration = Duration::from_secs(60);
/// A logged-in SMTP session over TLS.
pub struct Smtp {
client: SmtpClient<Stream>,
/// The server takes UTF-8 addresses (SMTPUTF8).
utf8: bool,
/// The server takes 8-bit bodies (8BITMIME).
eight: bool,
}
impl Smtp {
pub async fn connect(server: &Server, login: &Login, tls: &TlsConnector) -> Result<Self> {
let tcp = net::tcp(server).await?;
let mut client = match server.security {
Security::Tls => {
let mut client = smtp(net::tls(tcp, server, tls).await?);
greeting(&mut client).await?;
client
}
Security::StartTls => {
let mut plain = smtp(tcp);
greeting(&mut plain).await?;
let hello = within("greeting", plain.ehlo(HELLO))
.await?
.map_err(failed)?;
if !hello.has_capability(smtp_proto::EXT_START_TLS) {
return Err(Error::Server("the server does not offer STARTTLS".into()));
}
within("starting TLS", plain.start_tls(tls, &server.host))
.await?
.map_err(failed)?
}
};
let hello = within("greeting", client.capabilities(HELLO, false))
.await?
.map_err(failed)?;
match &login.secret {
Secret::Password(password) => {
let credentials = Credentials::Plain {
username: login.user.clone(),
secret: password.clone(),
};
within("logging in", client.authenticate(&credentials, &hello))
.await?
.map_err(refused)?;
}
Secret::Token(token) => {
if hello.auth_mechanisms & smtp_proto::AUTH_XOAUTH2 == 0 {
return Err(Error::Rejected("the server does not offer XOAUTH2".into()));
}
within("logging in", xoauth2(&mut client, &login.xoauth2(token))).await??;
}
}
log::info!("mail: SMTP login on {}", server.host);
let utf8 = hello.has_capability(smtp_proto::EXT_SMTP_UTF8);
let eight = hello.has_capability(smtp_proto::EXT_8BIT_MIME);
Ok(Self {
client,
utf8,
eight,
})
}
/// Hands one message to the server for these recipients.
pub async fn send(&mut self, from: &str, to: &[String], message: &[u8]) -> Result<()> {
if to.is_empty() {
return Err(Error::Invalid("a message needs a recipient".into()));
}
for email in to.iter().map(String::as_str).chain([from]) {
mailbox(email)?;
}
let data = data(message)?;
let headers = crate::mime::utf8_headers(message)?;
let mut params = Parameters::new();
let body = message
.windows(4)
.position(|end| end == b"\r\n\r\n")
.map_or(&[][..], |at| &message[at..]);
if !body.is_ascii() {
if !self.eight {
return Err(Error::Invalid(
"the server does not take 8-bit message bodies".into(),
));
}
params.add(("BODY", "8BITMIME"));
}
if !from.is_ascii() || to.iter().any(|to| !to.is_ascii()) || headers {
if !self.utf8 {
return Err(Error::Invalid(
"the server does not take non-ASCII addresses or headers".into(),
));
}
params.add("SMTPUTF8");
}
let sent = within("sending", async {
self.client.mail_from(from, &params).await?;
for to in to {
self.client.rcpt_to(to, &Parameters::new()).await?;
}
self.client.cmd(b"DATA\r\n").await?.assert_code(354)?;
self.client.stream.write_all(&data).await?;
self.client.stream.flush().await?;
self.client.read().await?.assert_positive_completion()
})
.await?;
if let Err(SendError::UnexpectedReply(reply)) = sent {
// A refusal leaves the transaction open.
let words = format!("{} {}", reply.code(), reply.message());
return Err(match within("resetting", self.client.rset()).await? {
Ok(()) => Error::Server(words),
Err(err) => Error::Server(format!("{words}; RSET failed: {}", failed(err))),
});
}
sent.map_err(failed)?;
log::info!("mail: SMTP accepted a message for {} recipients", to.len());
Ok(())
}
pub async fn quit(self) -> Result<()> {
within("logging out", self.client.quit())
.await?
.map_err(failed)
}
}
fn smtp<T: tokio::io::AsyncRead + tokio::io::AsyncWrite>(stream: T) -> SmtpClient<T> {
SmtpClient {
stream,
timeout: WAIT,
}
}
async fn greeting<T>(client: &mut SmtpClient<T>) -> Result<()>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let reply = within("waiting for the greeting", client.read())
.await?
.map_err(failed)?;
reply.assert_positive_completion().map_err(failed)
}
/// Gmail's exchange: an error challenge draws an empty line.
async fn xoauth2<T>(client: &mut SmtpClient<T>, initial: &str) -> Result<()>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let command = format!("AUTH XOAUTH2 {}\r\n", STANDARD.encode(initial));
let mut reply = client.cmd(command).await.map_err(failed)?;
if reply.code() == 334 {
reply = client.cmd("\r\n").await.map_err(failed)?;
}
match reply.code() {
235 => Ok(()),
code => Err(Error::Rejected(format!("{code} {}", reply.message()))),
}
}
/// RFC 5321 §4.5.2: a leading dot doubles.
fn data(message: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(message.len() + 8);
let mut start = true;
for (at, &byte) in message.iter().enumerate() {
let bare = match byte {
b'\r' => message.get(at + 1) != Some(&b'\n'),
b'\n' => at == 0 || message[at - 1] != b'\r',
_ => false,
};
if bare {
return Err(Error::Invalid("the message has a bare CR or LF".into()));
}
if start && byte == b'.' {
out.push(b'.');
}
out.push(byte);
start = byte == b'\n';
}
if !out.is_empty() && !out.ends_with(b"\r\n") {
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(b".\r\n");
Ok(out)
}
/// RFC 5321 §4.1.2 Mailbox, with RFC 6531 UTF-8.
pub(crate) fn mailbox(email: &str) -> Result<()> {
let readable = email.rsplit_once('@').is_some_and(|(local, domain)| {
let local = match local
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
{
Some(inner) => quoted(inner),
None => dotted(local),
};
local && domain_ok(domain)
});
if !readable {
return Err(Error::Invalid(format!("not a mail address: {email:?}")));
}
Ok(())
}
fn quoted(inner: &str) -> bool {
let mut chars = inner.chars();
while let Some(char) = chars.next() {
let fine = match char {
'\\' => chars.next().is_some_and(|next| matches!(next, ' '..='~')),
'"' => false,
' '..='~' => true,
_ => !char.is_ascii(),
};
if !fine {
return false;
}
}
true
}
fn dotted(local: &str) -> bool {
let atext = |char: char| {
char.is_ascii_alphanumeric() || "!#$%&'*+-/=?^_`{|}~".contains(char) || !char.is_ascii()
};
local
.split('.')
.all(|atom| !atom.is_empty() && atom.chars().all(atext))
}
fn domain_ok(domain: &str) -> bool {
if let Some(literal) = domain
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
{
return !literal.is_empty()
&& literal
.bytes()
.all(|byte| matches!(byte, 33..=90 | 94..=126));
}
domain.split('.').all(|label| {
!label.is_empty()
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label
.chars()
.all(|char| char.is_ascii_alphanumeric() || char == '-' || !char.is_ascii())
})
}
fn refused(err: SendError) -> Error {
match err {
SendError::AuthenticationFailed(reply) => {
Error::Rejected(format!("{} {}", reply.code(), reply.message()))
}
SendError::UnsupportedAuthMechanism => {
Error::Rejected("the server offers no login method for these credentials".into())
}
other => failed(other),
}
}
fn failed(err: SendError) -> Error {
match err {
SendError::Io(err) => Error::Connect(err),
SendError::Tls(err) => Error::Tls(err.to_string()),
SendError::Timeout => Error::Timeout("talking to the server"),
SendError::UnexpectedReply(reply) => {
Error::Server(format!("{} {}", reply.code(), reply.message()))
}
other => Error::Protocol(other.to_string()),
}
}
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff. Show more