use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use serde::Deserialize;
use crate::MacosError;
use crate::safari_guard::with_safari_session;
use super::target::{execute_js_in_tab, resolve_tab};
use super::types::SafariEvalResult;
static NEXT_EVAL_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Deserialize)]
struct EvalWire {
status: String,
#[serde(default)]
value_type: Option<String>,
#[serde(default)]
result_json: Option<String>,
#[serde(default)]
error: Option<String>,
}
fn build_eval_js(code: &str, token: &str, body: bool) -> Result<String, MacosError> {
let source = if body {
format!("(async () => {{\n{code}\n}})()")
} else {
format!("(async () => (\n{code}\n))()")
};
let token = serde_json::to_string(token)
.map_err(|err| MacosError::Other(format!("invalid evaluation token: {err}")))?;
Ok(
r#"(() => {
const token = __TOKEN__;
const store = window.__cuewardEvalPending ||= Object.create(null);
const failure = (error) => JSON.stringify({
status: 'error', error: String(error?.name || 'Error') + ': ' +
String(error?.message || error)
});
const success = (value) => {
let valueType = value === null ? 'null' :
Array.isArray(value) ? 'array' : typeof value;
if (valueType === 'undefined') return JSON.stringify({
status: 'complete', value_type: valueType, result_json: 'null'
});
if (valueType === 'bigint') return JSON.stringify({
status: 'complete', value_type: valueType,
result_json: JSON.stringify(value.toString())
});
if (valueType === 'number' && !Number.isFinite(value)) {
valueType = 'nonfinite-number';
return JSON.stringify({status: 'complete', value_type: valueType,
result_json: JSON.stringify(String(value))});
}
const encoded = JSON.stringify(value);
if (encoded === undefined) throw new TypeError('JavaScript result is not JSON serializable');
return JSON.stringify({status: 'complete', value_type: valueType,
result_json: encoded});
};
let result;
try {
result = __SOURCE__;
} catch (error) {
return failure(error);
}
if (result && typeof result.then === 'function') {
store[token] = JSON.stringify({status: 'running'});
Promise.resolve(result).then(
value => { if (Object.hasOwn(store, token)) {
try { store[token] = success(value); }
catch (error) { store[token] = failure(error); }
} },
error => { if (Object.hasOwn(store, token)) store[token] = failure(error); }
);
return store[token];
}
try { return success(result); }
catch (error) { return failure(error); }
})()"#
.replace("__TOKEN__", &token)
.replace("__SOURCE__", &source),
)
}
fn build_poll_js(token: &str) -> Result<String, MacosError> {
let token = serde_json::to_string(token)
.map_err(|err| MacosError::Other(format!("invalid evaluation token: {err}")))?;
Ok(format!(
r#"(() => {{
const store = window.__cuewardEvalPending;
const result = store?.[{token}];
if (!result) return JSON.stringify({{status: 'error', error: 'evaluation state lost'}});
if (JSON.parse(result).status !== 'running') delete store[{token}];
return result;
}})()"#
))
}
fn build_cleanup_js(token: &str) -> Result<String, MacosError> {
let token = serde_json::to_string(token)
.map_err(|err| MacosError::Other(format!("invalid evaluation token: {err}")))?;
Ok(format!(
"(() => {{ const store = window.__cuewardEvalPending; if (store) delete store[{token}]; return 'true'; }})()"
))
}
fn decode_eval_wire(payload: &str) -> Result<Option<SafariEvalResult>, MacosError> {
let wire: EvalWire = serde_json::from_str(payload)
.map_err(|err| MacosError::Other(format!("invalid Safari evaluation response: {err}")))?;
match wire.status.as_str() {
"running" => Ok(None),
"error" => Err(MacosError::Other(format!(
"JavaScript evaluation failed: {}",
wire.error.unwrap_or_else(|| "unknown error".to_string())
))),
"complete" => {
let result = wire
.result_json
.ok_or_else(|| MacosError::Other("JavaScript result had no value".to_string()))?;
let result = serde_json::from_str(&result).map_err(|err| {
MacosError::Other(format!("invalid serialized JavaScript result: {err}"))
})?;
let value_type = wire
.value_type
.ok_or_else(|| MacosError::Other("JavaScript result had no type".to_string()))?;
Ok(Some(SafariEvalResult { result, value_type }))
}
other => Err(MacosError::Other(format!(
"unknown JavaScript evaluation status: {other}"
))),
}
}
pub fn exec(
js_code: &str,
profile_filter: Option<&str>,
tab_selector: Option<&str>,
timeout_seconds: u64,
body: bool,
) -> Result<SafariEvalResult, MacosError> {
with_safari_session(|| {
let tab = resolve_tab(tab_selector, profile_filter)?;
let token = format!(
"{}-{}",
std::process::id(),
NEXT_EVAL_ID.fetch_add(1, Ordering::Relaxed)
);
let initial =
execute_js_in_tab(&build_eval_js(js_code, &token, body)?, &tab, "safari_exec")?;
if let Some(result) = decode_eval_wire(&initial)? {
return Ok(result);
}
let poll_js = build_poll_js(&token)?;
let deadline = Instant::now() + Duration::from_secs(timeout_seconds);
while Instant::now() < deadline {
thread::sleep(Duration::from_millis(250));
let payload = execute_js_in_tab(&poll_js, &tab, "safari_exec_poll")?;
if let Some(result) = decode_eval_wire(&payload)? {
return Ok(result);
}
}
if let Ok(cleanup) = build_cleanup_js(&token) {
let _ = execute_js_in_tab(&cleanup, &tab, "safari_exec_cleanup");
}
Err(MacosError::Other(format!(
"timeout waiting for JavaScript result after {timeout_seconds} seconds"
)))
})
}
#[cfg(test)]
mod tests {
use std::process::Command;
use super::{build_cleanup_js, build_eval_js, build_poll_js, decode_eval_wire};
fn evaluate_with_node(code: &str, body: bool) -> super::SafariEvalResult {
let initial = build_eval_js(code, "test-token", body).expect("build evaluation");
let poll = build_poll_js("test-token").expect("build poll");
let harness = format!(
"globalThis.window = globalThis; globalThis.eval = () => {{throw Error('eval is blocked')}}; \
const initial = {initial}; \
if (JSON.parse(initial).status === 'running') {{ \
setImmediate(() => process.stdout.write({poll})); \
}} else {{ process.stdout.write(initial); }}"
);
let output = Command::new("node")
.arg("-e")
.arg(harness)
.output()
.expect("Node.js is required for Safari JavaScript behavior tests");
assert!(
output.status.success(),
"JavaScript bridge failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let payload = String::from_utf8(output.stdout).expect("UTF-8 result");
decode_eval_wire(&payload)
.expect("decode JavaScript result")
.expect("completed JavaScript result")
}
#[test]
fn javascript_bridge_preserves_types_and_resolves_await() {
for (code, expected_type, expected_value) in [
("[1,2]", "array", serde_json::json!([1, 2])),
("({a:1})", "object", serde_json::json!({"a": 1})),
("null", "null", serde_json::Value::Null),
("undefined", "undefined", serde_json::Value::Null),
("await Promise.resolve(42)", "number", serde_json::json!(42)),
] {
let result = evaluate_with_node(code, false);
assert_eq!(result.value_type, expected_type, "source: {code}");
assert_eq!(result.result, expected_value, "source: {code}");
}
let body_result = evaluate_with_node(
"const value = await Promise.resolve(7); return value + 1;",
true,
);
assert_eq!(body_result.result, serde_json::json!(8));
}
#[test]
fn cancelled_evaluation_does_not_restore_late_result() {
let initial = build_eval_js("window.pending", "cancelled", false).expect("evaluation");
let cleanup = build_cleanup_js("cancelled").expect("cleanup");
let script = format!(
"globalThis.window = globalThis; let finish; \
window.pending = new Promise(resolve => {{ finish = resolve; }}); \
{initial}; {cleanup}; \
finish(1); setImmediate(() => process.stdout.write( \
String(Object.hasOwn(window.__cuewardEvalPending, 'cancelled'))));"
);
let output = Command::new("node")
.arg("-e")
.arg(script)
.output()
.expect("Node.js is required for Safari JavaScript behavior tests");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, b"false");
}
#[test]
fn decodes_typed_values() {
let array =
decode_eval_wire(r#"{"status":"complete","value_type":"array","result_json":"[1,2]"}"#)
.expect("decode array")
.expect("complete array");
assert_eq!(array.result, serde_json::json!([1, 2]));
let string = decode_eval_wire(
r#"{"status":"complete","value_type":"string","result_json":"\"hello\""}"#,
)
.expect("decode string")
.expect("complete string");
assert_eq!(string.result, serde_json::json!("hello"));
}
#[test]
fn rejects_malformed_result_instead_of_returning_corrupt_data() {
assert!(
decode_eval_wire(
r#"{"status":"complete","value_type":"array","result_json":"1.02.0"}"#
)
.is_err()
);
}
}