Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b2c02e771 |
No files matched your search
@@ -1,21 +1,17 @@
|
||||
# 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:
|
||||
# A GUI-session agent: daemons see no login keychain.
|
||||
runs-on: [self-hosted, macos-signing]
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 720
|
||||
steps:
|
||||
- name: Build, package and upload for macOS
|
||||
shell: bash
|
||||
env:
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_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
|
||||
@@ -24,45 +20,42 @@ jobs:
|
||||
ELY_NOTARY_PROFILE: ${{ vars.ELY_NOTARY_PROFILE }}
|
||||
run: |
|
||||
dir=$HOME/ely-ci/ElyOffice
|
||||
[ -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
|
||||
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||
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: [self-hosted, ely-windows]
|
||||
runs-on: windows
|
||||
timeout-minutes: 360
|
||||
steps:
|
||||
- name: Build, package and upload for Windows
|
||||
shell: bash
|
||||
env:
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_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://github.com/ELY-Open-Source/ElyOffice.git "$dir"
|
||||
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
|
||||
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||
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: [self-hosted, ely-windows]
|
||||
runs-on: windows
|
||||
needs: windows
|
||||
timeout-minutes: 360
|
||||
steps:
|
||||
- name: Build, package and upload for Linux
|
||||
shell: bash
|
||||
env:
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_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://github.com/ELY-Open-Source/ElyOffice.git "$dir"
|
||||
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
|
||||
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||
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 }}"
|
||||
@@ -1,63 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ely Office
|
||||
url: https://office.elygpui.com
|
||||
about: "Downloads and what each app does."
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -1,35 +0,0 @@
|
||||
<!--
|
||||
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
-2
@@ -18,5 +18,4 @@ node_modules/
|
||||
/assets/papirus_icons-master.zip
|
||||
/Gitea.md
|
||||
/.work/
|
||||
/TyporaRef
|
||||
/oauth.env
|
||||
/TyporaRef
|
||||
+8
-5
@@ -1,12 +1,15 @@
|
||||
# Contributing
|
||||
|
||||
## Opening a pull request on GitHub
|
||||
## Opening a pull request on git.zacharyzhang.com
|
||||
|
||||
1. Fork https://github.com/ELY-Open-Source/ElyOffice and clone your fork:
|
||||
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:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your-account>/ElyOffice.git
|
||||
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>"
|
||||
```
|
||||
|
||||
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.
|
||||
3. To update the pull request, push again with the same `topic`.
|
||||
Generated
+19
-487
@@ -410,45 +410,6 @@ 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"
|
||||
@@ -794,9 +755,6 @@ 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"
|
||||
@@ -1690,12 +1648,6 @@ 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"
|
||||
@@ -1754,20 +1706,6 @@ 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"
|
||||
@@ -2000,7 +1938,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "ely-agent"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"ely-platform",
|
||||
"ely-ui",
|
||||
@@ -2009,7 +1947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-doc"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"csv",
|
||||
@@ -2031,7 +1969,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-files"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"dunce",
|
||||
@@ -2048,7 +1986,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ely-gpui-component"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/ELY-Open-Source/Ely-GPUI-Components?rev=cd365e92ea0c4467c439b8253963461ba0f6d155#cd365e92ea0c4467c439b8253963461ba0f6d155"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=2e65b05a6592d8c3166a252261fc01bf6215c3c5#2e65b05a6592d8c3166a252261fc01bf6215c3c5"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -2094,14 +2032,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-ipc"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-lok"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"dunce",
|
||||
@@ -2122,54 +2060,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-mail"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
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.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.23.1",
|
||||
@@ -2194,7 +2094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-office"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"directories",
|
||||
@@ -2215,7 +2115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-pdf"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ely-files",
|
||||
@@ -2233,7 +2133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-pdf-engine"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"ely-ipc",
|
||||
@@ -2250,11 +2150,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-platform"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
"core-foundation 0.10.0",
|
||||
"core-video",
|
||||
"directories",
|
||||
@@ -2290,7 +2189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-show"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"ely-files",
|
||||
"ely-gpui-component",
|
||||
@@ -2310,7 +2209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-table"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"ely-gpui-component",
|
||||
"ely-lok",
|
||||
@@ -2327,7 +2226,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-tree"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ely-files",
|
||||
@@ -2347,7 +2246,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-ui"
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"directories",
|
||||
@@ -2571,18 +2470,6 @@ 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"
|
||||
@@ -3627,27 +3514,6 @@ 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"
|
||||
@@ -3927,15 +3793,6 @@ 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"
|
||||
@@ -4187,36 +4044,6 @@ 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"
|
||||
@@ -4424,17 +4251,6 @@ 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"
|
||||
@@ -4620,38 +4436,6 @@ 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"
|
||||
@@ -5536,15 +5320,6 @@ 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"
|
||||
@@ -5602,12 +5377,6 @@ 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"
|
||||
@@ -5712,16 +5481,6 @@ 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"
|
||||
@@ -6500,20 +6259,6 @@ 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"
|
||||
@@ -6722,31 +6467,6 @@ 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"
|
||||
@@ -6809,15 +6529,6 @@ 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"
|
||||
@@ -6857,18 +6568,6 @@ 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"
|
||||
@@ -6878,33 +6577,6 @@ 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"
|
||||
@@ -6967,15 +6639,6 @@ 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"
|
||||
@@ -7313,16 +6976,6 @@ 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"
|
||||
@@ -7431,25 +7084,6 @@ 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"
|
||||
@@ -7477,18 +7111,6 @@ 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"
|
||||
@@ -7755,17 +7377,6 @@ 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"
|
||||
@@ -8032,42 +7643,6 @@ 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"
|
||||
@@ -8517,12 +8092,6 @@ 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"
|
||||
@@ -8800,15 +8369,6 @@ 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"
|
||||
@@ -9728,7 +9288,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"dunce",
|
||||
"http",
|
||||
"jni 0.21.1",
|
||||
"jni",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2 0.6.4",
|
||||
@@ -9780,24 +9340,6 @@ 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"
|
||||
@@ -9870,16 +9412,6 @@ 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"
|
||||
@@ -9917,7 +9449,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure 0.14.0",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10131,7 +9663,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure 0.14.0",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+2
-15
@@ -5,7 +5,7 @@ members = ["apps/*", "crates/*"]
|
||||
exclude = ["engines", "reference"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.9"
|
||||
version = "0.1.7"
|
||||
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://github.com/ELY-Open-Source/Ely-GPUI-Components", rev = "cd365e92ea0c4467c439b8253963461ba0f6d155" }
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "2e65b05a6592d8c3166a252261fc01bf6215c3c5" }
|
||||
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,8 +39,6 @@ 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"
|
||||
@@ -58,21 +56,10 @@ 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" }
|
||||
|
||||
@@ -22,12 +22,12 @@ Ely Office 提供八个桌面应用。用户可以写文档、计算表格、制
|
||||
| ElyTable | 电子表格编辑与计算 | LOK | XLSX、ODS、CSV |
|
||||
| ElyShow | 演示文稿编辑与放映 | LOK | PPTX、ODP |
|
||||
| ElyPDF | PDF 阅读、编辑、批注、表单和页面处理 | MuPDF;OCR 复用 Tesseract | PDF |
|
||||
| ElyMail | 邮件收发、本地存储和搜索 | Rust 的 mail-send、mail-parser、mail-builder(IMAP 与 POP3 由引擎自写),加本地同步与存储 | 邮件、附件、草稿 |
|
||||
| ElyMail | 邮件收发、本地存储和搜索 | MailCore 2,加本地同步与存储 | 邮件、附件、草稿 |
|
||||
| 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 是已确定的方向。邮件协议 crate、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
|
||||
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
|
||||
|
||||
ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能。它不是其他七个应用的聊天侧栏,也不是整个套件的控制中枢。[S19]
|
||||
|
||||
@@ -375,18 +375,16 @@ XFA 动态表单、任意嵌入脚本执行、专业印前分色及 PDF 到 Offi
|
||||
|
||||
### 12.1 定位与后端
|
||||
|
||||
提供多账号邮件客户端,不建设邮件托管服务。协议与 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 负责账号流程、本地存储、搜索、同步队列和界面。
|
||||
提供多账号邮件客户端,不建设邮件托管服务。MailCore 2 负责 IMAP、POP、SMTP 和邮件解析等能力。Ely 负责账号流程、本地存储、搜索、同步队列和界面。MailCore 2 官方列出 C++ API 与三端支持,但发行前仍须验证实际构建和依赖。[S10]
|
||||
|
||||
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。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 的构建里该登录写明不可用。
|
||||
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文和富文本撰写区不用 CEF 与 Electron,渲染方式随 ElyMail 任务另定;不能把正文 HTML 解析当成完整页面渲染。
|
||||
|
||||
### 12.2 功能要求
|
||||
|
||||
| 编号 | 功能 | 完成要求 |
|
||||
| --- | --- | --- |
|
||||
| MAIL-01 | 账号 | 支持多个 IMAP/SMTP 账号、POP3 账号、服务器配置、TLS 与连通性检查 |
|
||||
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程,iCloud 的应用专用密码向导;支持服务商允许的应用密码或自定义认证配置 |
|
||||
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程;支持服务商允许的应用密码或自定义认证配置 |
|
||||
| MAIL-03 | 邮箱视图 | 支持统一收件箱、账号独立视图、文件夹、未读、标记和附件筛选 |
|
||||
| MAIL-04 | 阅读 | 支持纯文本、HTML、内嵌图片、附件、原始邮件头与会话线程 |
|
||||
| MAIL-05 | 写信 | 支持收件人、抄送、密送、回复、回复全部、转发、签名、附件和纯文本/富文本撰写 |
|
||||
@@ -632,7 +630,7 @@ C/C++ 异常、原生错误与资源释放在 FFI 边界处理,不能跨 Rust
|
||||
|
||||
### 17.3 内容渲染
|
||||
|
||||
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文按块的子集由 GPUI 绘制,写信用 Markdown 文档组件(§12.1)。
|
||||
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文和富文本撰写的渲染方式随 ElyMail 任务另定,定前不写成已有。
|
||||
|
||||
Ely 主题 token 用于内容区。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示。
|
||||
|
||||
@@ -768,6 +766,7 @@ 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、字体、词典和媒体组件 | 记录每个资源的来源、版本和再分发条件,不从系统目录随意拷贝受限资源 |
|
||||
@@ -842,7 +841,7 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
| LOK 三端嵌入 | Doc/Table/Show 的编辑、保存、中文输入与窗口操作录像及日志 | 修复适配或更新设计决策;不能只交付查看器 |
|
||||
| LOK 高级功能 | 母版、放映、图表、修订、输入法、打印和无障碍的接口映射及用例 | 补齐桥接或维护必要上游补丁,不跳回上游原生 UI |
|
||||
| MuPDF 编辑 | 字体、对象修改、表单、删除及保存的样本结果 | 修复对象编辑与输出;不得改为视觉覆盖冒充编辑 |
|
||||
| 邮件协议 crate | 三端 TLS、OAuth、IMAP 与 POP3 同步、SMTP 发送和 MIME 测试 | 修复或换 crate;换后端须显式修订选型 |
|
||||
| MailCore 2 | 三端可重复构建、TLS、OAuth、同步和 MIME 测试 | 处理依赖及适配问题;替换后端必须显式修订选型 |
|
||||
| Markdown 原生编辑 | 可视、源码、预览的输入、焦点、缩放、输入法和辅助技术测试 | 未通过不能以 CEF、WebView 或 Electron 代替 |
|
||||
| Markdown 原文保留 | 无编辑保存、模式切换、局部修改和外部冲突的字节级差异 | 修复映射与保存;未知语法回到原文模式,不删除 |
|
||||
| 邮箱服务注册 | 正式 OAuth 配置、适用审核状态与用户流程 | 不将未获许可的内置登录描述为可用 |
|
||||
@@ -896,8 +895,8 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
[S9] GNU Affero General Public License 第 3 版原文。
|
||||
`https://www.gnu.org/licenses/agpl-3.0.en.html`
|
||||
|
||||
[S10] Apple,iCloud 邮件用于其他邮件应用的服务器设置。
|
||||
`https://support.apple.com/en-us/102525`
|
||||
[S10] MailCore 2 官方介绍,协议、API 与平台范围。
|
||||
`https://etpan.org/mailcore2.html`
|
||||
|
||||
[S11] Google,Gmail IMAP/POP/SMTP OAuth 2.0 与权限要求。
|
||||
`https://developers.google.com/workspace/gmail/imap/xoauth2-protocol`
|
||||
@@ -936,5 +935,8 @@ 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/`
|
||||
@@ -14,7 +14,7 @@
|
||||
| Doc / Table / Show | LibreOfficeKit,独立工作进程 |
|
||||
| Show 媒体 | 各平台原生:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(LOK 不放媒体) |
|
||||
| PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 |
|
||||
| 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 走应用专用密码 |
|
||||
| Mail | MailCore 2 + SQLite;正文渲染随其任务另定(不用 CEF 与 Electron) |
|
||||
| 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;mail/sample.eml:带编码主题、分组抄送、纯文本加 HTML、cid 图片与附件的邮件,LF 行尾,测试换成 CRLF)
|
||||
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)
|
||||
tasks/ TASKS.md 进度真相
|
||||
site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木;中英两种文案在 src/text.ts);site/video 是发布视频的分镜、配乐、录屏与合成脚本,产物在 site/video/out(不入库)
|
||||
```
|
||||
|
||||
## 组件映射
|
||||
|
||||
依赖 `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` 核对下表每个名字都是组件库公开项。
|
||||
依赖 `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` 核对下表每个名字都是组件库公开项。
|
||||
|
||||
| 需要 | 用 |
|
||||
| --- | --- |
|
||||
@@ -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 文本里不能嵌图片:行内公式无从排版 | 组件库 `render/math_text.rs`:以不断行的空格占出公式的宽(整个公式作一个词折行),排好的公式画在其上,按下处读回公式的原文 |
|
||||
| gpui 文本里不能嵌图片:行内公式无从排版 | 组件库的 `MarkdownRenderer` 把行内公式显示为 TeX 原文(着色),块公式才排版 |
|
||||
| `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.1-sol,effort xhigh,fast)
|
||||
scripts/review.sh ID 轮次 路径 审查一条任务(herdr 右侧分栏,codex gpt-6-astra,effort max,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);LOK 探测本构建的内核,不挂载官方镜像
|
||||
tests/engines/*.sh 引擎探测(LOK、MuPDF、libetpan);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`:GitHub API 的最新版本与全部发布的下载总数,加上 Gitea 上迁移前的下载(Gitea 不通时只计 GitHub;都不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev
|
||||
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 test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
|
||||
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
|
||||
cargo check --workspace
|
||||
@@ -206,13 +206,14 @@ 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(用户定),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-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-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`。
|
||||
@@ -276,7 +277,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 默认的浅黄),切换主题不再改文档,三端同此(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 启动带黑色命令行窗口、“打开方式”里无图标、点 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 上标题栏的最小化、最大化、关闭三个按钮都按不动):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 宽时文件名止于按钮之前(原布局时失败)。
|
||||
@@ -301,7 +302,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`。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-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-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`)只切到该标签,也加入列表。
|
||||
@@ -317,12 +318,3 @@ 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。
|
||||
@@ -11,20 +11,8 @@ 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
|
||||
@@ -1,285 +0,0 @@
|
||||
//! 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)])
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
//! 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
//! 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())],
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,8 @@
|
||||
// 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, mail::open);
|
||||
ely_ui::run(AppId::Mail, ely_ui::blank);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//! 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)]),
|
||||
]
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
//! 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();
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
//! 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) => {}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
//! 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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ mod tests;
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_gpui_component::documents::markdown_tree;
|
||||
use ely_gpui_component::documents::markdown_options;
|
||||
|
||||
pub use files::{Format, Target, write};
|
||||
pub use pictures::{Pictures, picture_file};
|
||||
@@ -43,7 +43,8 @@ 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_tree(text);
|
||||
let tree = markdown::to_mdast(text, &markdown_options())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let mut page = Page::default();
|
||||
html::write(&tree, text, placed, &mut page)?;
|
||||
log::info!(
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
use std::io;
|
||||
|
||||
use ely_gpui_component::documents::{
|
||||
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_tree,
|
||||
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_options,
|
||||
};
|
||||
use markdown::mdast::Node;
|
||||
|
||||
/// `text` as plain text, a blank line between blocks.
|
||||
pub fn plain(text: &str) -> io::Result<String> {
|
||||
let tree = markdown_tree(text);
|
||||
let tree = markdown::to_mdast(text, &markdown_options())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let reader = MarkdownReader::joined(text, &tree);
|
||||
let mut blocks = Vec::new();
|
||||
// Alerts and `[toc]` live only at the root.
|
||||
@@ -74,15 +75,7 @@ fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec<String>) {
|
||||
block(reader, child, blocks);
|
||||
}
|
||||
}
|
||||
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::Paragraph(_) | Node::Heading(_) => blocks.push(words(reader, node)),
|
||||
Node::Html(_) => {
|
||||
let mut read = Vec::new();
|
||||
reader.block(node, &mut read);
|
||||
|
||||
@@ -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_tree, pictures::Placed};
|
||||
use crate::output::{Page, html::write, markdown_options, 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_tree(&source);
|
||||
let tree = markdown::to_mdast(&source, &markdown_options()).expect("parsed");
|
||||
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,17 +237,3 @@ 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");
|
||||
}
|
||||
@@ -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) -> Vec<Showing> {
|
||||
pub fn showing(&self, sf: f32, dark: bool) -> 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, tx, ty };
|
||||
let key = TileKey { page: place.page, scale, rotate: self.rotate, dark, 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, tx: 1, ty: 0 };
|
||||
let key = TileKey { page: 0, scale: 2000, rotate: 0, dark: false, 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 };
|
||||
|
||||
@@ -16,13 +16,14 @@ use crate::view::{PdfView, Reported};
|
||||
/// A tile's side in device pixels.
|
||||
pub const TILE: i32 = 512;
|
||||
|
||||
/// A page tile at one scale and turn.
|
||||
/// A page tile at one scale, turn and theme.
|
||||
#[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,
|
||||
}
|
||||
@@ -122,14 +123,17 @@ 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 = window.scale_factor();
|
||||
let (sunken, paper) = (cx.theme().colors.sunken, gpui::white());
|
||||
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 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);
|
||||
let showing = self.showing(sf, dark);
|
||||
// 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;
|
||||
@@ -262,7 +266,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),
|
||||
move |worker| worker.render(doc, key.page, (scale, key.rotate), rect, key.dark),
|
||||
move |view, painted, cx| {
|
||||
view.painting = false;
|
||||
if view.version != version {
|
||||
|
||||
+13
-13
@@ -100,11 +100,11 @@ impl PdfView {
|
||||
}
|
||||
|
||||
/// Asks for the first missing picture in `rows`.
|
||||
fn ask_thumb(&mut self, rows: Range<usize>, sf: f32, cx: &mut Context<Self>) {
|
||||
fn ask_thumb(&mut self, rows: Range<usize>, (sf, dark): (f32, bool), cx: &mut Context<Self>) {
|
||||
if self.thumbing {
|
||||
return;
|
||||
}
|
||||
let due = |page: &usize| !self.thumbs.contains_key(page) || self.thumbs_stale.contains(page);
|
||||
let due = |page: &usize| !self.thumbs.contains_key(&(*page, dark)) || self.thumbs_stale.contains(&(*page, dark));
|
||||
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),
|
||||
move |worker| worker.render(doc, page, (scale, 0), tile, dark),
|
||||
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);
|
||||
if let Some(old) = view.thumbs.insert(page, image) {
|
||||
view.thumbs_stale.remove(&(page, dark));
|
||||
if let Some(old) = view.thumbs.insert((page, dark), 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) -> Option<Arc<RenderImage>> {
|
||||
self.thumbs.get(&page).cloned()
|
||||
pub fn picture(&self, page: usize, dark: bool) -> Option<Arc<RenderImage>> {
|
||||
self.thumbs.get(&(page, dark)).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// One row: the page number and its picture.
|
||||
fn row(view: &Entity<PdfView>, page: usize, cx: &App) -> gpui::AnyElement {
|
||||
fn row(view: &Entity<PdfView>, page: usize, dark: bool, 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);
|
||||
let picture = read.picture(page, dark);
|
||||
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, cx: &App) -> gpui::AnyElement {
|
||||
.h(px(height))
|
||||
.border_1()
|
||||
.border_color(if picked { colors.focus } else { colors.border })
|
||||
.bg(gpui::white())
|
||||
.bg(if dark { colors.surface } else { colors.paper })
|
||||
.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 sf = window.scale_factor();
|
||||
let shown = (window.scale_factor(), cx.theme().is_dark());
|
||||
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(), sf, cx));
|
||||
range.map(|page| row(&rows, page, cx)).collect()
|
||||
rows.update(cx, |view, cx| view.ask_thumb(range.clone(), shown, cx));
|
||||
range.map(|page| row(&rows, page, shown.1, cx)).collect()
|
||||
})
|
||||
.size_full();
|
||||
div()
|
||||
|
||||
@@ -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 = cx.update(|window, _| window.scale_factor());
|
||||
let (sf, dark) = cx.update(|window, app| (window.scale_factor(), ely_gpui_component::theme::ActiveTheme::theme(app).is_dark()));
|
||||
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).iter().any(|page| read.pieces(page).is_empty()) {
|
||||
if read.showing(sf, dark).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 = cx.update(|window, _| window.scale_factor());
|
||||
let (sf, dark) = cx.update(|window, app| (window.scale_factor(), ely_gpui_component::theme::ActiveTheme::theme(app).is_dark()));
|
||||
let now = cx.update(|_, app| {
|
||||
let read = view.read(app);
|
||||
read.showing(sf).iter().map(|page| read.pieces(page).len()).collect::<Vec<_>>()
|
||||
read.showing(sf, dark).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()).iter().map(|s| {
|
||||
read.showing(window.scale_factor(), false).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,8 +333,9 @@ 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);
|
||||
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"));
|
||||
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"));
|
||||
// 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)));
|
||||
@@ -348,7 +349,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).expect("a picture"));
|
||||
let new = cx.update(|_, app| view.read(app).picture(0, dark).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");
|
||||
|
||||
@@ -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]).expect("draws").bgra;
|
||||
let bgra = worker.render(doc, 0, (s, 0), [0, 0, w, h], false).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");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Password prompts, zoomed pairs, jumps across (C11.1).
|
||||
|
||||
use ely_office::{Dialog, Dialogs as _, Opening as _, Tabs as _};
|
||||
use ely_office::{Dialog, Dialogs as _, Tabs as _};
|
||||
use gpui::{Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{file, frames, settle, shown, windowed};
|
||||
@@ -144,21 +144,3 @@ 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");
|
||||
}
|
||||
@@ -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.
|
||||
pub thumbs: HashMap<usize, Arc<RenderImage>>,
|
||||
/// Page pictures for the side panel, by page and darkness.
|
||||
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
|
||||
pub thumbs_failed: HashSet<usize>,
|
||||
/// Pictures from before an edit; shown until repainted.
|
||||
pub thumbs_stale: HashSet<usize>,
|
||||
pub thumbs_stale: HashSet<(usize, bool)>,
|
||||
/// A picture is on its way from the worker.
|
||||
pub thumbing: bool,
|
||||
/// Pages picked in the side panel, and the anchor.
|
||||
|
||||
@@ -100,12 +100,12 @@ impl Host for Deck {
|
||||
view.turn_parts();
|
||||
});
|
||||
}
|
||||
// Pictures before the scheme landed are repainted.
|
||||
let lit = view.read(cx).lit();
|
||||
// Pictures follow the kernel's light or dark scheme.
|
||||
let dark = view.read(cx).dark();
|
||||
if let Some(tab) = self.tab_mut(id)
|
||||
&& tab.extra.lit != lit
|
||||
&& tab.extra.dark != dark
|
||||
{
|
||||
tab.extra.lit = lit;
|
||||
tab.extra.dark = dark;
|
||||
self.repaint_all(id, cx);
|
||||
}
|
||||
self.offer_menu(id, view, cx);
|
||||
|
||||
@@ -50,8 +50,8 @@ pub struct Extra {
|
||||
pub painting: bool,
|
||||
/// The parts are masters.
|
||||
pub masters: bool,
|
||||
/// The pictures were painted with Ely's scheme.
|
||||
pub lit: bool,
|
||||
/// The scheme the pictures were painted with.
|
||||
pub dark: Option<bool>,
|
||||
/// The view was fitted to the slide once.
|
||||
pub fitted: bool,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
let (copy, light) = (tab.work.join("output.odp"), ely_lok::scheme(false));
|
||||
let (shown, opened) = (folder.display().to_string(), folder.clone());
|
||||
log::info!("show: writes slide pictures");
|
||||
self.read_in(
|
||||
|
||||
@@ -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(),
|
||||
Playing::Effects { .. } => ely_lok::paper(cx),
|
||||
};
|
||||
let content = match &preview.playing {
|
||||
Playing::Effects {
|
||||
@@ -266,7 +266,7 @@ impl Deck {
|
||||
} else {
|
||||
(t / preview.length) as f32
|
||||
};
|
||||
let paper = ely_lok::paper();
|
||||
let paper = ely_lok::paper(cx);
|
||||
// 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());
|
||||
|
||||
@@ -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),
|
||||
Some(part) => stage::still(show, part, small, cx),
|
||||
None => div().w(small.0).h(small.1).into_any_element(),
|
||||
};
|
||||
let notes = show.part().map(|part| show.slides[part].notes.clone());
|
||||
|
||||
@@ -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();
|
||||
let paper = ely_lok::paper(cx);
|
||||
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)) -> AnyElement {
|
||||
pub fn still(show: &Show, part: usize, size: (Pixels, Pixels), cx: &App) -> AnyElement {
|
||||
match show.stack(part) {
|
||||
Some(stack) => compose::slide(stack, |visible| visible, size, ely_lok::paper())
|
||||
Some(stack) => compose::slide(stack, |visible| visible, size, ely_lok::paper(cx))
|
||||
.into_any_element(),
|
||||
None => black(size),
|
||||
}
|
||||
|
||||
@@ -59,14 +59,16 @@ pub fn discard(worker: Arc<Mutex<Worker>>, doc: Doc, copy: PathBuf, cx: &App) {
|
||||
fn copy(
|
||||
worker: &mut Worker,
|
||||
doc: &Doc,
|
||||
copy: &std::path::Path,
|
||||
(copy, scheme): (&std::path::Path, Option<&'static str>),
|
||||
) -> 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>> {
|
||||
worker.run(&shown, "view.theme", &[ely_lok::SCHEME])?;
|
||||
if let Some(scheme) = scheme {
|
||||
worker.run(&shown, "view.theme", &[scheme])?;
|
||||
}
|
||||
ely_lok::show::slide_show(&worker.values(&shown, ely_lok::show::SLIDE_SHOW)?)
|
||||
};
|
||||
match read(worker) {
|
||||
@@ -159,13 +161,14 @@ 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),
|
||||
move |worker, doc| copy(worker, doc, (&into, scheme)),
|
||||
move |deck, read, _, cx| {
|
||||
deck.show_starting = false;
|
||||
let (current, doc, slides) = match read {
|
||||
@@ -379,7 +382,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).expect("a copy");
|
||||
let (_, doc, slides) = super::copy(&mut worker, &tab.doc, (&path, None)).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| {
|
||||
|
||||
@@ -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", &format!("{error:#}"))]);
|
||||
let text = Book::text(cx, "table.data.clipboard", &[("error", &error.to_string())]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,11 +65,6 @@ 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(
|
||||
|
||||
@@ -9,8 +9,8 @@ use ely_gpui_component::{
|
||||
use ely_lok::{LokView, sheet};
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
|
||||
Point, Styled as _, div, px,
|
||||
AnyElement, Context, Entity, IntoElement, ParentElement as _, Pixels, Point, Styled as _, div,
|
||||
px,
|
||||
};
|
||||
|
||||
use crate::book::Book;
|
||||
@@ -112,7 +112,6 @@ impl Book {
|
||||
.absolute()
|
||||
.left(corner.x)
|
||||
.top(corner.y)
|
||||
.block_mouse_except_scroll()
|
||||
.child(
|
||||
IconButton::new("table-choices-open", IconName::ChevronDown)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
@@ -122,8 +121,7 @@ impl Book {
|
||||
book.offer_choices(id, (column, row), cx)
|
||||
})),
|
||||
)
|
||||
.h(px(size))
|
||||
.min_h(cx.theme().control_height(ControlSize::Sm))
|
||||
.h(px(size.max(16.0)))
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
@@ -139,7 +137,6 @@ impl Book {
|
||||
.absolute()
|
||||
.left(below.x)
|
||||
.top(below.y)
|
||||
.block_mouse_except_scroll()
|
||||
.max_w(theme.tooltip_max_width())
|
||||
.px_2()
|
||||
.py_1()
|
||||
|
||||
@@ -109,12 +109,18 @@ impl Book {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let Some(tabs) = strip(tab.id, &tab.extra.sheets) else {
|
||||
let Some(choices) = visible(&tab.extra.sheets) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let this = cx.entity();
|
||||
let picked = this.clone();
|
||||
let tabs = tabs.on_change(move |value, _, cx| {
|
||||
let tabs = SheetTabs::new(
|
||||
"table-sheets",
|
||||
choices,
|
||||
tab.extra.sheets.current.to_string(),
|
||||
)
|
||||
.placement(TabPlacement::Bottom)
|
||||
.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));
|
||||
})
|
||||
@@ -304,15 +310,6 @@ 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)?;
|
||||
@@ -330,10 +327,6 @@ fn visible(sheets: &ely_lok::Sheets) -> Option<Vec<Choice>> {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/strip.rs"]
|
||||
mod strip_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -394,7 +394,6 @@ fn a_delete_stays_with_its_workbook() {
|
||||
}
|
||||
|
||||
mod analysis;
|
||||
mod aids;
|
||||
mod data;
|
||||
mod menu;
|
||||
mod names;
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -43,9 +43,11 @@ pub(super) fn cells(cx: &mut TestAppContext, book: &Entity<Book>, range: &str) -
|
||||
worker
|
||||
.run(&tab.doc, "cell.goto", &[range])
|
||||
.expect("the range");
|
||||
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")
|
||||
tab.doc
|
||||
.selection_text(&mut worker)
|
||||
.expect("the text")
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -113,17 +113,15 @@ 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"),
|
||||
(Format::Tsv, "out.tsv", "3\ta;b"),
|
||||
(Format::Csv, "out.csv", "3,a;b\n"),
|
||||
(Format::Tsv, "out.tsv", "3\ta;b\n"),
|
||||
] {
|
||||
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"), format!("{text}{end}"));
|
||||
assert_eq!(std::fs::read_to_string(&path).expect("reads"), text);
|
||||
}
|
||||
let pdf = dir.path().join("out.pdf");
|
||||
update(&mut cx, &book, |book, cx| book.export_to(id, pdf.clone(), Format::Pdf, cx));
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -76,26 +76,12 @@ impl Doc {
|
||||
Unpack::new(&payload).str()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// Shows sheet `part`, from 0.
|
||||
|
||||
@@ -29,7 +29,6 @@ mod sheet;
|
||||
pub(crate) mod show;
|
||||
mod slides;
|
||||
mod tools;
|
||||
mod word;
|
||||
mod workbook;
|
||||
|
||||
/// The pinned LibreOffice commit the build must report.
|
||||
|
||||
@@ -152,30 +152,3 @@ 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");
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
let taken = doc.complete_function(&mut worker, Some("SUMIF"), None);
|
||||
assert!(taken.expect("the completion"), "SUMIF is on offer");
|
||||
doc.complete_function(&mut worker, "SUMIF")
|
||||
.expect("the completion");
|
||||
let done = poll_until(&mut worker, &doc, &offered, SETTLE, |seen| {
|
||||
seen.cell.formula == "=SUMIF()"
|
||||
});
|
||||
|
||||
@@ -350,7 +350,7 @@ fn brightness(tile: &Tile) -> u32 {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ely_scheme_paints_the_page_white() {
|
||||
fn the_page_follows_a_dark_theme_and_back() {
|
||||
let Some(install) = install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
@@ -363,11 +363,23 @@ fn ely_scheme_paints_the_page_white() {
|
||||
let doc = worker
|
||||
.load(&crate::file_url(&source))
|
||||
.expect("the document loads");
|
||||
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}");
|
||||
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}"
|
||||
);
|
||||
worker.close_doc(doc).expect("the document closes");
|
||||
}
|
||||
}
|
||||
@@ -182,16 +182,18 @@ pub(super) fn open_book(text: &str) -> (tempfile::TempDir, Worker, Doc) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scheme_is_no_edit() {
|
||||
fn a_theme_switch_is_no_edit() {
|
||||
if install().is_none() {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
}
|
||||
let (_dir, mut worker, doc) = open_book(FODS);
|
||||
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");
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -103,24 +103,6 @@ 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() {
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
//! 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}_{"10"}} over {{a}_{"3"}}"),
|
||||
"the fraction keeps its italics and subscripts"
|
||||
);
|
||||
assert!(fodt.contains("nroot{"3"}{"x"}"), "the radical");
|
||||
assert!(fodt.contains("sum from{"1"} to{"5"}"), "an uppercase \\SU is a sum");
|
||||
assert!(
|
||||
fodt.contains("matrix{{"a"} # {"b"} ## {"c"} # {"d"}}"),
|
||||
"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("{"m"} over {"n"}<"), "nothing after the fraction");
|
||||
assert_eq!(subscripts(&fodt, ">p<"), 1, "a style change keeps one subscript");
|
||||
assert!(fodt.contains(""u" stack{{"v"}}"), "\\ai only spaces lines");
|
||||
assert!(fodt.contains(""w" stack{{"k"}}"), "\\di only spaces lines");
|
||||
assert!(fodt.contains("_{{"g"}} "+h""), "a lowercase \\s\\do keeps its tail");
|
||||
assert!(fodt.contains("_{{{"r"} over {"s"}}}"), "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 {"10"}} over {{a} rsub {"3"}}"),
|
||||
"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("{""} rsub {"g"} "+h""), "the tail survives");
|
||||
assert!(!fodt.contains("<?>"), "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:?}");
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use crate::ffi::Document;
|
||||
|
||||
mod cell;
|
||||
mod parse;
|
||||
pub use cell::{Cell, Function, Mark, Request, offered};
|
||||
pub use cell::{Cell, Function, Mark, Request};
|
||||
use cell::{functions_of, marks_of, request_of, usage_of};
|
||||
pub use parse::A11y;
|
||||
use parse::{
|
||||
|
||||
@@ -5,19 +5,6 @@ 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 {
|
||||
|
||||
@@ -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,
|
||||
SCHEME, Zoomed, init, layout, paper, read_off,
|
||||
Zoomed, init, layout, paper, read_off, scheme,
|
||||
};
|
||||
#[cfg(feature = "view")]
|
||||
mod view;
|
||||
|
||||
@@ -32,7 +32,7 @@ mod scroll;
|
||||
mod sheet;
|
||||
pub use input::{CONTEXT, Copy, Cut, Paste, init};
|
||||
pub use off::read_off;
|
||||
pub use render::{SCHEME, paper};
|
||||
pub use render::{paper, scheme};
|
||||
mod turn;
|
||||
pub use turn::notch;
|
||||
|
||||
@@ -123,9 +123,8 @@ pub struct LokView {
|
||||
pointer: gpui::CursorStyle,
|
||||
selection: Vec<Rect>,
|
||||
cell: crate::Cell,
|
||||
completion: formula::Completion,
|
||||
/// Whether the kernel was told Ely's scheme.
|
||||
lit: bool,
|
||||
/// Whether the kernel was last told to paint dark.
|
||||
dark: Option<bool>,
|
||||
/// The zoom follows the viewport so a page fits.
|
||||
fit: bool,
|
||||
/// Set when the wheel and Up, Down turn parts.
|
||||
@@ -219,8 +218,7 @@ impl LokView {
|
||||
pointer: gpui::CursorStyle::Arrow,
|
||||
selection: Vec::new(),
|
||||
cell: crate::Cell::default(),
|
||||
completion: formula::Completion::default(),
|
||||
lit: false,
|
||||
dark: None,
|
||||
fit: false,
|
||||
turning: None,
|
||||
part: 0,
|
||||
@@ -394,7 +392,6 @@ 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;
|
||||
|
||||
@@ -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(|error| io::Error::other(format!("{error:#}")))? else {
|
||||
let Some(clip) = clipboard::read().map_err(io::Error::other)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let (mime, data) = flavor_of(clip);
|
||||
|
||||
@@ -5,17 +5,11 @@ use ely_gpui_component::{
|
||||
primitives::raise,
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, Bounds, Context, Hsla, InteractiveElement, IntoElement, Keystroke, Modifiers,
|
||||
ParentElement, Pixels, anchored, div, rgb,
|
||||
AnyElement, Bounds, Context, Hsla, IntoElement, Keystroke, Modifiers, ParentElement, Pixels,
|
||||
anchored, rgb,
|
||||
};
|
||||
|
||||
use super::{
|
||||
LokView,
|
||||
input::{Input, vcl_key},
|
||||
};
|
||||
|
||||
mod completion;
|
||||
pub(super) use completion::Completion;
|
||||
use super::{LokView, input::Input};
|
||||
|
||||
impl LokView {
|
||||
/// The sheet shown, which reference marks follow.
|
||||
@@ -54,7 +48,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 || self.completion.pending() || keystroke.modifiers != Modifiers::none() {
|
||||
if count == 0 || keystroke.modifiers != Modifiers::none() {
|
||||
return false;
|
||||
}
|
||||
self.picked = match keystroke.key.as_str() {
|
||||
@@ -66,46 +60,32 @@ impl LokView {
|
||||
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;
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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.complete_function(&item.label);
|
||||
view.picked = at.expect("a picked item is offered");
|
||||
view.complete();
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
@@ -114,7 +94,7 @@ impl LokView {
|
||||
let place = anchored()
|
||||
.position(cell.bottom_left())
|
||||
.snap_to_window()
|
||||
.child(div().block_mouse_except_scroll().child(hint));
|
||||
.child(hint);
|
||||
Some(raise("lok-view-hints", place).into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -45,11 +45,8 @@ pub(super) enum Input {
|
||||
Paste,
|
||||
/// Values or formulas alone: `paste.values` or `paste.formulas`.
|
||||
PasteOnly(&'static str),
|
||||
/// A function the kernel offers then, else Enter or Tab.
|
||||
Complete {
|
||||
name: Option<String>,
|
||||
key: Option<(u32, i32)>,
|
||||
},
|
||||
/// A function the kernel offered, taken.
|
||||
Complete(String),
|
||||
/// The kernel's color scheme; not an edit.
|
||||
Theme(&'static str),
|
||||
/// Commands in order, from one read.
|
||||
@@ -75,8 +72,6 @@ pub(super) enum Input {
|
||||
pub(super) enum Back {
|
||||
Copied(String),
|
||||
Shown(usize),
|
||||
/// Whether a function was taken.
|
||||
Completed(bool),
|
||||
}
|
||||
|
||||
impl Input {
|
||||
@@ -97,7 +92,7 @@ impl Input {
|
||||
| Input::Paste
|
||||
| Input::PasteOnly(_)
|
||||
| Input::Drag { .. }
|
||||
| Input::Complete { .. } => true,
|
||||
| Input::Complete(_) => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,10 +201,7 @@ 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, key } => {
|
||||
let taken = doc.complete_function(worker, name.as_deref(), key)?;
|
||||
return Ok(Some(Back::Completed(taken)));
|
||||
}
|
||||
Input::Complete(name) => doc.complete_function(worker, &name),
|
||||
Input::Theme(scheme) => worker.run(doc, "view.theme", &[scheme]),
|
||||
Input::Text { commit, text } => worker.post_text(doc, commit, &text),
|
||||
Input::Copy { cut } => {
|
||||
@@ -278,10 +270,6 @@ 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;
|
||||
};
|
||||
@@ -319,14 +307,10 @@ 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();
|
||||
}
|
||||
}
|
||||
@@ -441,14 +425,10 @@ impl LokView {
|
||||
self.send(Input::Paste);
|
||||
}
|
||||
|
||||
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_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_previous_field(
|
||||
|
||||
@@ -107,9 +107,6 @@ 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);
|
||||
}
|
||||
@@ -121,7 +118,7 @@ impl LokView {
|
||||
self.part = part;
|
||||
self.invalidate(cx);
|
||||
}
|
||||
Ok(Some(Back::Shown(_) | Back::Completed(_))) => {}
|
||||
Ok(Some(Back::Shown(_))) => {}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
log::error!("lok: input failed ({:?})", error.kind());
|
||||
|
||||
@@ -6,9 +6,9 @@ use super::LokView;
|
||||
use crate::Rect;
|
||||
|
||||
impl LokView {
|
||||
/// Whether the kernel was told Ely's scheme.
|
||||
pub fn lit(&self) -> bool {
|
||||
self.lit
|
||||
/// Whether the kernel paints dark; None before the first frame.
|
||||
pub fn dark(&self) -> Option<bool> {
|
||||
self.dark
|
||||
}
|
||||
|
||||
/// The kernel's state for `uno`, as LOK reports it (LOK-05).
|
||||
|
||||
@@ -15,21 +15,33 @@ use gpui::{Pixels, RenderImage, point};
|
||||
use super::{CONTEXT, LokView, TILE_TWIPS, TWIPS_PER_PIXEL, handles, input};
|
||||
use crate::Rect;
|
||||
|
||||
/// Ely's kernel color scheme, from `ely.xcd`.
|
||||
pub const SCHEME: &str = "COLOR_SCHEME_ELY_LIGHT";
|
||||
/// Ely's kernel color schemes, from `ely.xcd`.
|
||||
const DARK: &str = "COLOR_SCHEME_ELY_DARK";
|
||||
const LIGHT: &str = "COLOR_SCHEME_ELY_LIGHT";
|
||||
|
||||
/// A page's colour, the scheme's `DocColor`.
|
||||
pub fn paper() -> gpui::Hsla {
|
||||
gpui::white()
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
impl LokView {
|
||||
/// 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));
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,14 +53,12 @@ impl Render for LokView {
|
||||
self.keep_scroll(|view| view.geom.dpi = dpi);
|
||||
self.retire_tiles();
|
||||
}
|
||||
self.paint_light();
|
||||
self.follow_theme(cx.theme().is_dark());
|
||||
let this = cx.entity().clone();
|
||||
let focus = self.focus.clone();
|
||||
let colors = &cx.theme().colors;
|
||||
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 paper = super::paper(cx);
|
||||
let (selection_color, caret_color) = (colors.selection, 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());
|
||||
|
||||
@@ -4,7 +4,6 @@ mod borders;
|
||||
mod clip;
|
||||
mod commands;
|
||||
mod fit;
|
||||
mod formula;
|
||||
mod input;
|
||||
mod large;
|
||||
mod show_borders;
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
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");
|
||||
});
|
||||
}
|
||||
@@ -247,12 +247,6 @@ 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]))
|
||||
|
||||
@@ -24,150 +24,37 @@ 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 a_dark_app_paints_the_page_white() {
|
||||
fn the_view_paints_the_app_theme_and_back() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
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 cx = TestAppContext::single();
|
||||
let (view, worker) = cx.update(|app| live_view(app, &install));
|
||||
let doc = cx.update(|app| view.read(app).doc.clone());
|
||||
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");
|
||||
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));
|
||||
}
|
||||
assert!(
|
||||
settle(&cx, |app| !view.read(app).selection.is_empty()),
|
||||
"the selection arrives"
|
||||
shades[0] < 100 && shades[1] > 200,
|
||||
"dark then light: {shades:?}"
|
||||
);
|
||||
|
||||
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]
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::{
|
||||
use crate::ffi::{Document, Lok};
|
||||
use crate::ipc::{self, Pack, Unpack, tag};
|
||||
|
||||
mod complete;
|
||||
mod events;
|
||||
#[cfg(unix)]
|
||||
mod fatal;
|
||||
@@ -265,7 +264,13 @@ 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 => complete::complete(unpack),
|
||||
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::SET_AUTHOR => unpack
|
||||
.str()
|
||||
.and_then(|name| lok.set_option("author", &name))
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
//! 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())
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
[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
|
||||
@@ -1,39 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
//! 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",
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
//! 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))
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,151 +0,0 @@
|
||||
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());
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//! 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};
|
||||
@@ -1,275 +0,0 @@
|
||||
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"))
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
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"))
|
||||
})
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
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~"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
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, ¶ms).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
Reference in new issue
Block a user