Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor cb178bb18d address review: drop the legacy-batch reply that crosses the output budget; budget batch results as escaped text 2026-11-10 21:41:02 -05:00
Dylan M. Taylor 77780d19db fix(mcp): stop a batch once its kept results pass an output budget 2026-11-10 21:38:32 -05:00
Dylan M. Taylor 92e0e67f3a Clipboard: size-check a Wayland clipboard picture before anything decodes it (#5441)
* fix(clipboard): size-check a Wayland clipboard picture before it is decoded

* address review: refuse a clipboard PNG the renderer cannot decode within its budget
2026-11-10 21:33:46 -05:00
Dylan M. Taylor a34c94e4c1 packaging: use [[ for bash tests (SonarCloud S7688) (#6224)
Behaviour-preserving: `[[ ]]` in place of `[ ]` for bash tests (SonarCloud shell:S7688). Every script has a bash shebang; no test uses `-a`/`-o`, escaped parentheses or an unquoted pattern on the right of `=`. `bash -n` passes on each script; the rest is left to CI.

- `packaging/freebsd/package.sh`
2026-11-10 18:45:02 -05:00
FredApps a42c9eaab3 Merge pull request #6187 from storytold/sonar/format-mandated-sha1
sonar: SHA-1 in ODT decryption is the format's, not a choice
2026-11-10 18:43:18 +01:00
FredApps 12869b8e02 Merge branch 'main' into sonar/format-mandated-sha1 2026-11-10 18:23:24 +01:00
8548b33be1 MacOS: the window moves by its title bar behind a modal dialog (#5124) (#5125)
* #5124: the window moves by its title bar behind a modal dialog

On macOS WordCraft draws its own title bar, and since modal dialogs
(#3859, #4076) the dialog's backdrop covers it, so the window couldn't be
dragged while a dialog was open. Word for Mac lets you drag the document
window by its title bar behind its Font dialog (and beeps on the click).

- The title bar (and the Backstage's top band on macOS) notes the part
  that drags the window each frame. When the modal backdrop's drag starts
  there, it moves the window; the press still shakes the dialog, as Word
  beeps.
- Only the backdrop's own drag counts, so a drag on the dialog, its tabs
  or its popups, where it reaches over the title bar, moves only the
  dialog.

Tested by the developer on a Mac.

Fixes #5124.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: only the macOS title bar moves the window behind a dialog

Review (CodeRabbit):
- Elsewhere the system's title bar already moves the window; WordCraft's
  own bar under it now doesn't, behind a modal dialog either, so nothing
  changes on Linux and Windows.
- A drag from just inside the dialog's edge, where it reaches over the
  title bar, moves only the dialog, as the nudge already tells it apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: the window moves by its title bar behind Colors too; modal_open tests

Colors is an egui Modal, not a window with the backdrop of block_behind, so
a drag on the macOS title bar did nothing behind it. It now shares the check
with the other modal windows.

The new tests pin modal_open with nothing open, a modeless dialog, a modal
dialog, the file browser and Colors, and that the title bar moves the window
in each of those while the dialog stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: the modal drag test covers the top of File's side menu

On macOS File's side menu covers the title bar, and its top band moves the
window. The test now drags there too, so losing the band's note_title_drag
call fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-10 14:04:45 +01:00
FredApps abd5a6e957 sonar: cite the ODF section the SHA-1 algorithms come from 2026-11-10 11:21:57 +01:00
FredApps 8dac5c7802 sonar: SHA-1 in ODT decryption is the format's, not a choice 2026-11-10 11:21:35 +01:00
16 changed files with 368 additions and 15 deletions
+6 -1
View File
File diff suppressed because one or more lines are too long
+23 -2
View File
@@ -108,8 +108,8 @@ pub fn formats() -> Vec<String> {
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
/// from the header before anything is decoded (#3623).
/// why (#833). The UI checks the sizes before inserting anything; on Windows and Wayland they are
/// checked from the header before anything is decoded (#3623).
pub fn picture() -> Option<ClipboardPicture> {
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
@@ -136,6 +136,10 @@ fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
#[cfg(not(target_os = "windows"))]
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
if let Some(picture) = platform::picture() {
return Some(picture);
}
match clipboard.get_image() {
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
@@ -160,6 +164,7 @@ mod platform {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
fn wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some()
@@ -364,6 +369,22 @@ mod platform {
assert!(complete(0, 0, 0));
}
}
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
/// (or isn't reachable): arboard is asked then, which also covers X11.
pub fn picture() -> Option<ClipboardPicture> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
})
}
}
#[cfg(target_os = "windows")]
+28 -1
View File
@@ -136,7 +136,34 @@ impl Server {
if batch.is_empty() {
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
} else {
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
// Held to the same output budget as the `batch` tool: the rest of the batch is
// refused once the replies pass it.
let mut replies: Vec<Value> = Vec::new();
let mut size = 0usize;
for m in batch {
if size > crate::tools::MAX_BATCH_OUTPUT {
// Notifications get no reply, run or not.
if let Some(id) = m.get("id").cloned() {
replies.push(error(id, INTERNAL_ERROR, "the batch's replies passed the output limit; not run"));
}
continue;
}
let id = m.get("id").cloned();
if let Some(r) = self.handle(m) {
size = size.saturating_add(crate::tools::json_len(&r));
if size > crate::tools::MAX_BATCH_OUTPUT {
// This reply is the one that crosses the limit: it is not kept.
let id = id.unwrap_or(Value::Null);
replies.push(error(
id,
INTERNAL_ERROR,
"the batch's replies passed the output limit; this reply and the rest are left out",
));
} else {
replies.push(r);
}
}
}
(!replies.is_empty()).then_some(Value::Array(replies))
}
}
+29
View File
@@ -452,3 +452,32 @@ fn invalid_utf8_does_not_change_arguments_or_stop_later_requests() {
assert_eq!(replies[1]["result"]["isError"], false);
assert_eq!(tool(&mut s, "get_text", json!({}))["text"], "good", "malformed text must never reach the backend");
}
/// A backend whose every result is 1 MiB of text.
struct Big;
impl crate::Backend for Big {
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
Ok(json!("x".repeat(1 << 20)))
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"big".into()
}
}
#[test]
fn a_legacy_batch_does_not_keep_the_reply_that_crosses_the_output_budget() {
let n = (crate::tools::MAX_BATCH_OUTPUT >> 20) + 5;
let one =
|i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "tools/call", "params": {"name": "execute", "arguments": {"command": "edit.copy"}}});
let line = Value::Array((0..n).map(one).collect()).to_string();
let mut s = Server::new(Box::new(Big));
let out = s.handle_line(&line).expect("reply");
assert!(out.len() <= crate::tools::MAX_BATCH_OUTPUT + (1 << 16), "{}", out.len());
let replies: Vec<Value> = serde_json::from_str(&out).unwrap();
assert_eq!(replies.len(), n);
assert!(replies.iter().any(|r| r["error"]["message"].as_str().is_some_and(|m| m.contains("left out"))));
}
+98 -4
View File
@@ -216,6 +216,26 @@ fn with_active_id(b: &mut dyn Backend, r: Result<Value, String>) -> Result<Value
Ok(v)
}
/// Most bytes of results one `batch` (or one legacy JSON-RPC batch) keeps for its reply.
pub const MAX_BATCH_OUTPUT: usize = 64 << 20;
/// Length of `v` written as JSON, counted without keeping the text.
pub(crate) fn json_len(v: &Value) -> usize {
struct Count(usize);
impl std::io::Write for Count {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0 = self.0.saturating_add(b.len());
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut n = Count(0);
let _ = serde_json::to_writer(&mut n, v);
n.0
}
fn wrap(r: Result<Value, String>) -> ToolResult {
match r {
Ok(v) => ToolResult::json(&v),
@@ -267,15 +287,27 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
"batch" => {
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
let mut out = Vec::new();
// What the results hold so far: past MAX_BATCH_OUTPUT the batch stops, rather than
// keeping copies of a document command after command until the reply is written.
let mut size = 0usize;
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({})), doc);
let failed = r.is_err();
out.push(match r {
let mut failed = r.is_err();
let entry = match r {
Ok(v) => json!({"command": id, "ok": true, "result": v}),
Err(e) => json!({"command": id, "ok": false, "error": e}),
});
if failed && !keep {
};
// Counted as the reply stores it: the array is text content, so its quotes and
// backslashes are escaped again.
size = size.saturating_add(json_len(&Value::String(entry.to_string())));
if size > MAX_BATCH_OUTPUT {
out.push(json!({"command": id, "ok": false, "error": format!("the batch's results passed {MAX_BATCH_OUTPUT} bytes; this command's result and the commands after it are left out")}));
failed = true;
} else {
out.push(entry);
}
if failed && (!keep || size > MAX_BATCH_OUTPUT) {
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
}
@@ -410,6 +442,68 @@ mod tests {
assert!(b.seen.take().expect("called").1.get("doc").is_none());
}
/// A backend whose every result is 1 MiB of text (a whole document, say).
struct Big {
calls: usize,
}
impl Backend for Big {
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
self.calls += 1;
Ok(json!("x".repeat(1 << 20)))
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"big".into()
}
}
/// A backend whose every result is 1 MiB of quotes, which the reply escapes twice.
struct Quoted {
calls: usize,
}
impl Backend for Quoted {
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
self.calls += 1;
Ok(json!("\"".repeat(1 << 20)))
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"quoted".into()
}
}
#[test]
fn a_batch_budgets_the_escaped_text_it_returns() {
// Each result is 2 MiB as JSON and 4 MiB once the array is stored as text.
let commands: Vec<Value> = (0..40).map(|_| json!({"command": "edit.copy"})).collect();
let mut b = Quoted { calls: 0 };
let r = call_tool(&mut b, "batch", &json!({"commands": commands, "keepGoing": true}));
assert!(r.is_error);
assert!(b.calls <= (MAX_BATCH_OUTPUT >> 22) + 1, "{} commands ran", b.calls);
}
#[test]
fn a_batch_stops_once_its_results_pass_the_output_budget() {
// 200 commands of 1 MiB results each kept 200 MiB until the reply; it stops at 64 MiB.
let commands: Vec<Value> = (0..200).map(|_| json!({"command": "edit.copy"})).collect();
let mut b = Big { calls: 0 };
let r = call_tool(&mut b, "batch", &json!({"commands": commands, "keepGoing": true}));
assert!(r.is_error);
assert!(b.calls <= (MAX_BATCH_OUTPUT >> 20) + 1, "{} commands ran", b.calls);
let text = r.content[0]["text"].as_str().unwrap_or_default();
assert!(text.len() <= MAX_BATCH_OUTPUT + 1024, "{}", text.len());
assert!(text.contains("left out"));
// A small batch is untouched.
let r = call_tool(&mut b, "batch", &json!({"commands": [{"command": "edit.copy"}]}));
assert!(!r.is_error);
}
/// The password has to be in the schema, or a client has no way to know it can be sent.
#[test]
fn the_schema_advertises_the_password_as_optional() {
+1
View File
@@ -92,6 +92,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
crate::chrome::note_title_drag(ui.ctx(), band);
// The chevron's stroke starts at the content edge.
let c = if rtl {
pos2(band.max.x - 26.0, band.center().y)
+22
View File
@@ -281,6 +281,24 @@ fn autosave_knob_x(rtl: bool, on: bool, r: Rect) -> f32 {
crate::rtl::lead_x(rtl != on, r, 8.0)
}
/// The `ctx.data` key for the part of the title bar that drags this document window.
fn title_drag_id(ctx: &egui::Context) -> egui::Id {
egui::Id::unique(("title_drag_rect", ctx.viewport_id()))
}
/// Notes `rect` as the part of the title bar that drags the window in this frame: behind a modal
/// dialog its backdrop has the pointer there, and moves the window instead ([`title_drag_rect`]).
pub(crate) fn note_title_drag(ctx: &egui::Context, rect: Rect) {
let (key, pass) = (title_drag_id(ctx), ctx.cumulative_pass_nr());
ctx.data_mut(|d| d.insert_temp(key, (rect, pass)));
}
/// The part of the title bar that drags the window, if it was drawn in this frame.
pub(crate) fn title_drag_rect(ctx: &egui::Context) -> Option<Rect> {
let (key, pass) = (title_drag_id(ctx), ctx.cumulative_pass_nr());
ctx.data(|d| d.get_temp::<(Rect, u64)>(key)).filter(|(_, p)| *p == pass).map(|(r, _)| r)
}
/// Draws the title bar with window dragging, AutoSave, quick access, account, community and search
/// controls, and the centred clickable document title.
pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
@@ -301,6 +319,10 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
// Elsewhere the system's own title bar above this one moves the window.
if mac {
note_title_drag(ui.ctx(), full);
}
if drag.double_clicked() {
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
+1
View File
@@ -455,6 +455,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
let resp = egui::Modal::new(Id::unique("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d));
// egui's Modal blocks the window behind it, but not the document's keyboard.
crate::dialogs::release_keyboard(app, ctx);
crate::dialogs::drag_by_title_bar(ctx, &resp.backdrop_response, resp.response.rect);
let mut done = resp.inner;
if done.is_none() && resp.should_close() {
done = Some(false);
+3 -1
View File
@@ -34,7 +34,9 @@ use bookmarks::bookmark_at_caret;
#[cfg(test)]
use bookmarks::bookmark_names;
pub use chrome::show;
pub(crate) use chrome::{block_behind, buttons, cancel_key_pressed, color_menu, forget_nudge, ok_cancel_responses, release_keyboard};
pub(crate) use chrome::{
block_behind, buttons, cancel_key_pressed, color_menu, drag_by_title_bar, forget_nudge, ok_cancel_responses, release_keyboard,
};
pub(crate) use dispatch::search_results;
pub(crate) use find::swallow_shortcut_text;
pub use find::{FindExtra, FindTab, GoToWhat};
+11
View File
@@ -67,6 +67,16 @@ fn count_dialog_frame(ctx: &egui::Context, id: egui::Id) {
}
}
/// Behind a modal window, whose `backdrop` has the pointer, the app still moves by its title bar,
/// as in Word for Mac (#5124); not from just inside the window's edge, where it reaches over the
/// title bar.
pub(crate) fn drag_by_title_bar(ctx: &egui::Context, backdrop: &egui::Response, window: egui::Rect) {
let origin = ctx.input(|i| i.pointer.press_origin()).filter(|p| !window.contains(*p));
if backdrop.drag_started() && origin.is_some_and(|p| crate::chrome::title_drag_rect(ctx).is_some_and(|r| r.contains(p))) {
ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
}
/// Makes `window` modal (#3859): the document gives up the keyboard now, and from the next frame
/// nothing beneath the window takes the pointer or Tab. A press beside it nudges it ([`nudge`]).
pub(crate) fn block_behind(app: &WordApp, ctx: &egui::Context, window: &egui::Response) {
@@ -81,6 +91,7 @@ pub(crate) fn block_behind(app: &WordApp, ctx: &egui::Context, window: &egui::Re
.inner;
// The backdrop also has the pointer just inside the window's edge (egui's hit radius).
let beside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| !window.rect.contains(p)));
drag_by_title_bar(ctx, &backdrop, window.rect);
ctx.move_to_top(layer);
ctx.memory_mut(|m| m.set_modal_layer(layer));
ctx.accesskit_node_builder(window.id, |node| node.set_modal());
+110
View File
@@ -2078,6 +2078,116 @@ fn the_page_waits_for_the_keyboard_until_a_modal_dialog_closes() {
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "!", "the page has it after the dialog");
}
/// Drags from `at` to 30 points right in `h`, and counts the window drags the app asked for.
fn window_drags(h: &mut egui_kittest::Harness<WordApp>, at: egui::Pos2) -> usize {
let mut drags = 0;
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
let events = [egui::Event::PointerMoved(at), button(at, true)]
.into_iter()
.chain([10.0, 20.0, 30.0].map(|dx| egui::Event::PointerMoved(at + vec2(dx, 0.0))))
.chain([button(at + vec2(30.0, 0.0), false)]);
for e in events {
h.event(e);
h.step();
drags += h.output().viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::StartDrag)).count();
}
drags
}
/// #5124: behind a modal dialog the window still moves by its title bar, as in Word for Mac, where
/// WordCraft draws it, or by the top of File's side menu, which covers it there; elsewhere the
/// system's title bar does, and WordCraft's bar under it doesn't.
#[test]
fn the_window_moves_by_its_title_bar_behind_a_modal_dialog() {
for (mac, backstage) in [(true, false), (true, true), (false, false), (false, true)] {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.integrated_titlebar = mac;
a.ui.backstage = backstage;
let mut h = app_harness(a);
h.run_steps(6);
let font = Dialog::open("font", h.state_mut());
h.state_mut().dialog = font;
h.run_steps(4);
let at = egui::pos2(if backstage { 100.0 } else { 300.0 }, 12.0);
assert_eq!(window_drags(&mut h, at), usize::from(mac), "macOS: {mac}, File: {backstage}");
assert!(matches!(h.state().dialog, Some(Dialog::Font { .. })), "Font stays");
assert_eq!(h.state().ui.backstage, backstage, "nothing behind it opened or closed");
}
}
/// Opens a modal state in `a`: the built-in file browser, a dialog by name, or Colors.
fn open_modal_state(a: &mut WordApp, state: &str) {
match state {
"none" => {}
"fileBrowser" => {
let document = a.session.document_id();
a.show_file_browser(
crate::file_dialogs::FileDialogRequest::Open { purpose: "document".into() },
crate::file_dialogs::AfterPick::OpenDocument { document },
false,
);
}
"colors" => a.colors = Some(crate::colors::ColorsDialog::new(None, crate::colors::Target::cmd("format.color"))),
dialog => a.dialog = Dialog::open(dialog, a),
}
}
/// The file browser, a modal dialog and Colors each block the window; nothing open or a modeless
/// dialog doesn't.
#[test]
fn modal_open_is_true_for_the_file_browser_a_modal_dialog_and_colors() {
for (state, modal) in [("none", false), ("find", false), ("font", true), ("fileBrowser", true), ("colors", true)] {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
open_modal_state(&mut a, state);
assert_eq!(a.modal_open(), modal, "{state}");
}
}
/// #5124: whatever is open, a drag on the title bar macOS draws moves the window; where the system
/// draws it, WordCraft's bar under it only does while nothing blocks it.
#[test]
fn the_title_bar_moves_the_window_in_every_modal_state() {
for (state, modal) in [("none", false), ("find", false), ("font", true), ("fileBrowser", true), ("colors", true)] {
for mac in [true, false] {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.integrated_titlebar = mac;
let mut h = app_harness(a);
h.run_steps(6);
open_modal_state(h.state_mut(), state);
h.run_steps(4);
assert_eq!(h.state().modal_open(), modal, "{state}");
assert_eq!(window_drags(&mut h, egui::pos2(300.0, 12.0)), usize::from(mac || !modal), "{state}, macOS: {mac}");
assert_eq!(h.state().modal_open(), modal, "{state} stays open");
}
}
}
/// #5124: where a dialog reaches over the title bar (a short window), a drag on the dialog is the
/// dialog's own and doesn't move the window.
#[test]
fn a_drag_on_a_dialog_over_the_title_bar_doesnt_move_the_window() {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.integrated_titlebar = true;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 420.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
h.run_steps(6);
let font = Dialog::open("font", h.state_mut());
h.state_mut().dialog = font;
h.run_steps(4);
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Font")))).expect("the dialog is open");
let at = egui::pos2(rect.center().x, rect.top() + 8.0);
assert!(at.y < 30.0, "the dialog reaches over the title bar: {rect:?}");
assert_eq!(window_drags(&mut h, at), 0, "a drag on its title");
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Font")))).expect("the dialog is open");
assert_eq!(window_drags(&mut h, egui::pos2(rect.center().x, rect.top() + 1.0)), 0, "nor just inside its edge");
}
#[test]
fn font_sample_props_map_each_field_to_its_property() {
use super::font::font_sample_props;
+22 -1
View File
@@ -67,11 +67,16 @@ fn too_large_to_paste(width: u32, height: u32) -> ClipboardPicture {
ClipboardPicture::Unreadable(format!("the clipboard picture is too large to paste ({width} × {height} pixels)"))
}
/// Whether the renderer can decode `bytes` within its budget (a 16-bit picture takes more than its pixel count says).
fn probe_fits(bytes: &[u8]) -> bool {
wordcraft_geom::picture::probe(bytes).is_ok_and(|p| p.fits(wordcraft_geom::MAX_DECODED_BYTES))
}
/// An encoded picture the host read from the clipboard (the Windows "PNG" format), refused from
/// its header when a paste can't take its size, before anything decodes it (#3623).
pub fn encoded_picture(bytes: Vec<u8>) -> ClipboardPicture {
match wordcraft_geom::picture::probe(&bytes).map(|p| (p.width, p.height)) {
Ok((w, h)) if pastes(w, h) => ClipboardPicture::Encoded(bytes),
Ok((w, h)) if pastes(w, h) && probe_fits(&bytes) => ClipboardPicture::Encoded(bytes),
Ok((w, h)) => too_large_to_paste(w, h),
Err(e) => ClipboardPicture::Unreadable(e.to_string()),
}
@@ -258,6 +263,22 @@ mod tests {
image::GrayImage::new(20_000, 1).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
assert!(matches!(encoded_picture(wide), ClipboardPicture::Unreadable(e) if e.contains("too large")));
assert!(matches!(encoded_picture(b"not a picture".to_vec()), ClipboardPicture::Unreadable(_)));
// 8000 × 6000 passes the pixel limits, but 16-bit RGBA needs 12 bytes a pixel to decode.
let mut big = Vec::new();
image::DynamicImage::ImageRgba16(image::ImageBuffer::new(1, 1))
.write_to(&mut std::io::Cursor::new(&mut big), image::ImageFormat::Png)
.unwrap();
big[16..20].copy_from_slice(&8000u32.to_be_bytes());
big[20..24].copy_from_slice(&6000u32.to_be_bytes());
let mut crc = 0xFFFF_FFFFu32;
for &b in &big[12..29] {
crc ^= u32::from(b);
for _ in 0..8 {
crc = if crc & 1 == 1 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 };
}
}
big[29..33].copy_from_slice(&(!crc).to_be_bytes());
assert!(matches!(encoded_picture(big), ClipboardPicture::Unreadable(e) if e.contains("too large")));
// BGRA in memory: a half-transparent red pixel and an opaque blue one.
let ClipboardPicture::Pixels { width, height, rgba } = dib_picture(dib(2, 1, &[[0, 0, 255, 128], [255, 0, 0, 255]])) else {
panic!("pixels")
+1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -55,7 +55,7 @@ esac
# Only binaries for this machine run here (the --version check at the end); a FreeBSD target built
# on another OS never does.
HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="$(uname -m)"
if [ -n "${CARGO_BUILD_TARGET:-}" ] && [ "$(uname -s)" != FreeBSD ]; then
if [[ -n "${CARGO_BUILD_TARGET:-}" ]] && [[ "$(uname -s)" != FreeBSD ]]; then
HOST_ARCH="$(uname -s) $HOST_ARCH"
fi
BASENAME="wordcraft-$VERSION-freebsd-$ARCH"
@@ -96,12 +96,12 @@ mkdir -p "$DIST"
tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
echo "wrote $DIST/$BASENAME.tar.gz"
if [ -z "${CARGO_BUILD_TARGET:-}" ] && [ "$ARCH" = "$HOST_ARCH" ]; then
if [[ -z "${CARGO_BUILD_TARGET:-}" ]] && [[ "$ARCH" = "$HOST_ARCH" ]]; then
"$STAGE/bin/wordcraft-cli" --version
else
# They cannot run here, so check what they are instead: executables, not libraries or objects.
# An older file(1) calls a PIE a shared object; its interpreter still marks it as a program.
want="$ARCH"; [ "$ARCH" != x86_64 ] || want=x86-64
want="$ARCH"; [[ "$ARCH" != x86_64 ]] || want=x86-64
for b in "$STAGE"/bin/*; do
case "$(file -b "$b")" in
ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;;
+7
View File
@@ -1,2 +1,9 @@
sonar.projectKey=storytold_wordcraft
sonar.organization=storytold
# S4790 (weak hash): SHA-1 is a start-key and "SHA1/1K" checksum algorithm that ODF package
# encryption (ODF 1.3 Part 2 §4.16) names, and older office suites wrote it. It is needed to open
# those files, not chosen for new security: WordCraft writes a SHA-256 start key and checksum.
sonar.issue.ignore.multicriteria=odt_sha1
sonar.issue.ignore.multicriteria.odt_sha1.ruleKey=rust:S4790
sonar.issue.ignore.multicriteria.odt_sha1.resourceKey=crates/formats/src/odt/crypt.rs