Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 66ed962ad1 Merge branch 'main' into fix/sonar-complexity-batch-2b 2026-10-25 22:47:25 -04:00
Dylan M. Taylor 42bbf6baa2 refactor: split complex functions for Sonar S3776 (#2612)
Extract group_bracket from outline layout and move_marks from data
outline_settings. No behaviour change.
2026-10-25 21:39:17 -04:00
Dylan M. Taylor 52cf825395 refactor: split complex functions for Sonar S3776 (#2609)
Extract helpers from auto_sum (selection and single-cell guess), update_cursor
(drag, hover, freeze, and header-edge cursors) and read_styles (dxf reader).
No behaviour change.
2026-10-25 21:37:09 -04:00
Dylan M. Taylor bc881f6c4f refactor: split complex functions for Sonar S3776
Extract helpers from backstage options (general, calculation and iteration
sections), code_editor python (string scanning), xtask i18n literals
(escape and string readers) and calc recalc loops_among (SCC search).
No behaviour change.
2026-10-25 21:31:04 -04:00
Dylan M. Taylor 6f0e43c21c Linux: default to XWayland for file drag-and-drop on Wayland desktops (#165)
* Linux: default to XWayland for file drag-and-drop on Wayland desktops

* Ignore an empty DISPLAY before forcing X11
2026-10-25 19:37:08 -04:00
Dylan M. Taylor 0870b1ad1c Merge pull request #2595 from storytold/chore/deps-safe-refresh
chore(deps): safe dependency refresh
2026-10-25 19:00:54 -04:00
skylar 14dd23db6c chore(deps): refresh serde_json, arboard, muda, winresource, sha2 2026-10-25 16:36:12 -06:00
Dylan M. Taylor ad10881cc3 build: pin craft-update to craft-libs main (495e597) (#2591)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:50:44 -04:00
Dylan M. TaylorandCodebuff ee7cbf32ab build: pin craft-update to craft-libs main (495e597) (#2586)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:15 -04:00
Dylan M. Taylor 2fbbed33bc Merge pull request #783 from vangel76/fix/control-auth-777
Security: the --control server requires an auth token and closes bad connections (#777)
2026-10-25 16:45:46 -04:00
Dylan M. Taylor 1a36d9db07 feat: startup update prompt modal and direct self-updating (#2566)
* feat: startup update prompt modal and direct self-updating

The update flow (check at start or on request, the startup prompt, the self-update worker) is craft-update's Updater; the app only supplies the suite, its close path and its status message. An install holds back while the workbook is unsaved.

Ref: storytold/craft-libs#319

* build: refresh the lock

* build: pin craft-update to craft-libs main (db19c37)

* fix(updates): address review feedback

Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved.

* fix(updates): add Check for Updates… to the native macOS Help menu
2026-10-25 15:37:52 -04:00
skylar taylor-barrick 41769f1d16 Migrate GridCraft to egui 0.37 (#2543)
* E37: migrate GridCraft to egui 0.37

Pin the reviewed fork and shared dependencies, adapt input and rendering to the
new frame lifecycle, and preserve bounded capture handling and font notices.
Add representative accessibility checks and meaningful control labels.

Validation: full repository CI passed all ten stages, with 2,249 tests passed,
zero failed and one existing timing test ignored. Native workflows and paired
performance qualification remain documented separately.

* egui037: record final published pins and local qualification

* M0.37: fix RTL clipping and preserve screenshot budgets

* test: share migrated GridCraft UI harness setup

* egui037: document migrated UI and regression contracts

* egui-037: align popup test fonts and avoid redundant tooltip probes
2026-10-25 14:55:00 -04:00
vangel76 1da640abcc Merge upstream/main into fix/control-auth-777 2026-10-22 19:32:43 +02:00
vangel76andClaude Sonnet 5.5 f1cb4f00f4 Merge upstream/main into fix/control-auth-777
Keep the token check on the first line and the new bounded-line request parsing;
tests authenticate before sending their requests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:03:55 +02:00
vangel76andClaude Sonnet 5.5 392fb29727 Merge upstream/main into fix/control-auth-777
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:08:11 +02:00
vangel76andClaude Sonnet 5.5 fef1f4ddb1 Security: the --control server requires an auth token and closes bad connections (#777)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:25:14 +02:00
29 changed files with 1202 additions and 469 deletions
+1 -1
View File
@@ -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`.
+1 -1
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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"
+2
View File
@@ -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) |
+7
View File
@@ -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"] }
+138 -8
View File
@@ -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);
}
}
+29 -2
View File
@@ -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}"),
}
+11
View File
@@ -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
View File
@@ -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> =
+29 -25
View File
@@ -1454,6 +1454,34 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new
/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost
/// group whose detail is all hidden, or the outermost when none is.
fn move_marks(map: &mut std::collections::BTreeMap<u32, LineInfo>, was_before: bool, on: bool) {
let mut marked: Vec<outline::OutlineGroup> =
outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
}
fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
edit(s, |cx| {
@@ -1469,31 +1497,7 @@ fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
if was_before != on {
continue;
}
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let mut marked: Vec<outline::OutlineGroup> = outline::groups_with(map, was_before)
.into_iter()
.filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed))
.collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on);
}
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
})
+93 -69
View File
@@ -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}()"))));
+32 -3
View File
@@ -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()?;
+135
View File
@@ -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)
}
+1
View File
@@ -15,6 +15,7 @@
#![forbid(unsafe_code)]
mod backend;
pub mod control_auth;
mod headless;
mod server;
mod tools;
+49 -2
View File
@@ -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]
+1
View File
@@ -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 }
+40 -25
View File
@@ -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
+27 -22
View File
@@ -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())) {
+1
View File
@@ -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
View File
@@ -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() {
+32
View File
@@ -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 {
+30 -24
View File
@@ -165,6 +165,35 @@ impl Dir<'_> {
}
/// The band's buttons and brackets for this frame.
/// The bracket for one group's detail, and its tick, on the lane `across`; `button` is the group's
/// summary button when it is on screen.
fn group_bracket(out: &mut Layout, dir: &Dir, grp: &OutlineGroup, button: Option<f32>, across: f32, side: f32) {
let first = dir.first_visible(grp.start, grp.end);
let Some((first_line, (a0, _))) = first else { return };
if grp.summary_before {
// From the button down (or across) to the last detail line, with the tick there.
let a0 = button.map_or(a0, |mid| mid + side / 2.0);
let end = dir.start(grp.end) + dir.size(grp.end);
let a1 = end.min(dir.hi);
if a1 <= a0 {
return;
}
let tick = (end <= dir.hi).then(|| [dir.point(a1 - 0.5, across), dir.point(a1 - 0.5, across + side / 2.0)]);
out.brackets.push(Bracket { line: [dir.point(a0, across), dir.point(a1 - 0.5, across)], tick });
return;
}
let a1 = match button {
Some(mid) => mid - side / 2.0,
None => dir.start(grp.summary()).min(dir.hi),
};
if a1 <= a0 {
return;
}
let start_shown = first_line == grp.start && (a0 - dir.start(grp.start)).abs() < 0.5;
let tick = start_shown.then(|| [dir.point(a0 + 0.5, across), dir.point(a0 + 0.5, across + side / 2.0)]);
out.brackets.push(Bracket { line: [dir.point(a0 + 0.5, across), dir.point(a1, across)], tick });
}
pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
let mut out = Layout::default();
let zoom = (geo.z / DISPLAY_SCALE).max(0.1);
@@ -212,30 +241,7 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
continue;
}
// The bracket, over the part of the detail that is on screen.
let first = dir.first_visible(grp.start, grp.end);
let Some((first_line, (a0, _))) = first else { continue };
if grp.summary_before {
// From the button down (or across) to the last detail line, with the tick there.
let a0 = button.map_or(a0, |mid| mid + side / 2.0);
let end = dir.start(grp.end) + dir.size(grp.end);
let a1 = end.min(dir.hi);
if a1 <= a0 {
continue;
}
let tick = (end <= dir.hi).then(|| [dir.point(a1 - 0.5, across), dir.point(a1 - 0.5, across + side / 2.0)]);
out.brackets.push(Bracket { line: [dir.point(a0, across), dir.point(a1 - 0.5, across)], tick });
continue;
}
let a1 = match button {
Some(mid) => mid - side / 2.0,
None => dir.start(grp.summary()).min(dir.hi),
};
if a1 <= a0 {
continue;
}
let start_shown = first_line == grp.start && (a0 - dir.start(grp.start)).abs() < 0.5;
let tick = start_shown.then(|| [dir.point(a0 + 0.5, across), dir.point(a0 + 0.5, across + side / 2.0)]);
out.brackets.push(Bracket { line: [dir.point(a0 + 0.5, across), dir.point(a1, across)], tick });
group_bracket(&mut out, dir, grp, button, across, side);
}
}
out
+5
View File
@@ -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() {
+69
View File
@@ -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
View File
@@ -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 {
+25 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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