mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
823c56582d | ||
|
|
8ffff19dda | ||
|
|
27932a2ae4 | ||
|
|
f1894c6da6 | ||
|
|
e91e7f7a68 | ||
|
|
bb685e3275 | ||
|
|
fda8e7b703 | ||
|
|
82ee02aac4 | ||
|
|
79e85c1b80 | ||
|
|
e6b6b34970 |
@@ -1,178 +0,0 @@
|
||||
name: Publish Flatpak repository to Pages
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Published GitHub release tag to publish (for example, v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: flatpak-pages-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 60
|
||||
name: Build Flatpak repository
|
||||
# Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped
|
||||
# here, and the validation step below refuses a manual tag with a pre-release suffix.
|
||||
if: github.event_name != 'release' || !github.event.release.prerelease
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
# configure-pages only reads the Pages configuration (base_url); deploy needs write.
|
||||
pages: read
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
APP_ID: ai.storyteller.gridcraft
|
||||
steps:
|
||||
- name: Configure Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
- name: Install Flatpak repository tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends flatpak ostree
|
||||
|
||||
- name: Download and validate release bundles
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="$RELEASE_TAG"
|
||||
if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
|
||||
exit 1
|
||||
fi
|
||||
# A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1).
|
||||
case "${tag%%+*}" in
|
||||
*-*)
|
||||
echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
|
||||
'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')"
|
||||
if [[ "$published_tag" != "$tag" ]]; then
|
||||
echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${tag#v}"
|
||||
mkdir -p bundles
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern SHA256SUMS.txt --dir bundles
|
||||
for arch in x86_64 aarch64; do
|
||||
asset="gridcraft-$version-linux-$arch.flatpak"
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$asset" --dir bundles
|
||||
test -s "bundles/$asset"
|
||||
(
|
||||
cd bundles
|
||||
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
|
||||
SHA256SUMS.txt | sha256sum --check -
|
||||
)
|
||||
done
|
||||
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
|
||||
|
||||
for arch in x86_64 aarch64; do
|
||||
asset="bundles/gridcraft-$version-linux-$arch.flatpak"
|
||||
validation_repo="$(mktemp -d)"
|
||||
ostree --repo="$validation_repo" init --mode=archive-z2
|
||||
flatpak build-import-bundle "$validation_repo" "$asset"
|
||||
expected_ref="app/$APP_ID/$arch/stable"
|
||||
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
|
||||
echo "::error::$(basename "$asset") does not contain $expected_ref"
|
||||
exit 1
|
||||
}
|
||||
rm -rf "$validation_repo"
|
||||
done
|
||||
|
||||
- name: Build repository and site
|
||||
shell: bash
|
||||
env:
|
||||
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
site="$RUNNER_TEMP/site"
|
||||
repo_dir="$site/flatpak/repo"
|
||||
mkdir -p "$repo_dir"
|
||||
ostree --repo="$repo_dir" init --mode=archive-z2
|
||||
|
||||
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
|
||||
if curl --fail --silent --show-error --location \
|
||||
--output /dev/null "$previous_url/summary"; then
|
||||
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
|
||||
ostree --repo="$repo_dir" pull --mirror previous
|
||||
ostree --repo="$repo_dir" remote delete previous
|
||||
fi
|
||||
|
||||
version="${RELEASE_TAG#v}"
|
||||
for arch in x86_64 aarch64; do
|
||||
flatpak build-import-bundle "$repo_dir" \
|
||||
"bundles/gridcraft-$version-linux-$arch.flatpak"
|
||||
done
|
||||
# Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository
|
||||
# (re-mirrored and re-deployed on every release) does not grow without bound.
|
||||
flatpak build-update-repo \
|
||||
--prune --prune-depth=3 \
|
||||
--title="GridCraft" \
|
||||
--comment='Official GridCraft releases' \
|
||||
--description='Official Flatpak repository for GridCraft' \
|
||||
--homepage="https://github.com/$GITHUB_REPOSITORY" \
|
||||
--default-branch=stable \
|
||||
"$repo_dir"
|
||||
|
||||
for arch in x86_64 aarch64; do
|
||||
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
|
||||
done
|
||||
test -s "$repo_dir/summary"
|
||||
|
||||
base_url="${PAGES_BASE_URL%/}"
|
||||
repo_url="$base_url/flatpak/repo/"
|
||||
cat >"$site/flatpak/gridcraft.flatpakrepo" <<META
|
||||
[Flatpak Repo]
|
||||
Title=GridCraft
|
||||
Comment=Official GridCraft releases
|
||||
Description=Official Flatpak repository for GridCraft
|
||||
Url=$repo_url
|
||||
Homepage=https://github.com/$GITHUB_REPOSITORY
|
||||
META
|
||||
cat >"$site/flatpak/index.html" <<HTML
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft Flatpak repository</title></head><body><main><h1>GridCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="gridcraft.flatpakrepo">Add the GridCraft repository</a></p><pre>flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
|
||||
flatpak install gridcraft $APP_ID</pre></main></body></html>
|
||||
HTML
|
||||
cat >"$site/index.html" <<HTML
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft downloads</title></head><body><main><h1>GridCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
|
||||
HTML
|
||||
touch "$site/.nojekyll"
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/site
|
||||
include-hidden-files: true
|
||||
|
||||
deploy:
|
||||
timeout-minutes: 60
|
||||
name: Deploy Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||
@@ -1,95 +1,27 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
# Test builds run only from the `release` branch (a manual run from any other branch is skipped).
|
||||
# Packaging runs (release.yml calls it that way) run from any branch, so a release.yml dry run
|
||||
# from a branch builds the tarball too; they need no secrets.
|
||||
#
|
||||
# With `package: true` (release.yml calls it that way, or a manual run) it instead builds the
|
||||
# release binaries and uploads `gridcraft-<version>-freebsd-x86_64.tar.gz` as the
|
||||
# `release-freebsd` artifact (packaging/freebsd/package.sh), which release.yml attaches to the
|
||||
# draft GitHub Release.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Build the release tarball instead of running the tests"
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version for the artifact name (empty: Cargo.toml)"
|
||||
type: string
|
||||
default: ""
|
||||
version_override:
|
||||
description: "Also write the version into Cargo.toml before building (test runs)"
|
||||
type: boolean
|
||||
default: false
|
||||
package:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
# Called from release.yml, github.workflow is the caller's name; keep the groups apart.
|
||||
group: freebsd-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.workflow == 'FreeBSD' }}
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }})
|
||||
if: github.ref == 'refs/heads/release' || inputs.package
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
env:
|
||||
GRIDCRAFT_VERSION: ${{ inputs.version }}
|
||||
GRIDCRAFT_VERSION_OVERRIDE: ${{ inputs.version_override && '1' || '' }}
|
||||
GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }}
|
||||
GRIDCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- name: Build in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
envs: "GRIDCRAFT_VERSION GRIDCRAFT_VERSION_OVERRIDE GRIDCRAFT_PACKAGE GRIDCRAFT_BUILD_SHA"
|
||||
# Only the packaging run brings files back (dist/release). The target dir lives outside
|
||||
# the synced checkout, because copying it out of the VM fails on large builds.
|
||||
copyback: ${{ inputs.package }}
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
export CARGO_TARGET_DIR=/var/tmp/gridcraft-target CARGO_PROFILE_DEV_DEBUG=0
|
||||
rustc --version
|
||||
if [ -n "$GRIDCRAFT_PACKAGE" ]; then
|
||||
if [ -n "$GRIDCRAFT_VERSION_OVERRIDE" ]; then
|
||||
cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION"
|
||||
fi
|
||||
CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 bash packaging/freebsd/package.sh
|
||||
else
|
||||
cargo build --workspace --all-targets --locked
|
||||
CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked
|
||||
fi
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: inputs.package
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
Generated
+16
-16
@@ -1496,7 +1496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-print",
|
||||
@@ -1518,7 +1518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1530,7 +1530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -1541,7 +1541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -1553,7 +1553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
@@ -1563,7 +1563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
@@ -1584,7 +1584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -1593,7 +1593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -1601,7 +1601,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -1610,7 +1610,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1622,18 +1622,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1655,7 +1655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1672,7 +1672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
@@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Localhost JSON-lines control server. Each connection may send many requests; each line
|
||||
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
|
||||
//!
|
||||
//! 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, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
@@ -13,6 +17,13 @@ const MAX_LINE: usize = 64 * 1024 * 1024;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let rx = serve_listener(listener, 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>> {
|
||||
let (tx, rx) = channel();
|
||||
std::thread::Builder::new().name("control".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
@@ -21,47 +32,184 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlR
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
})?;
|
||||
log::info!("control channel listening on 127.0.0.1:{port}");
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request too large (over {max} bytes); closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// One request line, parsed.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Json,
|
||||
method: String,
|
||||
params: Json,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
if is_http_request_line(text) {
|
||||
return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
|
||||
}
|
||||
let msg = match serde_json::from_str::<Json>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("invalid JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Json::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Json::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
|
||||
fn is_http_request_line(text: &str) -> bool {
|
||||
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
|
||||
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let reader = BufReader::new(read);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line.len() > MAX_LINE {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": "request too large"}));
|
||||
continue;
|
||||
}
|
||||
let msg: Json = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")}));
|
||||
continue;
|
||||
}
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => break,
|
||||
// The peer is gone before sending a line: nobody to tell.
|
||||
Err(_) if buf.is_empty() => break,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let (req, rx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
// Wake the UI thread: egui only runs frames when something happens.
|
||||
ctx.request_repaint();
|
||||
let mut reply = rx
|
||||
.recv_timeout(Duration::from_secs(60))
|
||||
.unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"}));
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
let id = msg.get("id").cloned().unwrap_or(Json::Null);
|
||||
let method = msg.get("method").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
// The connection closes either way; a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Send `input` on a new connection and read reply lines 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(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()
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
/// line in its body is read, so nothing is dispatched.
|
||||
#[test]
|
||||
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
|
||||
let (addr, rx) = serve_on_loopback();
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"x\"}}}\n";
|
||||
let replies = exchange(addr, http);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
|
||||
assert!(rx.try_recv().is_err(), "the body was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_request_closes_the_connection() {
|
||||
let (addr, rx) = serve_on_loopback();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true}));
|
||||
}
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
for id in [1, 2] {
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Json>(&line).unwrap()["id"], id, "{line}");
|
||||
}
|
||||
// Wake the UI thread: egui only runs frames when something happens.
|
||||
ctx.request_repaint();
|
||||
let mut reply = rx
|
||||
.recv_timeout(Duration::from_secs(60))
|
||||
.unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"}));
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
|
||||
let mut input = junk.to_vec();
|
||||
input.extend_from_slice(b"{\"id\":2,\"method\":\"b\"}\n");
|
||||
let replies = exchange(addr, &input);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
}
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
|
||||
assert!(is_http_request_line("PRI * HTTP/2.0"));
|
||||
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_line_is_refused_without_reading_the_rest() {
|
||||
let mut buf = Vec::new();
|
||||
let long = [b'x'; 100];
|
||||
let mut input: &[u8] = &long;
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1246,6 +1246,9 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
};
|
||||
let cells: Vec<(CellRef, Cell)> =
|
||||
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
|
||||
// Report every stored cell we touch, even beyond a million coordinates in a sparse
|
||||
// selection. Removed formula anchors must also reach calc so their spills disappear.
|
||||
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
|
||||
let mut cells = cells;
|
||||
if what == "contents" {
|
||||
for (_, cell) in cells.iter_mut() {
|
||||
@@ -1284,10 +1287,24 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
sh.hyperlinks.retain(|c, _| !r.contains(*c));
|
||||
}
|
||||
if matches!(what, "all" | "formats") {
|
||||
sh.merges.retain(|m| !r.contains_range(m));
|
||||
}
|
||||
for c in r.iter().take(1_000_000) {
|
||||
cx.changed.push((sheet, c));
|
||||
let mut unmerged = false;
|
||||
sh.merges.retain(|m| {
|
||||
let keep = !r.contains_range(m);
|
||||
unmerged |= !keep;
|
||||
keep
|
||||
});
|
||||
if unmerged {
|
||||
// An empty merge can block a spill without storing any cells in this range.
|
||||
// As in Unmerge, retry blocked formula anchors that could reach the freed area.
|
||||
let reach = RangeRef::new(CellRef::new(0, 0), r.end);
|
||||
let blocked: Vec<_> = sh
|
||||
.cells
|
||||
.iter_range(reach)
|
||||
.filter(|(_, cell)| cell.formula.is_some() && cell.value == Value::Error(gridcraft_core::CellError::Spill))
|
||||
.map(|(c, _)| (sheet, c))
|
||||
.collect();
|
||||
cx.changed.extend(blocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json::Null)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Clearing a large sparse selection must invalidate every changed cell (issue #1032).
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn value(s: &Session, address: &str) -> Value {
|
||||
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(address).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_clear_updates_distant_direct_and_transitive_dependents() {
|
||||
for command in ["edit.clearContents", "edit.clearAll"] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for (cell, input) in [("A1000001", "7"), ("B1", "=A1000001"), ("C1", "=B1*2")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0)));
|
||||
|
||||
// B1 stretches the used range to row 1; the changed source lies just past the old cap.
|
||||
s.execute(command, json!({"range": "A1:A1000001"})).unwrap();
|
||||
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}");
|
||||
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command}");
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(value(&s, "A1000001"), Value::Number(7.0), "{command} undo");
|
||||
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0)), "{command} undo");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command} redo");
|
||||
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command} redo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_clear_removes_a_distant_formulas_spill_and_updates_its_dependent() {
|
||||
for command in ["edit.clearContents", "edit.clearAll"] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1000001", "input": "=SEQUENCE(2)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1000002"})).unwrap();
|
||||
assert_eq!((value(&s, "A1000002"), value(&s, "B1")), (Value::Number(2.0), Value::Number(2.0)));
|
||||
|
||||
// The spill lies outside the selection, but must disappear when its anchor is cleared.
|
||||
s.execute(command, json!({"range": "A1:A1000001"})).unwrap();
|
||||
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}");
|
||||
assert_eq!(value(&s, "A1000002"), Value::Empty, "{command}");
|
||||
assert_eq!(value(&s, "B1"), Value::Number(0.0), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_an_empty_merge_unblocks_a_spill() {
|
||||
for command in ["edit.clearFormats", "edit.clearAll"] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
// Keep the empty merged area inside the used range after the spill becomes blocked.
|
||||
s.execute("cell.set", json!({"cell": "C4", "input": "1"})).unwrap();
|
||||
s.execute("home.mergeCells", json!({"range": "A2:B3"})).unwrap();
|
||||
assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill));
|
||||
|
||||
s.execute(command, json!({"range": "A2:B3"})).unwrap();
|
||||
assert_eq!((value(&s, "A1"), value(&s, "A3")), (Value::Number(1.0), Value::Number(3.0)), "{command}");
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().merges.is_empty(), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_distant_styled_blocker_unblocks_a_spill() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(100)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A90", "input": "x"})).unwrap();
|
||||
s.execute("home.bold", json!({"range": "A90", "on": true})).unwrap();
|
||||
assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill));
|
||||
|
||||
// The cleared blocker stays stored for its style and lies beyond the local 64-row scan.
|
||||
s.execute("edit.clearContents", json!({"range": "A2:A100"})).unwrap();
|
||||
assert_eq!(value(&s, "A1"), Value::Number(1.0));
|
||||
assert_eq!(value(&s, "A90"), Value::Number(90.0));
|
||||
assert_eq!(value(&s, "A100"), Value::Number(100.0));
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let cell = wb.active().unwrap().cell(CellRef::parse("A90").unwrap()).unwrap();
|
||||
assert_eq!(cell.value, Value::Empty);
|
||||
assert!(wb.styles.get(cell.style).font.bold);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ pub mod printer;
|
||||
|
||||
pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp};
|
||||
pub use input::{FormulaLocale, parse_input, print_input};
|
||||
pub use parser::{ParseError, normalize_function_name, parse};
|
||||
pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse};
|
||||
pub use printer::{print, print_r1c1, quote_sheet};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -17,6 +17,11 @@ pub struct ParseError {
|
||||
|
||||
pub(crate) const MAX_DEPTH: usize = 256;
|
||||
|
||||
/// Longest formula accepted, in characters: Excel's limit for formula contents. It also bounds
|
||||
/// the depth of the trees long operator chains build (`1+1+…`), which dropping and evaluating
|
||||
/// walk recursively; without it a formula of a few hundred kilobytes in a file overflows the stack.
|
||||
pub const MAX_FORMULA_LEN: usize = 8_192;
|
||||
|
||||
/// Parses formula text. A leading `=` (or `+`/`-` as Lotus users type) is accepted and skipped
|
||||
/// for `=`.
|
||||
pub fn parse(src: &str) -> Result<Expr, ParseError> {
|
||||
@@ -25,6 +30,9 @@ pub fn parse(src: &str) -> Result<Expr, ParseError> {
|
||||
|
||||
pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result<Expr, ParseError> {
|
||||
let body = src.strip_prefix('=').unwrap_or(src);
|
||||
if body.len() > MAX_FORMULA_LEN && body.chars().count() > MAX_FORMULA_LEN {
|
||||
return Err(ParseError { msg: format!("formula is longer than {MAX_FORMULA_LEN} characters"), pos: 0 });
|
||||
}
|
||||
let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
|
||||
let toks = significant_spaces(toks);
|
||||
let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es };
|
||||
@@ -160,11 +168,18 @@ impl Parser {
|
||||
return Err(self.err("formula is nested too deeply"));
|
||||
}
|
||||
let mut lhs = self.prefix(allow_union)?;
|
||||
// Postfix operators (`%`, invoking a result) wrap `lhs` without recursing: they count
|
||||
// towards the nesting limit, so `1%%%…` can't build a tree deeper than it.
|
||||
let mut postfix = 0;
|
||||
loop {
|
||||
if *self.peek() == Tok::Op("%") {
|
||||
if 70 < min_bp {
|
||||
break;
|
||||
}
|
||||
postfix += 1;
|
||||
if self.depth + postfix > MAX_DEPTH {
|
||||
return Err(self.err("formula is nested too deeply"));
|
||||
}
|
||||
self.next();
|
||||
lhs = Expr::Unary(UnOp::Percent, Box::new(lhs));
|
||||
continue;
|
||||
@@ -174,6 +189,10 @@ impl Parser {
|
||||
if let Expr::Name(_) = lhs {
|
||||
// A name followed by '(' was lexed as Func; this is `(name)(…)` only.
|
||||
}
|
||||
postfix += 1;
|
||||
if self.depth + postfix > MAX_DEPTH {
|
||||
return Err(self.err("formula is nested too deeply"));
|
||||
}
|
||||
self.next();
|
||||
let args = self.args()?;
|
||||
lhs = Expr::Invoke(Box::new(lhs), args);
|
||||
|
||||
@@ -366,3 +366,38 @@ fn prints_long_flat_chain_without_deep_recursion() {
|
||||
assert_eq!(text, vec!["1"; 20001].join("+"));
|
||||
assert_eq!(r1c1, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_formulas_are_errors_not_stack_overflows() {
|
||||
// A small stack: parsing must fail cleanly instead of building trees deeper than the nesting
|
||||
// limit, which dropping, printing and evaluating walk recursively.
|
||||
let handle = std::thread::Builder::new()
|
||||
.stack_size(512 * 1024)
|
||||
.spawn(|| {
|
||||
let percents = format!("1{}", "%".repeat(1_000));
|
||||
let invokes = format!("LAMBDA(x,x)(1){}", "(1)".repeat(1_000));
|
||||
let long_chain = format!("1{}", "+1".repeat(100_000));
|
||||
let huge_percents = format!("1{}", "%".repeat(1_000_000));
|
||||
(parse(&percents).is_err(), parse(&invokes).is_err(), parse(&long_chain).is_err(), parse(&huge_percents).is_err())
|
||||
})
|
||||
.expect("spawn");
|
||||
assert_eq!(handle.join().expect("parsing must not overflow the stack"), (true, true, true, true));
|
||||
// Within the limits these still parse.
|
||||
assert!(parse(&format!("1{}", "%".repeat(100))).is_ok());
|
||||
assert!(parse("LAMBDA(x,x)(1)(2)").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_longer_than_excel_allows_are_refused() {
|
||||
let at_limit = format!("1{}", "+1".repeat((crate::MAX_FORMULA_LEN - 1) / 2));
|
||||
assert_eq!(at_limit.chars().count(), crate::MAX_FORMULA_LEN - 1);
|
||||
assert!(parse(&at_limit).is_ok());
|
||||
assert!(parse(&format!("={at_limit}")).is_ok(), "the leading = does not count");
|
||||
let over = format!("{at_limit}+1");
|
||||
let err = parse(&over).unwrap_err();
|
||||
assert!(err.msg.contains("8192"), "{}", err.msg);
|
||||
// Characters, not bytes: 8,000 two-byte characters in a text literal are within the limit.
|
||||
let text = format!("\"{}\"", "é".repeat(8_000));
|
||||
assert!(text.len() > crate::MAX_FORMULA_LEN);
|
||||
assert!(parse(&text).is_ok());
|
||||
}
|
||||
|
||||
@@ -2573,7 +2573,8 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
||||
resp.request_focus();
|
||||
}
|
||||
let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f)));
|
||||
if editing || app.dialog.is_some() || other_focus || app.message.is_some() {
|
||||
// An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516).
|
||||
if editing || app.dialog.is_some() || other_focus || app.message.is_some() || app.grid.filter_menu.is_some() {
|
||||
return;
|
||||
}
|
||||
if ctx.memory(|m| m.focused()).is_none() {
|
||||
@@ -2768,16 +2769,24 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
||||
let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col);
|
||||
let key = egui::Id::new(("filter_sel", col));
|
||||
let search_key = egui::Id::new(("filter_search", col));
|
||||
let buttons_key = egui::Id::new(("filter_buttons", col));
|
||||
// Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its
|
||||
// focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click.
|
||||
let buttons: Vec<egui::Id> = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default();
|
||||
let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f));
|
||||
let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
|
||||
let response = area.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_width(230.0);
|
||||
if ui.button("↑ Sort Ascending").clicked() {
|
||||
let ascending = ui.button("↑ Sort Ascending");
|
||||
if ascending.clicked() {
|
||||
let _ = app.run("data.sortAscending", json!({"column": col_to_letters(col)}));
|
||||
close = true;
|
||||
}
|
||||
if ui.button("↓ Sort Descending").clicked() {
|
||||
let descending = ui.button("↓ Sort Descending");
|
||||
if descending.clicked() {
|
||||
let _ = app.run("data.sortDescending", json!({"column": col_to_letters(col)}));
|
||||
close = true;
|
||||
}
|
||||
@@ -2814,11 +2823,16 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
||||
let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect();
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Clear Filter").clicked() {
|
||||
let clear = ui.button("Clear Filter");
|
||||
if clear.clicked() {
|
||||
let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true}));
|
||||
close = true;
|
||||
}
|
||||
if ui.button("Apply").clicked() {
|
||||
// Nothing checked would hide every row, so Enter (unlike a click on Apply) does nothing then.
|
||||
let any = checks.iter().any(|(_, on)| *on);
|
||||
let apply = ui.button("Apply");
|
||||
ui.ctx().data_mut(|d| d.insert_temp(buttons_key, vec![ascending.id, descending.id, clear.id, apply.id]));
|
||||
if apply.clicked() || (enter && any) {
|
||||
let blanks = checks.iter().any(|(v, on)| v == "(Blanks)" && *on);
|
||||
let vals: Vec<String> = checks.iter().filter(|(v, on)| *on && v != "(Blanks)").map(|(v, _)| v.clone()).collect();
|
||||
let p = if checks.iter().all(|(_, b)| *b) {
|
||||
@@ -2836,6 +2850,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.remove::<Vec<(String, bool)>>(key);
|
||||
d.remove::<String>(search_key);
|
||||
d.remove::<Vec<egui::Id>>(buttons_key);
|
||||
});
|
||||
app.grid.filter_menu = None;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Enter in the AutoFilter menu works like its Apply button, as Excel's OK (issue #1516): from the
|
||||
//! search box or with a value focused it applies the filter and closes the menu, and the key never
|
||||
//! reaches the grid underneath.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
/// A1:A4 holds a header and three fruits under an AutoFilter, with the menu opened by Alt+Down on A1.
|
||||
fn filter_menu_open() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["Fruit"], ["Apple"], ["Banana"], ["Cherry"]]})).unwrap();
|
||||
session.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
key(&mut h, Key::ArrowDown, Modifiers::ALT);
|
||||
assert!(h.state().grid.filter_menu.is_some(), "Alt+Down opens the filter menu");
|
||||
h
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness, k: Key, m: Modifiers) {
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m });
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Rows 2..=4 (Apple, Banana, Cherry): which ones the filter hides.
|
||||
fn hidden(h: &Harness) -> Vec<bool> {
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
(1..=3).map(|r| sh.is_row_hidden(r)).collect()
|
||||
}
|
||||
|
||||
fn active(h: &Harness) -> String {
|
||||
h.state().session.active().unwrap().selection.active.a1()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_in_the_search_box_applies_the_filter() {
|
||||
let mut h = filter_menu_open();
|
||||
h.input_mut().events.push(Event::Text("an".into()));
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_eq!(hidden(&h), [true, false, true], "only Banana matches the search");
|
||||
assert!(h.state().grid.filter_menu.is_none(), "Enter closes the menu");
|
||||
assert_eq!(active(&h), "A1", "Enter must not move the active cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_a_value_clicked_applies_the_filter() {
|
||||
let mut h = filter_menu_open();
|
||||
let apple = h.get_by_label("Apple").rect().center();
|
||||
h.input_mut().events.push(Event::PointerMoved(apple));
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: apple, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert!(h.state().grid.filter_menu.is_some(), "clicking a value keeps the menu open");
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
// Enter applies the list as it is: it must not toggle the value back on.
|
||||
assert_eq!(hidden(&h), [true, false, false], "Apple was unchecked");
|
||||
assert!(h.state().grid.filter_menu.is_none(), "Enter closes the menu");
|
||||
assert_eq!(active(&h), "A1", "Enter must not move the active cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_nothing_checked_keeps_the_menu_open() {
|
||||
let mut h = filter_menu_open();
|
||||
// A search that matches nothing leaves no value to show, as Excel's OK is then disabled.
|
||||
h.input_mut().events.push(Event::Text("Bananna".into()));
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_eq!(hidden(&h), [false, false, false], "an empty selection must not hide every row");
|
||||
assert!(h.state().grid.filter_menu.is_some(), "the menu stays open");
|
||||
assert_eq!(active(&h), "A1");
|
||||
}
|
||||
@@ -227,3 +227,32 @@ fn hyperlinks_past_the_cell_cap_are_reported() {
|
||||
assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 3);
|
||||
assert!(rep.warnings.is_empty(), "{:?}", rep.warnings);
|
||||
}
|
||||
|
||||
/// Formula text built to overflow a recursive parser or tree walk (a million `%`, a 200,000-term
|
||||
/// sum): the cells keep their formula text and cached value, nothing overflows the stack.
|
||||
#[test]
|
||||
fn hostile_formula_text_does_not_overflow_the_stack() {
|
||||
let percents = format!("1{}", "%".repeat(1_000_000));
|
||||
let chain = format!("1{}", "+1".repeat(100_000));
|
||||
let body = format!(r#"<sheetData><row r="1"><c r="A1"><f>{percents}</f><v>3</v></c><c r="B1"><f>{chain}</f><v>4</v></c></row></sheetData>"#);
|
||||
let names = format!(r#"<definedNames><definedName name="Deep">{percents}</definedName></definedNames>"#);
|
||||
let bytes = minimal(&body, &[], "", &names);
|
||||
let handle = std::thread::Builder::new()
|
||||
.stack_size(1024 * 1024)
|
||||
.spawn(move || {
|
||||
let (wb, _) = read_xlsx(&bytes).expect("readable");
|
||||
let s = wb.sheet(0).expect("sheet");
|
||||
let a1 = s.cell(gridcraft_core::CellRef::new(0, 0)).and_then(|c| c.formula.clone()).expect("formula kept");
|
||||
let b1 = s.cell(gridcraft_core::CellRef::new(0, 1)).and_then(|c| c.formula.clone()).expect("formula kept");
|
||||
let kept = (a1.expr.is_none(), b1.expr.is_none(), a1.text.len(), s.value(gridcraft_core::CellRef::new(0, 0)));
|
||||
assert_eq!(wb.names.first().map(|n| n.formula.len()), Some(1_000_001));
|
||||
let out = write_xlsx(&wb).expect("writable");
|
||||
drop(wb);
|
||||
(kept, !out.is_empty())
|
||||
})
|
||||
.expect("spawn");
|
||||
let ((a_unparsed, b_unparsed, a_len, a_value), written) = handle.join().expect("no stack overflow");
|
||||
assert!(a_unparsed && b_unparsed && written);
|
||||
assert_eq!(a_len, 1_000_001);
|
||||
assert_eq!(a_value, gridcraft_core::Value::Number(3.0));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ Then send newline-delimited JSON to `127.0.0.1:7979`. Every request line gets on
|
||||
|
||||
Errors come back as `{"id": …, "ok": false, "error": "…"}`.
|
||||
|
||||
A line that is not a JSON object with a string `method` (an HTTP request, text that isn't JSON,
|
||||
bytes that aren't UTF-8, a line over 64 MiB) gets one error reply and the server closes the
|
||||
connection; nothing sent after it runs. Blank lines are skipped.
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Params | What it does |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (XLSX chart sheets are kept on save (#727); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
|
||||
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
|
||||
@@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped |
|
||||
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
|
||||
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
|
||||
| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Function parity: worksheet functions and formulas
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (clearing large sparse ranges recalculates every changed cell's dependents, #1032) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
|
||||
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
|
||||
@@ -76,6 +76,7 @@ PIVOTBY, REGEXTEST…) are all present.
|
||||
| Structured references (`Table1[Col]`, `[#Totals]`, `@`; the unbracketed `[[#This Row],Col]` some writers emit) | done |
|
||||
| Defined names (workbook and sheet scope), Create from Selection | done |
|
||||
| Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) |
|
||||
| Recalculation after clearing large sparse ranges | every stored cell changed by Clear is reported to recalculation, without a million-coordinate cutoff; removed formula spills are cleared, and removing empty merges still unblocks spills (#1032) |
|
||||
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
|
||||
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
|
||||
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done |
|
||||
@@ -108,6 +109,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) |
|
||||
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
|
||||
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
|
||||
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
|
||||
|
||||
Reference in New Issue
Block a user