js: app.openDoc request, a link event's document target, empty popUpMenuEx

Ported from #2081: app.openDoc only queues Request::OpenDoc for the current document's
folder (the viewer asks first) and answers null; a Link event's target is the document;
a popup menu with no items answers null without asking.

Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com>
This commit is contained in:
FredAppsandLMatt08 committed 2026-11-11 12:32:31 +01:00
1 parent 6d03a7acdf
commit d9c895a32f
5 files changed
+136 -14

No files matched your search

+47 -8
View File
@@ -140,6 +140,41 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsVa
Ok(JsValue::undefined())
}
/// `app.openDoc(cPath, oDoc)` or `app.openDoc({cPath, oDoc, cFS})`: queues a request to open
/// the local file `cPath`, relative to this document. Only this document may be the base and
/// only the local file system is supported; other options are refused. The viewer asks the
/// user first, so no document object can be returned: the call answers null (#2081).
fn open_doc(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let unset = |v: &JsValue| v.is_undefined() || v.is_null();
// Only this context's own document object (not a lookalike, proxy or inherited object).
let this_document = |v: &JsValue, ctx: &Context| unset(v) || v.as_object().is_some_and(|o| JsObject::equals(&o, &ctx.global_object()));
let first = arg(args, 0);
let path = match first.as_object().filter(|o| !o.is_array() && !o.is_callable()) {
Some(o) => {
if !args.iter().skip(1).all(unset) {
return Err(error("unsupported openDoc options"));
}
let fs = o.get(js_string!("cFS"), ctx)?;
let base = o.get(js_string!("oDoc"), ctx)?;
if (!fs.is_undefined() && !text(&fs, ctx)?.is_empty()) || !this_document(&base, ctx) {
return Err(error("openDoc supports only the current document's local file system"));
}
text(&o.get(js_string!("cPath"), ctx)?, ctx)?
}
None => {
if !this_document(&arg(args, 1), ctx) || !args.iter().skip(2).all(unset) {
return Err(error("unsupported openDoc options"));
}
text(&first, ctx)?
}
};
if path.is_empty() || path.len() > 4096 {
return Err(error("invalid openDoc path"));
}
host(ctx)?.borrow_mut().request(Request::OpenDoc(path));
Ok(JsValue::null())
}
/// The attached file `exportDataObject` names, and whether to open it: from an object
/// (`{cName, nLaunch}`) or from the arguments (`cName, cDIPath, bAllowAuth, nLaunch`). `nLaunch`
/// 1 and 2 open the file; 0 (the default) and any other value save it.
@@ -555,6 +590,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
.function(NativeFunction::from_fn_ptr(crate::interaction::response), js_string!("response"), 1)
.function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2)
.function(NativeFunction::from_fn_ptr(crate::interaction::popup_menu), js_string!("popUpMenuEx"), 0)
.function(NativeFunction::from_fn_ptr(open_doc), js_string!("openDoc"), 1)
.function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1)
.function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2)
.function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2)
@@ -622,13 +658,16 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject,
.build();
let value_get = function(ctx, NativeFunction::from_copy_closure_with_captures(get, store.clone()));
let value_set = function(ctx, NativeFunction::from_copy_closure_with_captures(set, store.clone()));
let target = e.target.as_ref().map_or(JsValue::null(), |t| {
if e.target_id.is_some() {
crate::document::field_object_by_id(ctx, t, e.target_id).into()
} else {
crate::document::field_object(ctx, t).into()
}
});
// A link action's target is the document (#2081); other events without a field have none.
let link_document = e.target.is_none() && e.kind == "Link";
let target = match e.target.as_ref() {
Some(t) if e.target_id.is_some() => crate::document::field_object_by_id(ctx, t, e.target_id).into(),
Some(t) => crate::document::field_object(ctx, t).into(),
None if link_document => ctx.global_object().into(),
None => JsValue::null(),
};
// Not enumerable there, so `JSON.stringify(event)` doesn't walk back into the document.
let target_attributes = if link_document { Attribute::WRITABLE | Attribute::CONFIGURABLE } else { Attribute::all() };
let event = ObjectInitializer::new(ctx)
.property(js_string!("name"), s(&e.name), Attribute::all())
.property(js_string!("type"), s(&e.kind), Attribute::all())
@@ -637,7 +676,7 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject,
.property(js_string!("changeEx"), s(&e.change), Attribute::all())
.property(js_string!("willCommit"), e.will_commit, Attribute::all())
.property(js_string!("rc"), true, Attribute::all())
.property(js_string!("target"), target, Attribute::all())
.property(js_string!("target"), target, target_attributes)
.property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all())
.property(js_string!("selStart"), 0, Attribute::all())
.property(js_string!("selEnd"), 0, Attribute::all())
+2 -1
View File
@@ -76,12 +76,13 @@ impl Host {
Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)),
Request::Submit(u) => Request::Submit(xfa::clip(u)),
Request::Focus(n) => Request::Focus(xfa::clip(n)),
Request::OpenDoc(p) => Request::OpenDoc(xfa::clip(p)),
Request::FocusField { name, object_id } => Request::FocusField { name: xfa::clip(name), object_id },
other => other,
};
let bytes = match &r {
Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(),
Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) | Request::OpenDoc(u) => u.len(),
Request::FocusField { name, .. } => name.len(),
Request::OpenAttachment { name, .. } => name.len(),
_ => 0,
+14 -5
View File
@@ -31,9 +31,9 @@ pub(crate) fn has_host() -> bool {
pub(crate) fn set_host(host: Option<Sender<InteractiveEvent>>) {
HOST.with(|h| *h.borrow_mut() = host.map(|h| (h, 0)));
}
/// Send a bounded prompt and await its reply, treating disconnect/timeout as cancellation.
pub(crate) fn ask(prompt: Prompt) -> JsResult<Option<String>> {
let sender = HOST.with(|h| {
/// Charge one prompt to the invocation's budget: the channel to send it on, if the worker has one.
fn reserve() -> JsResult<Option<Sender<InteractiveEvent>>> {
HOST.with(|h| {
let mut h = h.borrow_mut();
let Some((tx, n)) = h.as_mut() else { return Ok(None) };
if *n >= 32 {
@@ -41,8 +41,11 @@ pub(crate) fn ask(prompt: Prompt) -> JsResult<Option<String>> {
}
*n += 1;
Ok(Some(tx.clone()))
})?;
let Some(sender) = sender else { return Ok(None) };
})
}
/// Send a bounded prompt and await its reply, treating disconnect/timeout as cancellation.
pub(crate) fn ask(prompt: Prompt) -> JsResult<Option<String>> {
let Some(sender) = reserve()? else { return Ok(None) };
let (tx, rx) = mpsc::channel();
sender.send(InteractiveEvent::Prompt(prompt, tx)).map_err(|_| error("interactive host closed"))?;
let answer = rx.recv_timeout(std::time::Duration::from_secs(300)).ok().flatten();
@@ -102,6 +105,12 @@ pub(crate) fn popup_menu(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> Js
for a in args {
items.push(read_menu(a, ctx, 0, &mut remaining)?);
}
// A menu with no items has nothing to pick: it answers null at once, without showing an empty
// menu or using up a later menu's answer, but still counts toward the prompt limit (#2081).
if items.is_empty() {
reserve()?;
return Ok(JsValue::null());
}
match ask(Prompt::Menu(items.clone()))? {
Some(answer) if allowed(&items, &answer) => Ok(s(&answer)),
Some(_) => Err(error("invalid popup menu selection")),
+3
View File
@@ -311,6 +311,9 @@ pub enum Request {
Highlight(bool),
/// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons).
SaveAs,
/// `app.openDoc(cPath)`: the path as the script gave it (relative to this document's
/// folder). A request only: the viewer asks the user before opening any file.
OpenDoc(String),
/// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch`
/// 1 or 2) or to save (0).
OpenAttachment {
+70
View File
@@ -2702,3 +2702,73 @@ fn af_simple_calculate_bounds_selector_work_before_expansion() {
run("AFSimple_Calculate('SUM',new Array(4096).fill('n'));", &Event::doc("Console"), &Default::default(), &fields, &[], Default::default());
assert!(out.error.as_deref().is_some_and(|e| e.contains("calculation field lookup exceeds")));
}
/// A link action's `event.target` is the document (#2081, ported), and the event can still be
/// serialized: the target isn't enumerable, so `JSON.stringify` doesn't walk back into it.
#[test]
fn a_link_events_target_is_the_document() {
let link = Event { kind: "Link".into(), ..Event::doc("Mouse Up") };
let o = go("JSON.stringify(event); JSON.stringify(this); (event.target === this) + ':' + event.type + ':' + event.name", &link);
assert_eq!(o.error, None);
assert_eq!(o.result.as_deref(), Some("true:Link:Mouse Up"));
assert_eq!(go("event.target === null", &Event::doc("Console")).result.as_deref(), Some("true"), "other document events keep none");
}
/// `app.openDoc` only queues a request (the viewer asks before opening a file) and answers null;
/// another document as the base, another file system or extra options are refused (#2081).
#[test]
fn open_doc_queues_a_request_for_the_current_documents_folder_only() {
let link = Event { kind: "Link".into(), ..Event::doc("Mouse Up") };
for call in [
"app.openDoc({cPath:'other.pdf',oDoc:this})",
"app.openDoc('other.pdf',this)",
"app.openDoc('other.pdf',event.target)",
"app.openDoc({cPath:'other.pdf',oDoc:this,cFS:''})",
"app.openDoc('other.pdf',null)",
"app.openDoc({cPath:'other.pdf'},null,undefined)",
] {
let o = go(&format!("getField('price').value='17'; {call} === null"), &link);
assert_eq!(o.error, None, "{call}");
assert_eq!(o.result.as_deref(), Some("true"), "{call}");
assert_eq!(o.requests, [Request::OpenDoc("other.pdf".into())], "{call}");
}
for call in [
"app.openDoc({cPath:'other.pdf',oDoc:{}})",
"app.openDoc('other.pdf',{})",
"app.openDoc('other.pdf',Object.create(this))",
"app.openDoc('other.pdf',new Proxy(this,{}))",
"app.openDoc('other.pdf',getField('price'))",
"app.openDoc('other.pdf',this,true)",
"app.openDoc({cPath:'other.pdf',oDoc:this,cFS:'CHTTP'})",
"app.openDoc({cPath:'other.pdf'},this)",
"app.openDoc('')",
] {
let o = go(call, &link);
assert!(o.error.as_deref().is_some_and(|e| e.contains("openDoc")), "{call}: {:?}", o.error);
assert!(o.requests.is_empty(), "{call}");
}
}
/// A popup menu with no items answers null without asking, so the next menu still gets its own
/// answer, and empty menus count toward the prompt limit (#2081).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_empty_popup_menu_answers_null_without_asking() {
let job = |script: &str| crate::session::Job {
script: script.into(),
event: Event { kind: "Link".into(), ..Event::doc("Mouse Up") },
doc: doc(),
fields: Arc::new(Vec::new()),
doc_scripts: Vec::new(),
calculations: Vec::new(),
};
let mut s = crate::session::ScriptSession::default();
let o = s.run(
job("var a = app.popUpMenuEx(); var b = app.popUpMenuEx({cName:'B', cReturn:'b'}); app.popUpMenuEx.apply(app, []); String(a) + ':' + b"),
&[Some("b".into())],
);
assert_eq!(o.error, None);
assert_eq!(o.result.as_deref(), Some("null:b"));
let o = s.run(job("for (var i = 0; i < 33; i++) app.popUpMenuEx();"), &[]);
assert!(o.error.as_deref().is_some_and(|e| e.contains("too many interactive prompts")), "{:?}", o.error);
}