diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7925998..4597c0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,4 +28,19 @@ jobs: run: npm run build - name: Check Rust - run: cargo check \ No newline at end of file + run: cargo check + + - name: Test Rust + run: cargo test + + # The rustls backends exist so apps can build for Linux and Android without OpenSSL (#34). + - name: Check rustls builds don't pull in OpenSSL + run: | + for target in x86_64-unknown-linux-gnu aarch64-linux-android; do + for features in "" "--no-default-features --features rustls-tls"; do + if cargo tree -e normal --target "$target" $features --prefix none | grep -E '^(openssl-sys|native-tls) '; then + echo "::error::OpenSSL in the dependency tree for $target ${features:-(default features)}" + exit 1 + fi + done + done \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6939a81..6a4b7d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Use the system proxy settings on macOS and Windows again. Turning off reqwest's default features to add the TLS feature flags (#34) also turned off its `system-proxy`, `http2` and `charset` features; they're back on, and the TLS backend is still chosen with `default-tls`, `rustls-tls` or `native-tls` +- `rust-version` is 1.85.0: reqwest 0.13 and rand 0.10 don't build on 1.81 + +### Added + +- Tests for event delivery (batches of 25, what's kept for the next flush and what's dropped, the event format, polling) and for App Key validation, run in CI +- README sections on delivery and on the TLS backend features + ## [1.2.0] - 2026-08-11 ### Changed diff --git a/Cargo.toml b/Cargo.toml index a5e798f..36936c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ license = "MIT" description = "Tauri Plugin for Aptabase: Open Source, Privacy-First and Simple Analytics for Mobile, Desktop and Web Apps" authors = ["Guilherme Oenning"] edition = "2021" -rust-version = "1.81.0" +rust-version = "1.85.0" readme = "README.md" repository = "https://github.com/aptabase/tauri-plugin-aptabase" exclude = ["/examples", "/webview-dist", "/webview-src", "/node_modules"] @@ -17,13 +17,17 @@ tokio = "1.53.1" futures = "0.3.31" serde = "1.0.229" serde_json = "1.0.151" -reqwest = { version = "0.13.4", default-features = false, features = ["json"] } +# Only reqwest's TLS default is turned off (see the TLS features below); its other defaults stay on. +reqwest = { version = "0.13.4", default-features = false, features = ["json", "charset", "http2", "system-proxy"] } time = { version = "0.3.41", features = ["formatting"] } os_info = "3.12.0" rand = "0.10.0" log = "0.4.27" sys-locale = "0.3.2" +[dev-dependencies] +time = { version = "0.3.41", features = ["parsing"] } + [build-dependencies] tauri-plugin = { version = "2.6.3", features = ["build"] } diff --git a/README.md b/README.md index 42fd437..dff185e 100644 --- a/README.md +++ b/README.md @@ -126,4 +126,34 @@ pub fn run() { } ``` +## Delivery + +Events are queued in memory and sent in the background: every 60 seconds in release builds and every 2 seconds in debug builds (set `flush_interval` in `InitOptions` to change it), and whenever you call `flush_events_blocking()`. + +- Each request carries up to 25 events. +- If a request doesn't reach the server (no network, a timeout) or the server answers with a 5xx status, its events stay queued for the next flush. +- If the server answers with any other unsuccessful status, such as a 4xx, its events are dropped. +- The queue isn't saved to disk. + +Requests use the system proxy settings on macOS and Windows, and the `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` and `NO_PROXY` environment variables on every platform. + +## TLS backends + +The plugin sends events with [reqwest](https://crates.io/crates/reqwest). You choose its TLS backend with one of these Cargo features: + +| Feature | Backend | +| --- | --- | +| `default-tls` (default) | reqwest's default backend, currently [rustls](https://crates.io/crates/rustls) | +| `rustls-tls` | rustls | +| `native-tls` | The platform's TLS library: OpenSSL on Linux and Android, Security.framework on Apple platforms, SChannel on Windows | + +To switch backend, turn off the default features: + +```toml +[dependencies] +tauri-plugin-aptabase = { version = "1.2", default-features = false, features = ["native-tls"] } +``` + +Neither `default-tls` nor `rustls-tls` needs OpenSSL, so you can cross-compile for Android and Linux without it. + For AI/LLM integration instructions, see [llms.txt](./llms.txt) diff --git a/llms.txt b/llms.txt index 5003bae..504674e 100644 --- a/llms.txt +++ b/llms.txt @@ -147,11 +147,21 @@ tauri::Builder::default() - `host` — required only for self-hosted Aptabase instances (app keys starting with `A-SH-*`) - `flush_interval` — defaults to 60s in release, 2s in debug mode - Events are batched and flushed periodically; `flush_events_blocking()` forces an immediate flush +- Up to 25 events per request; network failures and 5xx responses keep the events queued for the next flush, other unsuccessful responses drop them +- Requests use the system proxy settings (macOS, Windows) and the `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` environment variables + +## TLS Backend + +Cargo features choose the TLS backend: `default-tls` (default, rustls), `rustls-tls`, or `native-tls` (OpenSSL on Linux and Android). To switch, turn off the default features: + +```toml +tauri-plugin-aptabase = { version = "1.2", default-features = false, features = ["native-tls"] } +``` ## Platform Notes - Requires Tauri v2 (2.x) -- Rust edition 2021, MSRV 1.70 +- Rust edition 2021, MSRV 1.85 - The `EventTracker` trait is implemented for `App`, `AppHandle`, and `Window` - The JS `trackEvent` is async but you don't need to await it - A custom panic hook can be set via `Builder::with_panic_hook()` to track crashes diff --git a/src/config.rs b/src/config.rs index 1985f11..6b82204 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,3 +67,42 @@ impl Default for Config { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn options(host: Option<&str>) -> InitOptions { + InitOptions { + host: host.map(Into::into), + flush_interval: None, + } + } + + #[test] + fn a_self_hosted_app_key_sends_to_its_host() { + let config = Config::new( + "A-SH-0000000000".into(), + options(Some("http://127.0.0.1:8000")), + ); + assert_eq!(config.app_key, "A-SH-0000000000"); + assert_eq!( + config.ingest_api_url.as_str(), + "http://127.0.0.1:8000/api/v0/events" + ); + } + + // An empty App Key is what turns tracking off: the client drops every event it is given. + #[test] + fn an_invalid_app_key_turns_tracking_off() { + let host = Some("http://127.0.0.1:8000"); + for (app_key, host) in [ + ("A-XX-0000000000", host), + ("not-an-app-key", host), + ("A-SH-0000000000", None), + ] { + let config = Config::new(app_key.into(), options(host)); + assert_eq!(config.app_key, "", "{app_key} with host {host:?}"); + } + } +} diff --git a/src/dispatcher.rs b/src/dispatcher.rs index b4885a4..a94b017 100644 --- a/src/dispatcher.rs +++ b/src/dispatcher.rs @@ -123,3 +123,176 @@ impl EventDispatcher { self.enqueue_many(failed_items); } } + +// Delivery end to end: events tracked on a client and flushed to a local server. +#[cfg(test)] +mod tests { + use std::{ + net::TcpListener, + time::{Duration, Instant}, + }; + + use serde_json::{json, Value}; + use tauri::async_runtime::block_on; + use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + + use crate::{ + client::AptabaseClient, + config::Config, + test_server::{server, server_on, Server}, + InitOptions, + }; + + const APP_KEY: &str = "A-SH-0000000000"; + + fn client(base: &str) -> AptabaseClient { + let options = InitOptions { + host: Some(base.into()), + flush_interval: None, + }; + AptabaseClient::new(&Config::new(APP_KEY.into(), options), "1.4.0".into()) + } + + /// The event names in each request the server got. + fn batches(server: &Server) -> Vec> { + let requests = server.requests.lock().unwrap(); + requests + .iter() + .map(|(_, body)| { + let events = body.as_array().unwrap(); + events + .iter() + .map(|event| event["eventName"].as_str().unwrap().to_string()) + .collect() + }) + .collect() + } + + fn keys(value: &Value) -> Vec<&str> { + let mut keys: Vec<&str> = value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + keys.sort(); + keys + } + + #[test] + fn events_go_out_in_batches_of_25() { + let server = server(&[200, 200, 200]); + let client = client(&server.base); + for i in 0..60 { + client.track_event(&format!("event_{i}"), None).unwrap(); + } + block_on(client.flush()); + let sizes: Vec = batches(&server).iter().map(Vec::len).collect(); + assert_eq!(sizes, [25, 25, 10]); + } + + #[test] + fn a_server_error_keeps_the_events_for_the_next_flush() { + let server = server(&[503, 200]); + let client = client(&server.base); + client.track_event("app_started", None).unwrap(); + client.track_event("app_exited", None).unwrap(); + block_on(client.flush()); + block_on(client.flush()); + assert_eq!( + batches(&server), + [["app_started", "app_exited"], ["app_started", "app_exited"]] + ); + } + + #[test] + fn a_network_failure_keeps_the_events_for_the_next_flush() { + let addr = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap(); + let client = client(&format!("http://{addr}")); + client.track_event("app_started", None).unwrap(); + // Nothing listens on the port yet: the connection is refused. + block_on(client.flush()); + let server = server_on(TcpListener::bind(addr).unwrap(), &[200]); + block_on(client.flush()); + assert_eq!(batches(&server), [["app_started"]]); + } + + #[test] + fn a_client_error_drops_the_events() { + let server = server(&[400, 200]); + let client = client(&server.base); + client.track_event("app_started", None).unwrap(); + block_on(client.flush()); + block_on(client.flush()); + assert_eq!(batches(&server), [["app_started"]]); + } + + #[test] + fn events_are_sent_in_the_aptabase_format() { + let server = server(&[200]); + let client = client(&server.base); + let props = json!({ "plan": "pro", "seats": 3 }); + client + .track_event("app_started", Some(props.clone())) + .unwrap(); + block_on(client.flush()); + + let requests = server.requests.lock().unwrap(); + let (app_key, body) = &requests[0]; + assert_eq!(app_key, APP_KEY); + let event = &body[0]; + assert_eq!( + keys(event), + [ + "eventName", + "props", + "sessionId", + "systemProps", + "timestamp" + ] + ); + assert_eq!(event["eventName"], "app_started"); + assert_eq!(event["props"], props); + assert!(!event["sessionId"].as_str().unwrap().is_empty()); + OffsetDateTime::parse(event["timestamp"].as_str().unwrap(), &Rfc3339).unwrap(); + + let system = &event["systemProps"]; + assert_eq!( + keys(system), + [ + "appVersion", + "engineName", + "engineVersion", + "isDebug", + "locale", + "osName", + "osVersion", + "sdkVersion" + ] + ); + assert_eq!(system["appVersion"], "1.4.0"); + assert_eq!( + system["sdkVersion"], + concat!("tauri-plugin-aptabase@", env!("CARGO_PKG_VERSION")) + ); + assert_eq!(system["isDebug"], cfg!(debug_assertions)); + } + + #[test] + fn polling_flushes_without_a_manual_flush() { + let server = server(&[200]); + let client = client(&server.base); + client.start_polling(Duration::from_millis(50)); + client.track_event("app_started", None).unwrap(); + let started = Instant::now(); + while server.requests.lock().unwrap().is_empty() + && started.elapsed() < Duration::from_secs(5) + { + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(batches(&server), [["app_started"]]); + } +} diff --git a/src/lib.rs b/src/lib.rs index 824a794..6c20b53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,8 @@ mod commands; mod config; mod dispatcher; mod sys; +#[cfg(test)] +mod test_server; use std::{panic::PanicHookInfo, sync::Arc, time::Duration}; diff --git a/src/test_server.rs b/src/test_server.rs new file mode 100644 index 0000000..5c91635 --- /dev/null +++ b/src/test_server.rs @@ -0,0 +1,70 @@ +//! A local HTTP server for tests: it answers each request with the next status it was given and keeps what it was +//! sent (the App-Key header and the JSON body). A status of 0 never answers: the request stays in flight. + +// Not every test uses all of it. +#![allow(dead_code)] + +use std::{ + io::{BufRead, BufReader, Read, Write}, + net::TcpListener, + sync::{Arc, Mutex}, +}; + +use serde_json::Value; + +/// A server that answers each request with the next status in `statuses`, keeping what it was sent. +pub(crate) struct Server { + /// Where it listens, e.g. `http://127.0.0.1:50211`. + pub base: String, + pub requests: Arc>>, +} + +pub(crate) fn server(statuses: &[u16]) -> Server { + server_on(TcpListener::bind("127.0.0.1:0").unwrap(), statuses) +} + +/// [`server`] on a listener the test bound itself, e.g. on a port an earlier request found closed. +pub(crate) fn server_on(listener: TcpListener, statuses: &[u16]) -> Server { + let base = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let seen = requests.clone(); + let statuses = statuses.to_vec(); + std::thread::spawn(move || { + let mut unanswered = Vec::new(); + for (stream, status) in listener.incoming().zip(statuses) { + let mut stream = stream.unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let (mut app_key, mut length) = (String::new(), 0); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let lower = line.to_ascii_lowercase(); + if let Some(v) = lower.strip_prefix("content-length:") { + length = v.trim().parse().unwrap(); + } + if lower.starts_with("app-key:") { + app_key = line["app-key:".len()..].trim().to_string(); + } + if line == "\r\n" { + break; + } + } + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + seen.lock() + .unwrap() + .push((app_key, serde_json::from_slice(&body).unwrap())); + if status == 0 { + unanswered.push(stream); + continue; + } + let reply = + format!("HTTP/1.1 {status} X\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + stream.write_all(reply.as_bytes()).unwrap(); + } + // The last request answered: keep the unanswered ones in flight until the test ends. + std::thread::sleep(std::time::Duration::from_secs(60)); + drop(unanswered); + }); + Server { base, requests } +}