mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 03:36:54 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8938fdfdcf | ||
|
|
55ff9d9f54 | ||
|
|
6f0e43c21c | ||
|
|
0870b1ad1c | ||
|
|
14dd23db6c | ||
|
|
ad10881cc3 | ||
|
|
ee7cbf32ab | ||
|
|
2fbbed33bc | ||
|
|
1a36d9db07 | ||
|
|
41769f1d16 | ||
|
|
1da640abcc | ||
|
|
f1cb4f00f4 | ||
|
|
392fb29727 | ||
|
|
fef1f4ddb1 |
@@ -43,7 +43,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
|
||||
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
|
||||
@@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
|
||||
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
|
||||
Generated
+234
-112
@@ -206,9 +206,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"image",
|
||||
@@ -220,7 +220,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -898,6 +898,31 @@ dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"ureq",
|
||||
"verifysign",
|
||||
"winreg",
|
||||
"winsafe",
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-winprint-gdi"
|
||||
version = "0.1.0"
|
||||
@@ -1016,6 +1041,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.5.0"
|
||||
@@ -1364,7 +1395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1838,10 +1869,13 @@ dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"craft-print",
|
||||
"craft-update",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"getrandom 0.4.3",
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
"gridcraft-ui-egui",
|
||||
"image",
|
||||
"log",
|
||||
@@ -1851,6 +1885,7 @@ dependencies = [
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
@@ -1884,9 +1919,9 @@ dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
"log",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"serde_json",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1918,7 +1953,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1955,7 +1990,7 @@ dependencies = [
|
||||
"gridcraft-formula",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -1975,6 +2010,7 @@ name = "gridcraft-ui-egui"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"epaint_default_fonts",
|
||||
@@ -2017,11 +2053,11 @@ dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-model",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2728,9 +2764,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.1"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e"
|
||||
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
@@ -2818,6 +2854,18 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
@@ -2833,6 +2881,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -3234,7 +3288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3407,6 +3461,19 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"indexmap",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
@@ -3479,6 +3546,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -3554,6 +3627,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
@@ -3608,7 +3690,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3915,7 +3997,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3925,7 +4007,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
@@ -3972,7 +4056,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4075,6 +4159,10 @@ name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "send_wrapper"
|
||||
@@ -4114,9 +4202,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -4203,9 +4291,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
@@ -4696,10 +4784,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4765,6 +4853,36 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -4994,6 +5112,12 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -5059,6 +5183,35 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -5071,6 +5224,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -5115,6 +5274,16 @@ dependencies = [
|
||||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "verifysign"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -5330,7 +5499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -5570,7 +5739,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5691,7 +5860,7 @@ version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5700,16 +5869,7 @@ version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5727,31 +5887,14 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5769,96 +5912,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.13"
|
||||
@@ -5940,10 +6035,20 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winresource"
|
||||
version = "0.1.31"
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
|
||||
checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winresource"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
|
||||
dependencies = [
|
||||
"toml",
|
||||
"version_check",
|
||||
@@ -6334,6 +6439,23 @@ dependencies = [
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
@@ -24,6 +24,7 @@ gridcraft-engine = { path = "crates/engine" }
|
||||
gridcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
gridcraft-mcp = { path = "crates/mcp" }
|
||||
gridcraft-pdf = { path = "crates/pdf" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
|
||||
@@ -175,6 +175,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
|
||||
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
|
||||
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
|
||||
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
|
||||
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
|
||||
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
|
||||
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
|
||||
@@ -259,6 +260,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
|
||||
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
|
||||
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
|
||||
| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
|
||||
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
|
||||
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
|
||||
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
|
||||
|
||||
@@ -24,10 +24,17 @@ chrono = { workspace = true }
|
||||
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
gridcraft-mcp = { workspace = true }
|
||||
# The control channel's token (OS random numbers); already in the tree.
|
||||
getrandom = "0.4"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
|
||||
@@ -1,40 +1,85 @@
|
||||
//! Localhost JSON-lines control server. Each connection may send many requests; each line
|
||||
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
|
||||
//!
|
||||
//! The first line of a connection must authenticate with the app's token (#777; see
|
||||
//! `gridcraft_mcp::control_auth`). A connection that doesn't, or later sends a line that isn't
|
||||
//! JSON, is answered once and closed.
|
||||
//!
|
||||
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
|
||||
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
|
||||
//! closed, so nothing sent after it runs.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use gridcraft_mcp::control_auth;
|
||||
use gridcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
const MAX_LINE: usize = 64 * 1024 * 1024;
|
||||
/// How long a new connection may take to send its token.
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`),
|
||||
/// else 256 random bits from the operating system, written to a file only the user can read.
|
||||
fn make_token(port: u16, chosen: Option<String>) -> std::io::Result<String> {
|
||||
if let Some(t) = chosen.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) {
|
||||
if t.len() < control_auth::MIN_TOKEN_LEN {
|
||||
return Err(std::io::Error::other(format!("the control token must have at least {} characters", control_auth::MIN_TOKEN_LEN)));
|
||||
}
|
||||
eprintln!("gridcraft: control channel on 127.0.0.1:{port} requires the token you set (clients: GRIDCRAFT_CONTROL_TOKEN)");
|
||||
return Ok(t);
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(format!("no random numbers for the control token: {e}")))?;
|
||||
let token = control_auth::hex(&bytes);
|
||||
let path = control_auth::write_token_file(port, &token)?;
|
||||
eprintln!("gridcraft: control channel on 127.0.0.1:{port}; its token is in {} (gridcraft-cli reads it)", path.display());
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let rx = serve_listener(listener, ctx)?;
|
||||
let token: Arc<str> = make_token(port, chosen_token)?.into();
|
||||
let rx = serve_listener(listener, token, ctx)?;
|
||||
log::info!("control channel listening on 127.0.0.1:{port}");
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Accept connections on `listener`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let (tx, rx) = channel();
|
||||
std::thread::Builder::new().name("control".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
|
||||
let token = Arc::clone(&token);
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
|
||||
}
|
||||
})?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
|
||||
/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
|
||||
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str) -> bool {
|
||||
let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));
|
||||
let mut first = String::new();
|
||||
let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);
|
||||
let verdict = match read {
|
||||
Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),
|
||||
_ => Err(json!({"ok": false, "error": "authentication required"})),
|
||||
};
|
||||
let allowed = verdict.is_ok();
|
||||
let reply = verdict.unwrap_or_else(|e| e);
|
||||
let _ = writeln!(out, "{reply}");
|
||||
let _ = out.set_read_timeout(None);
|
||||
allowed
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
@@ -103,10 +148,13 @@ fn is_http_request_line(text: &str) -> bool {
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
if !authenticate(&mut reader, &mut out, token) {
|
||||
return;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
@@ -154,21 +202,29 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
/// Send `input` on a new connection and read reply lines until the server closes it.
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// Authenticate a new connection, send `input`, and read reply lines (after the
|
||||
/// authentication reply) until the server closes it.
|
||||
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Json> {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
|
||||
c.write_all(input).unwrap();
|
||||
// A read timeout ends the list too: the server kept the connection open.
|
||||
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
|
||||
let mut replies: Vec<Json> = BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect();
|
||||
assert_eq!(replies.first().map(|r| r["ok"].clone()), Some(json!(true)), "{replies:?}");
|
||||
replies.remove(0);
|
||||
replies
|
||||
}
|
||||
|
||||
fn serve_on_loopback() -> (std::net::SocketAddr, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
(addr, serve_listener(listener, egui::Context::default()).unwrap())
|
||||
(addr, serve_listener(listener, TOKEN.into(), egui::Context::default()).unwrap())
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
@@ -194,8 +250,11 @@ mod tests {
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
|
||||
c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
let mut auth_reply = String::new();
|
||||
r.read_line(&mut auth_reply).unwrap();
|
||||
for id in [1, 2] {
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
@@ -221,4 +280,75 @@ mod tests {
|
||||
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
|
||||
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
|
||||
}
|
||||
|
||||
/// A server on a loopback port with a stand-in UI that echoes each method.
|
||||
fn spawn() -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
fn lines(addr: std::net::SocketAddr, send: &str) -> Vec<Json> {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(send.as_bytes()).unwrap();
|
||||
// Replies up to the server closing the connection.
|
||||
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_opens_the_connection() {
|
||||
let addr = spawn();
|
||||
let auth = control_auth::auth_line(1, TOKEN);
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(format!("{auth}\n{{\"id\":2,\"method\":\"ping\"}}\n").as_bytes()).unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Json>(&line).unwrap()["ok"], json!(true));
|
||||
line.clear();
|
||||
r.read_line(&mut line).unwrap();
|
||||
let v: Json = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["id"].clone(), v["result"].clone()), (json!(2), json!("ping")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_but_the_token_first_closes_the_connection() {
|
||||
let addr = spawn();
|
||||
let wrong = control_auth::auth_line(1, "ffffffffffffffffffffffffffffffff");
|
||||
for first in [wrong.as_str(), "{\"id\":1,\"method\":\"app.save\",\"params\":{\"path\":\"/tmp/x\"}}", "POST / HTTP/1.1", ""] {
|
||||
// The second request is never served: one refusal, then end of stream.
|
||||
let got = lines(addr, &format!("{first}\n{{\"id\":2,\"method\":\"ping\"}}\n"));
|
||||
assert_eq!(got.len(), 1, "{first}: {got:?}");
|
||||
assert_eq!(got[0]["ok"], json!(false), "{first}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparsable_line_after_auth_closes_the_connection() {
|
||||
let addr = spawn();
|
||||
let auth = control_auth::auth_line(1, TOKEN);
|
||||
let got = lines(addr, &format!("{auth}\nnot json\n{{\"id\":3,\"method\":\"ping\"}}\n"));
|
||||
assert_eq!(got.len(), 2, "{got:?}");
|
||||
assert_eq!(got[1]["ok"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_token_must_be_long_enough() {
|
||||
assert!(make_token(1, Some("short".into())).is_err());
|
||||
assert_eq!(make_token(1, Some(format!(" {TOKEN} "))).unwrap(), TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! GridCraft desktop app.
|
||||
//!
|
||||
//! Usage: `gridcraft [--control <port>] [--sample <name>] [files…]`
|
||||
//! Usage: `gridcraft [--control <port> [--control-token <token>]] [--sample <name>] [files…]`
|
||||
//!
|
||||
//! `--control <port>` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! The first line of each connection must carry the token (`docs/control-protocol.md`).
|
||||
//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -251,11 +252,20 @@ fn main() -> eframe::Result<()> {
|
||||
}
|
||||
}));
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
// The restart helper a self-update spawns: it finishes the swap and relaunches, then exits.
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("GridCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("GridCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
let mut control_port: Option<u16> = std::env::var("GRIDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control_token: Option<String> = std::env::var("GRIDCRAFT_CONTROL_TOKEN").ok();
|
||||
let mut sample: Option<String> = None;
|
||||
let mut files: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
@@ -265,6 +275,10 @@ fn main() -> eframe::Result<()> {
|
||||
i += 1;
|
||||
control_port = args.get(i).and_then(|p| p.parse().ok());
|
||||
}
|
||||
"--control-token" => {
|
||||
i += 1;
|
||||
control_token = args.get(i).cloned();
|
||||
}
|
||||
"--sample" => {
|
||||
i += 1;
|
||||
sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into()));
|
||||
@@ -299,6 +313,7 @@ fn main() -> eframe::Result<()> {
|
||||
}
|
||||
let mut app = SheetApp::new(session, services());
|
||||
app.watch_desktop_theme();
|
||||
app.start_update_check();
|
||||
app.after_engine(); // Show warnings from the sample.
|
||||
let control_port = control_port;
|
||||
load_prefs(&mut app);
|
||||
@@ -321,6 +336,18 @@ fn main() -> eframe::Result<()> {
|
||||
viewport = viewport.with_icon(i);
|
||||
}
|
||||
let options = eframe::NativeOptions { viewport, ..Default::default() };
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut options = options;
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; GRIDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("GRIDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when
|
||||
// DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -352,7 +379,7 @@ fn main() -> eframe::Result<()> {
|
||||
};
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
|
||||
if let Some(port) = control_port {
|
||||
match control_server::start(port, cc.egui_ctx.clone()) {
|
||||
match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) {
|
||||
Ok(rx) => app.control_rx = Some(rx),
|
||||
Err(e) => log::error!("control channel on port {port}: {e}"),
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
|
||||
(
|
||||
"Help",
|
||||
vec![
|
||||
("Check for Updates…", "app.checkForUpdates", None),
|
||||
("Agent Control (MCP)…", "dialog:agents", None),
|
||||
("Contributors…", "dialog:about:Contributors", None),
|
||||
("GridCraft on getartcraft.com", "url:https://getartcraft.com/apps/gridcraft", None),
|
||||
@@ -337,6 +338,16 @@ mod tests {
|
||||
assert!(gridcraft_engine::find_command("file.exportPdf").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_menu_checks_for_updates_through_the_registered_command() {
|
||||
let tree = tree();
|
||||
let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu");
|
||||
let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu");
|
||||
assert_eq!(item.1, "app.checkForUpdates");
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
assert!(app.run(item.1, serde_json::json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_choices_use_the_shared_preference_command() {
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
|
||||
+60
-53
@@ -927,6 +927,64 @@ impl Host for PassHost<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strongly connected components of a graph given as adjacency lists (Tarjan, iterative): each
|
||||
/// node's component index, and the number of nodes in each component.
|
||||
fn components(edges: &[Vec<usize>]) -> (Vec<usize>, Vec<usize>) {
|
||||
let n = edges.len();
|
||||
let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]);
|
||||
let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]);
|
||||
let (mut next, mut comps) = (0usize, 0usize);
|
||||
for root in 0..n {
|
||||
if num[root] != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let mut call: Vec<(usize, usize)> = vec![(root, 0)];
|
||||
num[root] = next;
|
||||
low[root] = next;
|
||||
next += 1;
|
||||
stack.push(root);
|
||||
on_stack[root] = true;
|
||||
while let Some(top) = call.last_mut() {
|
||||
let v = top.0;
|
||||
if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) {
|
||||
top.1 += 1;
|
||||
if num[w] == usize::MAX {
|
||||
num[w] = next;
|
||||
low[w] = next;
|
||||
next += 1;
|
||||
stack.push(w);
|
||||
on_stack[w] = true;
|
||||
call.push((w, 0));
|
||||
} else if on_stack[w] {
|
||||
low[v] = low[v].min(num[w]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
call.pop();
|
||||
if let Some(&(u, _)) = call.last() {
|
||||
low[u] = low[u].min(low[v]);
|
||||
}
|
||||
if low[v] == num[v] {
|
||||
while let Some(w) = stack.pop() {
|
||||
on_stack[w] = false;
|
||||
comp[w] = comps;
|
||||
if w == v {
|
||||
break;
|
||||
}
|
||||
}
|
||||
comps += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut size = vec![0usize; comps];
|
||||
for &c in &comp {
|
||||
if let Some(s) = size.get_mut(c) {
|
||||
*s += 1;
|
||||
}
|
||||
}
|
||||
(comp, size)
|
||||
}
|
||||
|
||||
impl Calc {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
@@ -981,59 +1039,8 @@ impl Calc {
|
||||
e
|
||||
})
|
||||
.collect();
|
||||
// Tarjan's strongly connected components, iterative.
|
||||
let n = circ.len();
|
||||
let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]);
|
||||
let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]);
|
||||
let (mut next, mut comps) = (0usize, 0usize);
|
||||
for root in 0..n {
|
||||
if num[root] != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let mut call: Vec<(usize, usize)> = vec![(root, 0)];
|
||||
num[root] = next;
|
||||
low[root] = next;
|
||||
next += 1;
|
||||
stack.push(root);
|
||||
on_stack[root] = true;
|
||||
while let Some(top) = call.last_mut() {
|
||||
let v = top.0;
|
||||
if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) {
|
||||
top.1 += 1;
|
||||
if num[w] == usize::MAX {
|
||||
num[w] = next;
|
||||
low[w] = next;
|
||||
next += 1;
|
||||
stack.push(w);
|
||||
on_stack[w] = true;
|
||||
call.push((w, 0));
|
||||
} else if on_stack[w] {
|
||||
low[v] = low[v].min(num[w]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
call.pop();
|
||||
if let Some(&(u, _)) = call.last() {
|
||||
low[u] = low[u].min(low[v]);
|
||||
}
|
||||
if low[v] == num[v] {
|
||||
while let Some(w) = stack.pop() {
|
||||
on_stack[w] = false;
|
||||
comp[w] = comps;
|
||||
if w == v {
|
||||
break;
|
||||
}
|
||||
}
|
||||
comps += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut size = vec![0usize; comps];
|
||||
for &c in &comp {
|
||||
if let Some(s) = size.get_mut(c) {
|
||||
*s += 1;
|
||||
}
|
||||
}
|
||||
// Loop members: nodes in a component of more than one, or with an edge to themselves.
|
||||
let (comp, size) = components(&edges);
|
||||
let on_loop = |i: usize| comp.get(i).and_then(|c| size.get(*c)).is_some_and(|s| *s > 1) || edges.get(i).is_some_and(|e| e.contains(&i));
|
||||
// A formula with dynamic references (INDIRECT, OFFSET) may close a loop the graph can't see.
|
||||
let mut out: Vec<Key> =
|
||||
|
||||
@@ -96,75 +96,73 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sel = match str_param(p, "range") {
|
||||
Some(_) => target_range(s, p)?,
|
||||
None => d.selection.current(),
|
||||
};
|
||||
if !sel.is_single() {
|
||||
// A selection: totals go in its empty last row or column, as Excel's do, and otherwise
|
||||
// past it: to the right of a single row, below each column of anything taller. Columns
|
||||
// past the sheet's data get no total (a whole-row selection doesn't write 16,384
|
||||
// formulas), nor do rows past it, and all totals are one edit (one undo step).
|
||||
let Some(used) = sh.cells.used_range() else { return ok() };
|
||||
let empty = |row: u32, col: u32| sh.value(CellRef::new(row, col)).is_empty();
|
||||
let sum = |from: CellRef, to: CellRef| format!("={func}({})", RangeRef::new(from, to).a1());
|
||||
let last_col = used.end.col.min(sel.end.col);
|
||||
let mut writes: Vec<(CellRef, String)> = Vec::new();
|
||||
if sel.start.row == sel.end.row {
|
||||
// One row: its total goes in its last cell when that is empty, else just after it.
|
||||
let row = sel.start.row;
|
||||
let end_col = if sel.is_full_rows() { last_col } else { sel.end.col };
|
||||
let (data_end, out) = if end_col > sel.start.col && empty(row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) };
|
||||
if out >= gridcraft_core::MAX_COLS {
|
||||
return Err(EngineError::Other("There's no room to the right of the selection for the total.".into()));
|
||||
}
|
||||
writes.push((CellRef::new(row, out), sum(CellRef::new(row, sel.start.col), CellRef::new(row, data_end))));
|
||||
} else {
|
||||
// An empty last column takes each row's total; rows past the data get none.
|
||||
let rows_end = sel.end.row.min(used.end.row);
|
||||
let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| empty(r, sel.end.col));
|
||||
let data_end_col = if right_blank { sel.end.col - 1 } else { last_col };
|
||||
let last_row_blank = (sel.start.col..=last_col).all(|c| empty(sel.end.row, c));
|
||||
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
|
||||
// Each column's total, below it: unless only the last column is there to take totals.
|
||||
if last_row_blank || !right_blank {
|
||||
if out_row >= gridcraft_core::MAX_ROWS {
|
||||
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
|
||||
}
|
||||
for c in sel.start.col..=data_end_col.min(last_col) {
|
||||
writes.push((CellRef::new(out_row, c), sum(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c))));
|
||||
}
|
||||
}
|
||||
if right_blank {
|
||||
for r in sel.start.row..=data_end_row.min(rows_end) {
|
||||
writes.push((CellRef::new(r, sel.end.col), sum(CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col))));
|
||||
}
|
||||
// Both empty: the corner takes the total of the whole block.
|
||||
if last_row_blank {
|
||||
writes.push((
|
||||
CellRef::new(sel.end.row, sel.end.col),
|
||||
sum(CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let sheet = d.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
for (c, f) in &writes {
|
||||
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
|
||||
cx.sheet_mut(sheet)?.set_cell(*c, cell);
|
||||
cx.touch(sheet, *c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
/// Whether a cell on the active sheet is empty.
|
||||
fn blank(sh: &gridcraft_model::sheet::Sheet, row: u32, col: u32) -> bool {
|
||||
sh.value(CellRef::new(row, col)).is_empty()
|
||||
}
|
||||
|
||||
fn sum_formula(func: &str, from: CellRef, to: CellRef) -> String {
|
||||
format!("={func}({})", RangeRef::new(from, to).a1())
|
||||
}
|
||||
|
||||
/// Totals for a multi-cell selection as `(cell, formula)` writes. `None` when the sheet has no data.
|
||||
fn selection_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, func: &str) -> Result<Option<Vec<(CellRef, String)>>> {
|
||||
// A selection: totals go in its empty last row or column, as Excel's do, and otherwise
|
||||
// past it: to the right of a single row, below each column of anything taller. Columns
|
||||
// past the sheet's data get no total (a whole-row selection doesn't write 16,384
|
||||
// formulas), nor do rows past it, and all totals are one edit (one undo step).
|
||||
let Some(used) = sh.cells.used_range() else { return Ok(None) };
|
||||
if sel.start.row == sel.end.row { single_row_totals(sh, sel, used, func).map(Some) } else { block_totals(sh, sel, used, func).map(Some) }
|
||||
}
|
||||
|
||||
/// One row: its total goes in its last cell when that is empty, else just after it.
|
||||
fn single_row_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
|
||||
let row = sel.start.row;
|
||||
let last_col = used.end.col.min(sel.end.col);
|
||||
let end_col = if sel.is_full_rows() { last_col } else { sel.end.col };
|
||||
let (data_end, out) = if end_col > sel.start.col && blank(sh, row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) };
|
||||
if out >= gridcraft_core::MAX_COLS {
|
||||
return Err(EngineError::Other("There's no room to the right of the selection for the total.".into()));
|
||||
}
|
||||
// Single cell: guess the range above, else to the left.
|
||||
let at = sel.start;
|
||||
Ok(vec![(CellRef::new(row, out), sum_formula(func, CellRef::new(row, sel.start.col), CellRef::new(row, data_end)))])
|
||||
}
|
||||
|
||||
/// A block of rows: each column's total below it, and each row's total in an empty last column.
|
||||
fn block_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
|
||||
let mut writes: Vec<(CellRef, String)> = Vec::new();
|
||||
let last_col = used.end.col.min(sel.end.col);
|
||||
// An empty last column takes each row's total; rows past the data get none.
|
||||
let rows_end = sel.end.row.min(used.end.row);
|
||||
let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| blank(sh, r, sel.end.col));
|
||||
let data_end_col = if right_blank { sel.end.col - 1 } else { last_col };
|
||||
let last_row_blank = (sel.start.col..=last_col).all(|c| blank(sh, sel.end.row, c));
|
||||
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
|
||||
// Each column's total, below it: unless only the last column is there to take totals.
|
||||
if last_row_blank || !right_blank {
|
||||
if out_row >= gridcraft_core::MAX_ROWS {
|
||||
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
|
||||
}
|
||||
for c in sel.start.col..=data_end_col.min(last_col) {
|
||||
writes.push((CellRef::new(out_row, c), sum_formula(func, CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c))));
|
||||
}
|
||||
}
|
||||
if right_blank {
|
||||
for r in sel.start.row..=data_end_row.min(rows_end) {
|
||||
writes.push((CellRef::new(r, sel.end.col), sum_formula(func, CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col))));
|
||||
}
|
||||
// Both empty: the corner takes the total of the whole block.
|
||||
if last_row_blank {
|
||||
writes.push((
|
||||
CellRef::new(sel.end.row, sel.end.col),
|
||||
sum_formula(func, CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(writes)
|
||||
}
|
||||
|
||||
/// The range a single cell's AutoSum guesses: the numbers above it, else the numbers to its left.
|
||||
fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<RangeRef> {
|
||||
let numeric = |c: CellRef| sh.value(c).is_number();
|
||||
let mut range = None;
|
||||
if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) {
|
||||
@@ -186,7 +184,33 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
range = Some(RangeRef::new(CellRef::new(at.row, left), CellRef::new(at.row, at.col - 1)));
|
||||
}
|
||||
let text = match range {
|
||||
range
|
||||
}
|
||||
|
||||
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sel = match str_param(p, "range") {
|
||||
Some(_) => target_range(s, p)?,
|
||||
None => d.selection.current(),
|
||||
};
|
||||
if !sel.is_single() {
|
||||
let Some(writes) = selection_totals(sh, sel, &func)? else { return ok() };
|
||||
let sheet = d.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
for (c, f) in &writes {
|
||||
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
|
||||
cx.sheet_mut(sheet)?.set_cell(*c, cell);
|
||||
cx.touch(sheet, *c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
// Single cell: guess the range above, else to the left.
|
||||
let at = sel.start;
|
||||
let text = match guessed_range(sh, at) {
|
||||
Some(r) => format!("={func}({})", r.a1()),
|
||||
None => {
|
||||
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={func}()"))));
|
||||
|
||||
@@ -25,14 +25,22 @@ const MAX_REPLY: u64 = 256 * 1024 * 1024;
|
||||
/// A running GridCraft app, reached through its loopback control port.
|
||||
pub struct Remote {
|
||||
addr: String,
|
||||
token: String,
|
||||
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening.
|
||||
/// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. The app
|
||||
/// requires a token (#777): `GRIDCRAFT_CONTROL_TOKEN`, else the file the app wrote for this port.
|
||||
pub fn connect(addr: &str) -> std::io::Result<Self> {
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
|
||||
let token = crate::control_auth::client_token(addr).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
Self::connect_with_token(addr, token)
|
||||
}
|
||||
|
||||
/// Like [`Remote::connect`] with a token the caller already has.
|
||||
pub fn connect_with_token(addr: &str, token: String) -> std::io::Result<Self> {
|
||||
let mut r = Self { addr: addr.to_string(), token, conn: None, next_id: 1 };
|
||||
r.reconnect()?;
|
||||
Ok(r)
|
||||
}
|
||||
@@ -51,7 +59,9 @@ impl Remote {
|
||||
s.set_read_timeout(Some(IO_TIMEOUT)).ok();
|
||||
s.set_write_timeout(Some(IO_TIMEOUT)).ok();
|
||||
let read = s.try_clone()?;
|
||||
self.conn = Some((BufReader::new(read), s));
|
||||
let mut conn = (BufReader::new(read), s);
|
||||
Self::authenticate(&mut conn, &self.token)?;
|
||||
self.conn = Some(conn);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last = e,
|
||||
@@ -60,6 +70,25 @@ impl Remote {
|
||||
Err(last)
|
||||
}
|
||||
|
||||
/// The handshake every connection starts with: the app closes one that doesn't send the token first.
|
||||
fn authenticate(conn: &mut (BufReader<TcpStream>, TcpStream), token: &str) -> std::io::Result<()> {
|
||||
let (reader, writer) = conn;
|
||||
writer.write_all(crate::control_auth::auth_line(0, token).as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
let mut reply = String::new();
|
||||
let n = std::io::Read::take(reader, 64 * 1024).read_line(&mut reply)?;
|
||||
let ok = n > 0 && serde_json::from_str::<Value>(reply.trim()).ok().and_then(|v| v.get("ok").and_then(Value::as_bool)) == Some(true);
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"the app refused the control token (is GRIDCRAFT_CONTROL_TOKEN or the app's token file for this port out of date?)",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
if self.conn.is_none() {
|
||||
self.reconnect()?;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Authentication of the control channel (#777).
|
||||
//!
|
||||
//! Anything on the machine, and a web page that POSTs `text/plain` to `127.0.0.1:<port>`, can
|
||||
//! reach a loopback port. So the first line of every connection must be
|
||||
//! `{"id":…,"method":"auth","params":{"token":"…"}}`, with the secret the app made at start. The
|
||||
//! app keeps it in memory and writes it to a file only the user can read (or the user chooses it
|
||||
//! with `GRIDCRAFT_CONTROL_TOKEN`); [`Remote`](crate::Remote) reads it from the same places.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Environment variable holding a token chosen by the user. The app uses it instead of making
|
||||
/// one (and writes no file); clients use it instead of reading the file.
|
||||
pub const TOKEN_ENV: &str = "GRIDCRAFT_CONTROL_TOKEN";
|
||||
/// The method of the handshake line.
|
||||
pub const AUTH_METHOD: &str = "auth";
|
||||
/// Shortest token the app accepts from the user (a made one has 256 bits).
|
||||
pub const MIN_TOKEN_LEN: usize = 16;
|
||||
/// Longest first line the app reads from a peer that hasn't authenticated yet.
|
||||
pub const MAX_AUTH_LINE: usize = 4096;
|
||||
|
||||
/// Compare two secrets without stopping at the first difference. Unequal lengths are unequal
|
||||
/// at once (the length of a token isn't secret).
|
||||
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
// Keep the optimiser from turning the loop into an early exit.
|
||||
std::hint::black_box(diff) == 0
|
||||
}
|
||||
|
||||
/// A made token as lowercase hex (two characters per byte).
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
const DIGITS: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut s = String::with_capacity(bytes.len().saturating_mul(2));
|
||||
for b in bytes {
|
||||
s.push(char::from(DIGITS[usize::from(b >> 4)]));
|
||||
s.push(char::from(DIGITS[usize::from(b & 15)]));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The handshake line a client sends first.
|
||||
pub fn auth_line(id: u64, token: &str) -> String {
|
||||
json!({"id": id, "method": AUTH_METHOD, "params": {"token": token}}).to_string()
|
||||
}
|
||||
|
||||
/// Check a connection's first line. `Ok` is the reply to send before serving further requests;
|
||||
/// `Err` is the reply for a refused connection, which the caller sends and then closes. The
|
||||
/// refusal is the same whatever was wrong, so it tells a stranger nothing.
|
||||
pub fn check_first_line(line: &str, token: &str) -> Result<Value, Value> {
|
||||
let refused = || json!({"ok": false, "error": "authentication required"});
|
||||
let msg: Value = serde_json::from_str(line.trim()).map_err(|_| refused())?;
|
||||
if msg.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) {
|
||||
return Err(refused());
|
||||
}
|
||||
let given = msg.get("params").and_then(|p| p.get("token")).and_then(Value::as_str).unwrap_or("");
|
||||
// Always compare, so a missing token costs the same as a wrong one.
|
||||
if constant_time_eq(given.as_bytes(), token.as_bytes()) && !token.is_empty() {
|
||||
Ok(json!({"id": msg.get("id").cloned().unwrap_or(Value::Null), "ok": true, "result": {"authenticated": true}}))
|
||||
} else {
|
||||
Err(refused())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the app writes the token it made for `port`, and where clients look for it: a folder
|
||||
/// only the user can open (`$XDG_RUNTIME_DIR/gridcraft`, else `~/.cache/gridcraft`; on Windows
|
||||
/// `%USERPROFILE%\.gridcraft`), file `control-<port>.token`. `None` when no such folder is known.
|
||||
pub fn token_path(port: u16) -> Option<PathBuf> {
|
||||
let dir = if cfg!(windows) {
|
||||
PathBuf::from(std::env::var_os("USERPROFILE").filter(|v| !v.is_empty())?).join(".gridcraft")
|
||||
} else if let Some(run) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) {
|
||||
PathBuf::from(run).join("gridcraft")
|
||||
} else {
|
||||
PathBuf::from(std::env::var_os("HOME").filter(|v| !v.is_empty())?).join(".cache").join("gridcraft")
|
||||
};
|
||||
Some(dir.join(format!("control-{port}.token")))
|
||||
}
|
||||
|
||||
/// The port of a control address (`127.0.0.1:7979` → 7979).
|
||||
fn port_of(addr: &str) -> Option<u16> {
|
||||
addr.rsplit(':').next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// The token a client uses for `addr`: [`TOKEN_ENV`] if set, else the file the app wrote.
|
||||
pub fn client_token(addr: &str) -> Result<String, String> {
|
||||
if let Ok(t) = std::env::var(TOKEN_ENV)
|
||||
&& !t.trim().is_empty()
|
||||
{
|
||||
return Ok(t.trim().to_string());
|
||||
}
|
||||
let path = port_of(addr).and_then(token_path).ok_or_else(|| format!("no control token: set {TOKEN_ENV}"))?;
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(t) if !t.trim().is_empty() => Ok(t.trim().to_string()),
|
||||
_ => Err(format!("no control token: set {TOKEN_ENV}, or start the app on this machine and user (it writes {})", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `token` to [`token_path`] for the app: the folder is made private (mode 0700 on Unix),
|
||||
/// an older file is replaced, and the new one is created readable by the user only (mode 0600).
|
||||
/// Returns the path.
|
||||
pub fn write_token_file(port: u16, token: &str) -> std::io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let path = token_path(port).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user folder for the control token"))?;
|
||||
let dir = path.parent().ok_or_else(|| std::io::Error::other("token path has no folder"))?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Only our own folder: leave a shared one (`$XDG_RUNTIME_DIR` itself) as it is.
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
// Replace rather than open: a leftover file may have looser permissions, or be a link.
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut f = opts.open(&path)?;
|
||||
f.write_all(token.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod backend;
|
||||
pub mod control_auth;
|
||||
mod headless;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
+49
-2
@@ -366,6 +366,11 @@ fn remote_backend_over_tcp() {
|
||||
let stream = stream.unwrap();
|
||||
let mut w = stream.try_clone().unwrap();
|
||||
let mut r = BufReader::new(stream);
|
||||
// Every connection starts with the token, as the app requires.
|
||||
let mut first = String::new();
|
||||
r.read_line(&mut first).unwrap();
|
||||
let hello = crate::control_auth::check_first_line(&first, "secret-token-0123456789").unwrap();
|
||||
w.write_all(format!("{hello}\n").as_bytes()).unwrap();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if r.read_line(&mut line).unwrap_or(0) == 0 {
|
||||
@@ -385,7 +390,7 @@ fn remote_backend_over_tcp() {
|
||||
});
|
||||
let addr = control_addr(&port.to_string());
|
||||
assert_eq!(addr, format!("127.0.0.1:{port}"));
|
||||
let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap()));
|
||||
let mut s = Server::new(Box::new(Remote::connect_with_token(&addr, "secret-token-0123456789".into()).unwrap()));
|
||||
assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=1+1"}))["result"], json!(2.0));
|
||||
// Second call reconnects transparently.
|
||||
ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"}));
|
||||
@@ -394,7 +399,49 @@ fn remote_backend_over_tcp() {
|
||||
assert!(msg.contains("unknown command"), "{msg}");
|
||||
drop(s);
|
||||
handle.join().unwrap();
|
||||
assert!(Remote::connect("127.0.0.1:1").is_err());
|
||||
assert!(Remote::connect_with_token("127.0.0.1:1", "x".into()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_auth_handshake_and_compare() {
|
||||
use crate::control_auth::{auth_line, check_first_line, constant_time_eq, hex};
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let ok = check_first_line(&auth_line(7, token), token).unwrap();
|
||||
assert_eq!((ok["id"].clone(), ok["ok"].clone()), (json!(7), json!(true)));
|
||||
// Wrong or missing token, another method, and non-JSON are all refused the same way.
|
||||
let wrong = check_first_line(&auth_line(1, "0123456789abcdef0123456789abcdee"), token).unwrap_err();
|
||||
for bad in [
|
||||
auth_line(1, ""),
|
||||
r#"{"id":1,"method":"auth","params":{}}"#.into(),
|
||||
r#"{"id":1,"method":"app.save","params":{"path":"/tmp/x","token":"0123456789abcdef0123456789abcdef"}}"#.into(),
|
||||
"POST / HTTP/1.1".into(),
|
||||
String::new(),
|
||||
] {
|
||||
assert_eq!(check_first_line(&bad, token).unwrap_err(), wrong, "{bad}");
|
||||
}
|
||||
// An empty configured token never matches an empty given one.
|
||||
assert!(check_first_line(&auth_line(1, ""), "").is_err());
|
||||
assert!(constant_time_eq(b"abc", b"abc"));
|
||||
assert!(!constant_time_eq(b"abc", b"abd"));
|
||||
assert!(!constant_time_eq(b"abc", b"abcd"));
|
||||
assert_eq!(hex(&[0x00, 0xab, 0xff]), "00abff");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn control_token_file_is_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Unique port per run so parallel tests don't share a file; the folder is the real one.
|
||||
let port = 40_000 + (std::process::id() % 20_000) as u16;
|
||||
let path = crate::control_auth::write_token_file(port, "tok-0123456789abcdef").unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(std::fs::metadata(path.parent().unwrap()).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
// A looser leftover is replaced, not reused.
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).unwrap();
|
||||
crate::control_auth::write_token_file(port, "tok-1123456789abcdef").unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "tok-1123456789abcdef");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -36,6 +36,7 @@ epaint_default_fonts = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
# The desktop's preferred language for the first-run interface language.
|
||||
sys-locale = { workspace = true }
|
||||
|
||||
|
||||
@@ -957,6 +957,35 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
|
||||
fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let lang = app.ui.language;
|
||||
section(ui, lang, "Options|General");
|
||||
general_options(app, ui, lang);
|
||||
ui.add_space(6.0);
|
||||
let mut formula_bar = app.session.prefs.formula_bar;
|
||||
if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() {
|
||||
app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Options|Editing");
|
||||
let mut autocomplete = app.ui.autocomplete;
|
||||
if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() {
|
||||
app.run_or_alert("options.autoComplete", json!({"on": autocomplete}));
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Options|Save");
|
||||
let mut autosave = app.ui.autosave;
|
||||
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {
|
||||
app.toggle_autosave();
|
||||
}
|
||||
let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return };
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Formulas");
|
||||
ui.label(lang.tr("Workbook calculation"));
|
||||
calc_options(app, ui, lang, &calc);
|
||||
ui.add_space(6.0);
|
||||
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
|
||||
}
|
||||
|
||||
/// The display theme and interface language controls of the General section.
|
||||
fn general_options(app: &mut SheetApp, ui: &mut Ui, lang: Language) {
|
||||
egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| {
|
||||
let label = ui.label(lang.tr("Display theme"));
|
||||
let mode = app.ui.theme_mode();
|
||||
@@ -993,27 +1022,10 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
.labelled_by(label.id);
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let mut formula_bar = app.session.prefs.formula_bar;
|
||||
if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() {
|
||||
app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Options|Editing");
|
||||
let mut autocomplete = app.ui.autocomplete;
|
||||
if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() {
|
||||
app.run_or_alert("options.autoComplete", json!({"on": autocomplete}));
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Options|Save");
|
||||
let mut autosave = app.ui.autosave;
|
||||
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {
|
||||
app.toggle_autosave();
|
||||
}
|
||||
let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return };
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Formulas");
|
||||
ui.label(lang.tr("Workbook calculation"));
|
||||
}
|
||||
|
||||
/// The calculation mode, and iterative calculation, of the active workbook.
|
||||
fn calc_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings) {
|
||||
use gridcraft_engine::model::CalcMode;
|
||||
for (mode, id, label) in [
|
||||
(CalcMode::Automatic, "automatic", "Automatic"),
|
||||
@@ -1030,6 +1042,13 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative}));
|
||||
}
|
||||
// Maximum Iterations and Maximum Change apply only while iteration is on, as in Excel.
|
||||
iteration_options(app, ui, lang, calc, iterative);
|
||||
// TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes
|
||||
// `precisionAsDisplayed` (PR #1366); the model already stores it.
|
||||
}
|
||||
|
||||
/// Maximum Iterations and Maximum Change, live only while iterative calculation is on.
|
||||
fn iteration_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings, iterative: bool) {
|
||||
ui.add_enabled_ui(iterative, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(24.0);
|
||||
@@ -1070,10 +1089,6 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
});
|
||||
// TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes
|
||||
// `precisionAsDisplayed` (PR #1366); the model already stores it.
|
||||
ui.add_space(6.0);
|
||||
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
|
||||
}
|
||||
|
||||
/// Refuses a typed setting with a message, leaving the workbook as it was. The Enter that
|
||||
|
||||
@@ -78,6 +78,32 @@ pub fn highlight(text: &str, lang: Lang) -> Vec<(Range<usize>, Token)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// End (exclusive, in chars) of the Python string literal opening with quote `c` at char `i`.
|
||||
fn python_string_end(chars: &[(usize, char)], i: usize, c: char) -> usize {
|
||||
let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c);
|
||||
let mut j = if triple { i + 3 } else { i + 1 };
|
||||
loop {
|
||||
match chars.get(j) {
|
||||
None => break,
|
||||
Some((_, '\\')) => j += 2,
|
||||
Some((_, '\n')) if !triple => break,
|
||||
Some((_, d)) if *d == c => {
|
||||
if !triple {
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) {
|
||||
j += 3;
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
Some(_) => j += 1,
|
||||
}
|
||||
}
|
||||
j.min(chars.len())
|
||||
}
|
||||
|
||||
fn python(text: &str) -> Vec<(Range<usize>, Token)> {
|
||||
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
||||
let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len());
|
||||
@@ -92,28 +118,7 @@ fn python(text: &str) -> Vec<(Range<usize>, Token)> {
|
||||
out.push((start..end_of(j), Token::Comment));
|
||||
i = j;
|
||||
} else if c == '"' || c == '\'' {
|
||||
let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c);
|
||||
let mut j = if triple { i + 3 } else { i + 1 };
|
||||
loop {
|
||||
match chars.get(j) {
|
||||
None => break,
|
||||
Some((_, '\\')) => j += 2,
|
||||
Some((_, '\n')) if !triple => break,
|
||||
Some((_, d)) if *d == c => {
|
||||
if !triple {
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) {
|
||||
j += 3;
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
Some(_) => j += 1,
|
||||
}
|
||||
}
|
||||
let j = j.min(chars.len());
|
||||
let j = python_string_end(&chars, i, c);
|
||||
out.push((start..end_of(j), Token::String));
|
||||
i = j;
|
||||
} else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|(_, d)| d.is_ascii_digit())) {
|
||||
|
||||
@@ -595,6 +595,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
let language_params = format!("{{language: {}}} (alias: code)", crate::i18n::Language::code_list("|"));
|
||||
for (id, label, params) in [
|
||||
("ui.ribbonTab", "Ribbon Tab", ""),
|
||||
("app.checkForUpdates", "Check for Updates…", ""),
|
||||
("view.collapseRibbon", "Collapse Ribbon", ""),
|
||||
("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"),
|
||||
("view.darkMode", "Dark Mode", ""),
|
||||
|
||||
+52
-32
@@ -2046,38 +2046,46 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo:
|
||||
}
|
||||
// A held drag owns the cursor even outside the grid; a press below egui's threshold still
|
||||
// has Drag::None and uses hover. A click can briefly leave a drag state after release.
|
||||
if resp.dragged() && pointer.primary_down() {
|
||||
let cursor = match app.grid.drag {
|
||||
Drag::None => None,
|
||||
Drag::Select => Some(CursorIcon::Cell),
|
||||
Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default),
|
||||
Drag::Fill { .. } => Some(CursorIcon::Crosshair),
|
||||
Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move),
|
||||
Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn),
|
||||
Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow),
|
||||
Drag::Freeze { cols, .. } => Some(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }),
|
||||
Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe),
|
||||
};
|
||||
if let Some(cursor) = cursor {
|
||||
ctx.set_cursor_icon(cursor);
|
||||
return;
|
||||
}
|
||||
if resp.dragged()
|
||||
&& pointer.primary_down()
|
||||
&& let Some(cursor) = drag_cursor(&app.grid.drag)
|
||||
{
|
||||
ctx.set_cursor_icon(cursor);
|
||||
return;
|
||||
}
|
||||
if !resp.hovered() {
|
||||
return;
|
||||
}
|
||||
let Some(p) = pos else { return };
|
||||
hover_cursor(app, ctx, resp, &pointer, geo, sh, p);
|
||||
}
|
||||
|
||||
/// The cursor a held drag shows, or `None` when the drag leaves it to hover.
|
||||
fn drag_cursor(drag: &Drag) -> Option<CursorIcon> {
|
||||
match drag {
|
||||
Drag::None => None,
|
||||
Drag::Select => Some(CursorIcon::Cell),
|
||||
Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default),
|
||||
Drag::Fill { .. } => Some(CursorIcon::Crosshair),
|
||||
Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move),
|
||||
Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn),
|
||||
Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow),
|
||||
Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }),
|
||||
Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor over the grid when no drag owns it: freeze bars, header edges, the fill handle, cells.
|
||||
fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2) {
|
||||
let (freeze_cols, freeze_rows) = freeze_handles(geo);
|
||||
if freeze_cols.contains(p) || freeze_rows.contains(p) {
|
||||
let cols = freeze_cols.contains(p);
|
||||
ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical });
|
||||
if app.grid.drag == Drag::None && !pointer.any_down() {
|
||||
let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" };
|
||||
resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip));
|
||||
}
|
||||
freeze_cursor(app, ctx, resp, pointer, freeze_cols.contains(p));
|
||||
return;
|
||||
}
|
||||
let in_cells = geo.in_cells(p);
|
||||
let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left();
|
||||
let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top();
|
||||
let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some();
|
||||
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
|
||||
let cur_rect = geo.range_rect(sh, sel.current());
|
||||
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
|
||||
@@ -2089,16 +2097,8 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo:
|
||||
let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS);
|
||||
if app.grid.drag == Drag::None && near_corner {
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeNwSe);
|
||||
} else if (in_col_header && col_edge(geo, sh, p.x).is_some()) || (in_row_header && row_edge(geo, sh, p.y).is_some()) {
|
||||
let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some();
|
||||
// Just past a hidden column or row, Excel shows a split cursor: dragging or
|
||||
// double-clicking there unhides it.
|
||||
let hidden = app.grid.drag == Drag::None
|
||||
&& if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() };
|
||||
match pointer.hover_pos() {
|
||||
Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge),
|
||||
_ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }),
|
||||
}
|
||||
} else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) {
|
||||
header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge);
|
||||
} else if on_handle && app.editor.is_none() {
|
||||
ctx.set_cursor_icon(CursorIcon::Crosshair);
|
||||
if app.grid.drag == Drag::None && !pointer.any_down() {
|
||||
@@ -2115,6 +2115,26 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo:
|
||||
}
|
||||
}
|
||||
|
||||
/// The resize cursor for dragging a freeze bar.
|
||||
fn freeze_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, cols: bool) {
|
||||
ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical });
|
||||
if app.grid.drag == Drag::None && !pointer.any_down() {
|
||||
let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" };
|
||||
resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip));
|
||||
}
|
||||
}
|
||||
|
||||
/// The resize cursor over a column or row header edge. Just past a hidden column or row, Excel
|
||||
/// shows a split cursor: dragging or double-clicking there unhides it.
|
||||
fn header_edge_cursor(app: &SheetApp, ctx: &egui::Context, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2, near_col_edge: bool) {
|
||||
let hidden =
|
||||
app.grid.drag == Drag::None && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() };
|
||||
match pointer.hover_pos() {
|
||||
Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge),
|
||||
_ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }),
|
||||
}
|
||||
}
|
||||
|
||||
fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
|
||||
// Text selection and editing must not also move the object or edit worksheet cells.
|
||||
if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() {
|
||||
|
||||
@@ -35,6 +35,8 @@ pub mod selection_glide;
|
||||
pub mod tabs;
|
||||
pub mod text_box;
|
||||
pub mod theme;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod updates_ui;
|
||||
pub mod widgets;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -326,6 +328,9 @@ pub struct SheetApp {
|
||||
/// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next
|
||||
/// window close goes through.
|
||||
pub allow_close: bool,
|
||||
/// The update check, the startup prompt and a running self-update (desktop only).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: updates_ui::Runtime,
|
||||
}
|
||||
|
||||
impl SheetApp {
|
||||
@@ -379,6 +384,8 @@ impl SheetApp {
|
||||
tab_run: None,
|
||||
started: now_ms(),
|
||||
allow_close: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,6 +673,10 @@ impl SheetApp {
|
||||
/// UI-only commands (dialogs, view toggles that aren't saved in the file).
|
||||
fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> {
|
||||
let r = match id {
|
||||
"app.checkForUpdates" | "help.checkForUpdates" => {
|
||||
self.check_for_updates();
|
||||
Ok(Json::Null)
|
||||
}
|
||||
"ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()),
|
||||
"ui.printSubmit" => self.submit_print(p.clone()),
|
||||
"ui.ribbonTab" => {
|
||||
@@ -1234,6 +1245,8 @@ impl SheetApp {
|
||||
// frames still see worker completion. `ui` and the desktop app no longer poll it.
|
||||
self.poll_printing(ctx);
|
||||
self.guard_window_close(ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::tick(self, ctx);
|
||||
// Files read asynchronously (web file picker, dropped files).
|
||||
self.poll_async_opens();
|
||||
self.autosave(ctx);
|
||||
@@ -1395,6 +1408,23 @@ impl SheetApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Look for a newer release once, in the background, as the desktop app starts (a no-op on the
|
||||
/// web). A found release opens the update prompt; nothing is shown when GridCraft is current.
|
||||
pub fn start_update_check(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::start(self, false);
|
||||
}
|
||||
|
||||
/// Help ▸ Check for Updates…: look now, and say so when GridCraft is up to date or the check fails.
|
||||
pub fn check_for_updates(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::start(self, true);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
self.message = Some(("GridCraft".into(), "The web version is always the latest release.".into()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Window title, next to the close/autosave state: `ViewportCommand::Title` is valid outside a
|
||||
/// pass and a control-channel rename while hidden updates it on return.
|
||||
fn update_title(&mut self, ctx: &egui::Context) {
|
||||
@@ -1473,6 +1503,8 @@ impl SheetApp {
|
||||
text_box::before_ui(self, &ctx);
|
||||
// The language the widgets translate with this frame (see `i18n::current`).
|
||||
i18n::set_current(&ctx, self.ui.language);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::show(self, &ctx);
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
// A host can turn the File tab off while Backstage is already open: close it.
|
||||
if !self.chrome.file_tab {
|
||||
|
||||
@@ -2614,6 +2614,11 @@ fn automate(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if __k.clicked() {
|
||||
app.open_dialog("journal", json!({}));
|
||||
}
|
||||
let __k = big_button(ui, Icon::Script, "Check for\nUpdates…", "Look for a newer GridCraft release", false);
|
||||
control_keytip(app, ui, &__k, "checkForUpdates");
|
||||
if __k.clicked() {
|
||||
app.run_or_alert("app.checkForUpdates", json!({}));
|
||||
}
|
||||
let __k = big_button(ui, Icon::Script, "About\nGridCraft", "Version, contributors and the AI models that helped", false);
|
||||
control_keytip(app, ui, &__k, "about");
|
||||
if __k.clicked() {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! One background check at startup (and on Help ▸ Check for Updates…) and the modal prompt that
|
||||
//! offers the new release: Update and Restart (direct self-update), Download or Later. The flow
|
||||
//! itself is `craft_update::updater::Updater`.
|
||||
|
||||
use craft_update::suite::GRIDCRAFT;
|
||||
use craft_update::updater::{Action, Status, Updater};
|
||||
|
||||
use crate::SheetApp;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub(crate) struct Runtime {
|
||||
updater: Updater,
|
||||
/// The check at start was asked for; `tick` starts it with the context.
|
||||
startup: bool,
|
||||
/// Help ▸ Check for Updates… was chosen; the next `tick` starts it.
|
||||
manual: bool,
|
||||
}
|
||||
|
||||
impl Default for Runtime {
|
||||
fn default() -> Self {
|
||||
Self { updater: Updater::new(&GRIDCRAFT, VERSION), startup: false, manual: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for a check; the next frame's `tick` starts it. A manual one reports every outcome.
|
||||
pub(crate) fn start(app: &mut SheetApp, manual: bool) {
|
||||
if manual {
|
||||
app.updates_rt.manual = true;
|
||||
} else {
|
||||
app.updates_rt.startup = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tick(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
let rt = &mut app.updates_rt;
|
||||
if std::mem::take(&mut rt.manual) {
|
||||
rt.updater.check_now(ctx);
|
||||
} else if rt.startup {
|
||||
rt.updater.check_at_start(ctx);
|
||||
}
|
||||
rt.updater.tick(ctx);
|
||||
let lang = app.ui.language;
|
||||
let outcome = match rt.updater.status() {
|
||||
Status::UpToDate => Some(lang.tr("GridCraft {version} is up to date.").replace("{version}", VERSION)),
|
||||
Status::Failed(e) => Some(lang.tr("Could not check for updates: {error}").replace("{error}", e)),
|
||||
Status::Idle | Status::Checking => None,
|
||||
};
|
||||
if let Some(message) = outcome {
|
||||
rt.updater.dismiss_status();
|
||||
app.message = Some(("GridCraft".into(), message));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
|
||||
let clean = !app.session.documents().iter().any(|d| d.is_dirty());
|
||||
match app.updates_rt.updater.show_if(ctx, clean) {
|
||||
Action::OpenUrl(url) => {
|
||||
if let Some(open) = &app.services.open_url {
|
||||
open(&url);
|
||||
}
|
||||
}
|
||||
// The guarded close path asks about unsaved work; the restart helper relaunches once the
|
||||
// process has exited.
|
||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
Action::None => {}
|
||||
}
|
||||
}
|
||||
+46
-50
@@ -359,59 +359,55 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn
|
||||
}
|
||||
}
|
||||
}
|
||||
let dxfs = root
|
||||
.child("dxfs")
|
||||
.map(|d| {
|
||||
d.kids("dxf")
|
||||
.map(|x| {
|
||||
let mut s = Style::default();
|
||||
if let Some(e) = x.child("font") {
|
||||
let f = read_font(e, &palette);
|
||||
// Differential fonts rarely name a face; unless stated, keep the default face and size.
|
||||
let has_size = e.child("sz").and_then(|n| n.attr_f64("val")).is_some();
|
||||
let has_name = e.child("name").and_then(|n| n.attr("val")).is_some();
|
||||
let name = if has_name { f.name.clone() } else { s.font.name.clone() };
|
||||
let size = if has_size { f.size } else { s.font.size };
|
||||
let def = Font::default();
|
||||
let st = &mut s.dxf_stated;
|
||||
st.bold = e.child("b").is_some() && !f.bold;
|
||||
st.italic = e.child("i").is_some() && !f.italic;
|
||||
st.strike = e.child("strike").is_some() && !f.strike;
|
||||
st.underline = e.child("u").is_some() && f.underline == Underline::None;
|
||||
st.size = has_size && size == def.size;
|
||||
st.name = has_name && name == def.name;
|
||||
st.scheme = e.child_val("scheme").map(str::to_string);
|
||||
// A differential font's scheme is in `dxf_stated`.
|
||||
s.font = Font { name, size, minor: s.font.minor, ..f };
|
||||
}
|
||||
if let Some(n) = x.child("numFmt") {
|
||||
if let Some(code) = n.attr("formatCode") {
|
||||
s.num_fmt = NumFmt::new(code);
|
||||
} else if let Some(id) = n.attr_u32("numFmtId") {
|
||||
s.num_fmt = num_fmt_code(id, &fmts);
|
||||
}
|
||||
}
|
||||
if let Some(f) = x.child("fill") {
|
||||
s.fill = read_fill(f, &palette, true);
|
||||
}
|
||||
if let Some(b) = x.child("border") {
|
||||
s.borders = read_border(b, &palette);
|
||||
}
|
||||
if let Some(a) = x.child("alignment") {
|
||||
s.align = read_alignment(a);
|
||||
}
|
||||
if let Some(p) = x.child("protection") {
|
||||
s.protection = read_protection(p);
|
||||
s.dxf_stated.protection = s.protection == Protection::default();
|
||||
}
|
||||
s
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let dxfs = root.child("dxfs").map(|d| d.kids("dxf").map(|x| read_dxf(x, &palette, &fmts)).collect()).unwrap_or_default();
|
||||
StylesIn { xfs, dxfs, palette, named, num_fmts: fmts }
|
||||
}
|
||||
|
||||
/// One differential format (`dxf`): the style it sets over a cell's own.
|
||||
fn read_dxf(x: &El, palette: &[u32], fmts: &HashMap<u32, String>) -> Style {
|
||||
let mut s = Style::default();
|
||||
if let Some(e) = x.child("font") {
|
||||
let f = read_font(e, palette);
|
||||
// Differential fonts rarely name a face; unless stated, keep the default face and size.
|
||||
let has_size = e.child("sz").and_then(|n| n.attr_f64("val")).is_some();
|
||||
let has_name = e.child("name").and_then(|n| n.attr("val")).is_some();
|
||||
let name = if has_name { f.name.clone() } else { s.font.name.clone() };
|
||||
let size = if has_size { f.size } else { s.font.size };
|
||||
let def = Font::default();
|
||||
let st = &mut s.dxf_stated;
|
||||
st.bold = e.child("b").is_some() && !f.bold;
|
||||
st.italic = e.child("i").is_some() && !f.italic;
|
||||
st.strike = e.child("strike").is_some() && !f.strike;
|
||||
st.underline = e.child("u").is_some() && f.underline == Underline::None;
|
||||
st.size = has_size && size == def.size;
|
||||
st.name = has_name && name == def.name;
|
||||
st.scheme = e.child_val("scheme").map(str::to_string);
|
||||
// A differential font's scheme is in `dxf_stated`.
|
||||
s.font = Font { name, size, minor: s.font.minor, ..f };
|
||||
}
|
||||
if let Some(n) = x.child("numFmt") {
|
||||
if let Some(code) = n.attr("formatCode") {
|
||||
s.num_fmt = NumFmt::new(code);
|
||||
} else if let Some(id) = n.attr_u32("numFmtId") {
|
||||
s.num_fmt = num_fmt_code(id, fmts);
|
||||
}
|
||||
}
|
||||
if let Some(f) = x.child("fill") {
|
||||
s.fill = read_fill(f, palette, true);
|
||||
}
|
||||
if let Some(b) = x.child("border") {
|
||||
s.borders = read_border(b, palette);
|
||||
}
|
||||
if let Some(a) = x.child("alignment") {
|
||||
s.align = read_alignment(a);
|
||||
}
|
||||
if let Some(p) = x.child("protection") {
|
||||
s.protection = read_protection(p);
|
||||
s.dxf_stated.protection = s.protection == Protection::default();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- writing
|
||||
|
||||
pub fn color_attrs(c: &Color) -> String {
|
||||
|
||||
@@ -6,7 +6,31 @@ Start the desktop app with a control port:
|
||||
gridcraft --control 7979 # or GRIDCRAFT_CONTROL_PORT=7979
|
||||
```
|
||||
|
||||
Then send newline-delimited JSON to `127.0.0.1:7979`. Every request line gets one reply line:
|
||||
## Authentication
|
||||
|
||||
The control port is reachable by every program on the machine, and by a web page that POSTs
|
||||
`text/plain` to `127.0.0.1:7979`, so every connection must prove it knows the app's token
|
||||
(#777). **There is no way to turn this off.**
|
||||
|
||||
- The app makes a token at start (256 random bits from the operating system, kept in memory) and
|
||||
writes it to a file only you can read: `$XDG_RUNTIME_DIR/gridcraft/control-<port>.token` (else
|
||||
`~/.cache/gridcraft/…`; Windows: `%USERPROFILE%\.gridcraft\…`; the folder is mode 0700 and the
|
||||
file 0600 on Unix). The path is printed on stderr. The token itself is never printed or logged.
|
||||
- Or choose the token yourself: `GRIDCRAFT_CONTROL_TOKEN=…` (preferred) or `--control-token …`
|
||||
(visible in the process list), at least 16 characters. The app then writes no file.
|
||||
- The first line of a connection must be
|
||||
`{"id":0,"method":"auth","params":{"token":"…"}}`; the reply is
|
||||
`{"id":0,"ok":true,"result":{"authenticated":true}}`. Any other first line (a wrong or missing
|
||||
token, another method, text that isn't JSON such as an HTTP request, no line within 10 s, or
|
||||
one longer than 4 KiB) gets `{"ok":false,"error":"authentication required"}` and the
|
||||
connection is closed. After that, a line that isn't JSON also closes the connection.
|
||||
- `gridcraft-cli send`/`mcp --connect` and `gridcraft_mcp::Remote` do this themselves: they use
|
||||
`GRIDCRAFT_CONTROL_TOKEN` if set, else the token file of that port (so they work for the same
|
||||
user on the same machine without setup). Your own client reads the file or the variable.
|
||||
|
||||
## Requests
|
||||
|
||||
Then send newline-delimited JSON to `127.0.0.1:7979` (after the `auth` line). Every request line gets one reply line:
|
||||
|
||||
```json
|
||||
{"id": 1, "method": "engine.execute", "params": {"command": "cell.set", "params": {"cell": "B2", "input": "=SUM(A1:A9)"}}}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ Two backends:
|
||||
| Mode | Command | What it drives |
|
||||
|---|---|---|
|
||||
| Headless (default) | `gridcraft-cli mcp [--in book.xlsx \| --sample budget]` | An in-process engine session — no window. |
|
||||
| Remote | `gridcraft-cli mcp --connect 7979` | A running app started with `gridcraft --control 7979` (TCP JSON lines on `127.0.0.1`). |
|
||||
| Remote | `gridcraft-cli mcp --connect 7979` | A running app started with `gridcraft --control 7979` (TCP JSON lines on `127.0.0.1`, authenticated with the app's token: it is read from the app's token file or `GRIDCRAFT_CONTROL_TOKEN`, see [`control-protocol.md`](control-protocol.md)). |
|
||||
|
||||
## Claude Code
|
||||
|
||||
|
||||
+50
-38
@@ -116,6 +116,55 @@ pub fn catalog_keys(source: &str) -> BTreeSet<String> {
|
||||
keys
|
||||
}
|
||||
|
||||
/// The character a `\u{...}` escape stands for, if it names a valid code point.
|
||||
fn unicode_escape(chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>) -> Option<char> {
|
||||
let mut hex = String::new();
|
||||
for (_, c) in chars.by_ref() {
|
||||
match c {
|
||||
'}' => break,
|
||||
'{' => {}
|
||||
c => hex.push(c),
|
||||
}
|
||||
}
|
||||
u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)
|
||||
}
|
||||
|
||||
/// The value of a string literal whose opening quote was just read, and the byte offset past
|
||||
/// its closing quote (`len`, the source's length, when it is never closed).
|
||||
fn string_literal(chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>, len: usize) -> (String, usize) {
|
||||
let mut value = String::new();
|
||||
let mut end = len;
|
||||
while let Some((j, c)) = chars.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
end = j + 1;
|
||||
break;
|
||||
}
|
||||
'\\' => match chars.next().map(|(_, c)| c) {
|
||||
Some('n') => value.push('\n'),
|
||||
Some('t') => value.push('\t'),
|
||||
Some('r') => value.push('\r'),
|
||||
Some('0') => value.push('\0'),
|
||||
Some(c @ ('\\' | '"' | '\'')) => value.push(c),
|
||||
Some('u') => {
|
||||
if let Some(c) = unicode_escape(chars) {
|
||||
value.push(c);
|
||||
}
|
||||
}
|
||||
// A line continuation skips the newline and the next line's indentation.
|
||||
Some('\n') => {
|
||||
while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
c => value.push(c),
|
||||
}
|
||||
}
|
||||
(value, end)
|
||||
}
|
||||
|
||||
/// Every string literal outside `//` comments: its decoded value, and the byte range from its
|
||||
/// opening quote to just past its closing one.
|
||||
fn literals(source: &str) -> Vec<(String, usize, usize)> {
|
||||
@@ -133,44 +182,7 @@ fn literals(source: &str) -> Vec<(String, usize, usize)> {
|
||||
if c != '"' {
|
||||
continue;
|
||||
}
|
||||
let mut value = String::new();
|
||||
let mut end = source.len();
|
||||
while let Some((j, c)) = chars.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
end = j + 1;
|
||||
break;
|
||||
}
|
||||
'\\' => match chars.next().map(|(_, c)| c) {
|
||||
Some('n') => value.push('\n'),
|
||||
Some('t') => value.push('\t'),
|
||||
Some('r') => value.push('\r'),
|
||||
Some('0') => value.push('\0'),
|
||||
Some(c @ ('\\' | '"' | '\'')) => value.push(c),
|
||||
Some('u') => {
|
||||
let mut hex = String::new();
|
||||
for (_, c) in chars.by_ref() {
|
||||
match c {
|
||||
'}' => break,
|
||||
'{' => {}
|
||||
c => hex.push(c),
|
||||
}
|
||||
}
|
||||
if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
|
||||
value.push(c);
|
||||
}
|
||||
}
|
||||
// A line continuation skips the newline and the next line's indentation.
|
||||
Some('\n') => {
|
||||
while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
c => value.push(c),
|
||||
}
|
||||
}
|
||||
let (value, end) = string_literal(&mut chars, source.len());
|
||||
out.push((value, i, end));
|
||||
}
|
||||
out
|
||||
|
||||
Reference in New Issue
Block a user