diff --git a/crates/chatd/src/adapters/tiny.rs b/crates/chatd/src/adapters/tiny.rs index 71f6092a..b64faa5a 100644 --- a/crates/chatd/src/adapters/tiny.rs +++ b/crates/chatd/src/adapters/tiny.rs @@ -48,6 +48,11 @@ pub fn prove_legacy_saved_speakers() -> Result<(), String> { messages::prove_legacy_saved_speakers() } +/// The lab's keycap-reaction proof. See `messages::prove_keycap_reaction`. +pub fn prove_keycap_reaction() -> Result<(), String> { + messages::prove_keycap_reaction() +} + /// The lab's cache-open-without-device proof. See `messages::prove_cache_open_without_device`. pub fn prove_cache_open_without_device() -> Result<(), String> { messages::prove_cache_open_without_device() diff --git a/crates/chatd/src/adapters/tiny/messages.rs b/crates/chatd/src/adapters/tiny/messages.rs index 83ae665f..48333909 100644 --- a/crates/chatd/src/adapters/tiny/messages.rs +++ b/crates/chatd/src/adapters/tiny/messages.rs @@ -881,6 +881,21 @@ pub(super) fn prove_sealed_preview() -> Result<(), String> { } } +/// The lab's keycap-reaction proof. A keycap is taken as a reaction, and a bare digit still is not. +pub(super) fn prove_keycap_reaction() -> Result<(), String> { + for keycap in ["1\u{FE0F}\u{20E3}", "0\u{20E3}", "#\u{FE0F}\u{20E3}"] { + if !unicode_emoji(keycap) { + return Err(format!("the keycap {keycap:?} was refused as a reaction")); + } + } + for not_one in ["1", "10\u{FE0F}\u{20E3}", "1\u{FE0F}", ":one:"] { + if unicode_emoji(not_one) { + return Err(format!("{not_one:?} was taken as a reaction")); + } + } + Ok(()) +} + /// The lab's sealed-preview-attachments proof. A wiped sqlite row still restores a captionless /// image and a captioned attachment from the authenticated sealed cache. The lab sets /// `TINYCLIENT_PROVE=sealed-preview-attachments`. @@ -1887,14 +1902,17 @@ fn media_kind(kind: &str, mime: &str) -> MediaKind { } } -/// Unicode emoji only. A shortcode, a word, or a path is not one. +/// Unicode emoji only. A shortcode, a word, or a path is not one. A keycap is one, though it +/// starts with an ASCII digit (1️⃣ is `1`, U+FE0F, U+20E3), and tinyserver takes it. fn unicode_emoji(emoji: &str) -> bool { let mut textual = false; if emoji.is_empty() { return false; } - for ch in emoji.chars() { - if ch.is_ascii_alphanumeric() || ch == ':' || ch == '/' { + for (at, ch) in emoji.char_indices() { + let after = &emoji[at + ch.len_utf8()..]; + let keycap = ch.is_ascii_digit() && (after.starts_with('\u{20E3}') || after.starts_with("\u{FE0F}\u{20E3}")); + if !keycap && (ch.is_ascii_alphanumeric() || ch == ':' || ch == '/') { return false; } if !ch.is_ascii() { @@ -2881,6 +2899,20 @@ mod tests { assert!(!unicode_emoji("")); } + /// A keycap reaction (1️⃣) was refused when sent and dropped when another app had put one on + /// a message, because it starts with an ASCII digit. + #[test] + fn a_keycap_is_a_reaction_and_a_bare_digit_is_not() { + assert!(unicode_emoji("1\u{FE0F}\u{20E3}")); + assert!(unicode_emoji("7\u{20E3}"), "a keycap without its variation selector"); + assert!(unicode_emoji("*\u{FE0F}\u{20E3}")); + assert!(!unicode_emoji("1")); + assert!(!unicode_emoji("1\u{FE0F}"), "a digit and a variation selector are not a keycap"); + assert!(!unicode_emoji("10\u{FE0F}\u{20E3}"), "only the digit right before the keycap mark is part of it"); + assert!(!unicode_emoji("a\u{20E3}")); + assert!(super::prove_keycap_reaction().is_ok()); + } + #[test] fn a_new_direct_event_records_the_peer_on_either_side_before_the_first_message() { for (low, high) in [(ME, PEER), (PEER, ME)] { diff --git a/crates/chatd/src/main.rs b/crates/chatd/src/main.rs index 7bc3e40c..68d52c6e 100644 --- a/crates/chatd/src/main.rs +++ b/crates/chatd/src/main.rs @@ -236,6 +236,15 @@ fn main() -> io::Result<()> { eprintln!("prove-pending-message-identity:0"); return Ok(()); } + // The lab's keycap-reaction driver. A build without this returns to the server below and stays up. + if std::env::var("TINYCLIENT_PROVE").ok().as_deref() == Some("keycap-reaction") { + if let Err(err) = chatd::adapters::tiny::prove_keycap_reaction() { + eprintln!("keycap-reaction: {err}"); + std::process::exit(1); + } + eprintln!("prove-exit:0"); + return Ok(()); + } let _log = protocol::log_policy::install("chatd", protocol::log_policy::LogPolicy::from_env()); protocol::crash::install_panic_hook("chatd"); let dir = protocol::support_dir(); diff --git a/e2e/scenarios/keycap-reaction/drive.linux.sh b/e2e/scenarios/keycap-reaction/drive.linux.sh new file mode 100644 index 00000000..60e0c91d --- /dev/null +++ b/e2e/scenarios/keycap-reaction/drive.linux.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# keycap-reaction on Linux. The lab runs this with bash while ffmpeg films the display. +set -u +HERE=$(cd "$(dirname "$0")" && pwd) +if command -v python3 >/dev/null 2>&1; then + exec python3 "$HERE/drive.py" +fi +echo "python3 is not on PATH" >&2 +exit 1 + diff --git a/e2e/scenarios/keycap-reaction/drive.macos.sh b/e2e/scenarios/keycap-reaction/drive.macos.sh new file mode 100644 index 00000000..1982bc1e --- /dev/null +++ b/e2e/scenarios/keycap-reaction/drive.macos.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# keycap-reaction on macOS. The lab runs this with bash while the helper films the app window. +set -u +HERE=$(cd "$(dirname "$0")" && pwd) +if command -v python3 >/dev/null 2>&1; then + exec python3 "$HERE/drive.py" +fi +echo "python3 is not on PATH" >&2 +exit 1 + diff --git a/e2e/scenarios/keycap-reaction/drive.py b/e2e/scenarios/keycap-reaction/drive.py new file mode 100644 index 00000000..7e216716 --- /dev/null +++ b/e2e/scenarios/keycap-reaction/drive.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""A keycap emoji such as 1️⃣ is taken as a Tiny reaction. + +Starts this build's chatd with TINYCLIENT_PROVE=keycap-reaction. That build checks the reaction +filter the Tiny adapter applies to sent and received reactions, and exits 0 only when keycaps pass +and a bare digit does not. A build from before the fix ignores the variable and keeps serving, so +this driver fails there. + +Env from the lab: E2E_OUT, E2E_T0, E2E_APP_DIR. +""" + +import os +import subprocess +import sys +import time + +OUT = os.environ.get("E2E_OUT", "") +T0 = float(os.environ.get("E2E_T0") or time.time()) +APP = os.environ.get("E2E_APP_DIR", "") + +FAILED = False +STEPS = "" + + +def now(): + return time.time() - T0 + + +def step(words, ok): + global FAILED + if not ok: + FAILED = True + line = '{"t": %s, "step": "%s", "ok": %s}' % (round(now(), 1), words.replace('"', "'"), "true" if ok else "false") + print(("[ok] " if ok else "[FAILED] ") + words, flush=True) + if STEPS: + with open(STEPS, "a", encoding="utf-8") as handle: + handle.write(line + "\n") + + +def chatd_bin(app_dir): + if sys.platform == "win32": + return os.path.join(app_dir, "chatd.exe") + if sys.platform == "darwin" and app_dir.endswith(".app"): + return os.path.join(app_dir, "Contents", "MacOS", "chatd") + return os.path.join(app_dir, "chatd") + + +def main(): + global STEPS + if not OUT or not APP: + print("E2E_OUT and E2E_APP_DIR are required", file=sys.stderr) + return 1 + os.makedirs(OUT, exist_ok=True) + STEPS = os.path.join(OUT, "steps.jsonl") + open(STEPS, "w", encoding="utf-8").close() + binary = chatd_bin(APP) + if not os.path.isfile(binary): + step("chatd is in E2E_APP_DIR", False) + return 1 + + data = os.path.join(OUT, "data") + os.makedirs(data, exist_ok=True) + env = os.environ.copy() + env["TINYCLIENT_DIR"] = data + env["TINYCLIENT_PROVE"] = "keycap-reaction" + env["TINYCLIENT_LOG_LEVEL"] = "info" + kwargs = {"cwd": data, "env": env, "stdin": subprocess.DEVNULL} + if sys.platform == "win32": + kwargs["creationflags"] = 0x08000000 + with open(os.path.join(OUT, "chatd.out.log"), "w", encoding="utf-8") as out, open(os.path.join(OUT, "chatd.err.log"), "w", encoding="utf-8") as err: + kwargs["stdout"] = out + kwargs["stderr"] = err + proc = subprocess.Popen([binary], **kwargs) + try: + code = proc.wait(timeout=45) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + code = None + step("a keycap emoji is taken as a reaction", code == 0) + return 1 if FAILED else 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/e2e/scenarios/keycap-reaction/drive.windows.ps1 b/e2e/scenarios/keycap-reaction/drive.windows.ps1 new file mode 100644 index 00000000..188364c9 --- /dev/null +++ b/e2e/scenarios/keycap-reaction/drive.windows.ps1 @@ -0,0 +1,18 @@ +# keycap-reaction on Windows. The lab runs this in the console session it is already filming. +$ErrorActionPreference = 'Stop' +$drive = Join-Path $PSScriptRoot 'drive.py' +if (Get-Command py -ErrorAction SilentlyContinue) { + & py -3 $drive + exit $LASTEXITCODE +} +if (Get-Command python -ErrorAction SilentlyContinue) { + & python $drive + exit $LASTEXITCODE +} +if (Get-Command python3 -ErrorAction SilentlyContinue) { + & python3 $drive + exit $LASTEXITCODE +} +Write-Host 'python is not on PATH (tried py -3, python, python3)' +exit 1 + diff --git a/e2e/scenarios/keycap-reaction/scenario.json b/e2e/scenarios/keycap-reaction/scenario.json new file mode 100644 index 00000000..23689879 --- /dev/null +++ b/e2e/scenarios/keycap-reaction/scenario.json @@ -0,0 +1,12 @@ +{ + "id": "keycap-reaction", + "title": "A keycap emoji such as 1️⃣ is taken as a Tiny reaction", + "platforms": ["windows", "linux", "macos"], + "not_applicable": { + "android": "The phone proof build does not run desktop chatd. The same reaction filter is what this desktop run checks.", + "ios": "The phone proof build does not run desktop chatd. The same reaction filter is what this desktop run checks." + }, + "needs": [], + "release": false, + "max_still_seconds": 60 +}