diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 90f2e8d..c3e72bf 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -15,6 +15,7 @@ on: branches: ["**"] paths: - "src/cli/**" + - "src/analytics/**" - "src/update/**" - "src/app/paths.rs" - "src/app/window_hiding.rs" diff --git a/CLAUDE.md b/CLAUDE.md index 808ab0c..63b121c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH - **术语。** 保存的一项叫「主机」(量词「台」),代码和库里叫 host(`Host`、`HostId`、`HostStore`、`hosts` 表)。它以前叫「会话」/ `Session`,已全部改掉,新代码和文案别再用。填 IP / 域名的字段叫「地址」(`Host.address`)。SSH 协议层的 session(「SSH 会话通道」、russh 的 `Session`、SFTP 会话)照旧。 - **两种不保存的连接。** 「外部连接」:堡垒机像调 Xshell(`ssh://`、`-url`)或 WinSCP(`sftp://`)一样拉起 ShellRS,只开一个终端或 SFTP 标签(`HostStore::is_external`)。「临时连接」:标题栏「临时连接…」打开,功能齐全。两者共同的「不保存」在代码里叫 temporary(`HostInfo::temporary`)。 -- **全是真的。** SQLite(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)、本地终端(`portable-pty` + `alacritty_terminal`)、SSH(`russh`)、SFTP(双栏、传输队列、断点续传)、端口转发(`-L` / `-R` / `-D`)、凭据、连接方式(直连、多级跳板、HTTP / SOCKS5 代理)、右侧栏七个工具、内置编辑器和预览、外部 CLI、在线升级。密码和口令只进系统钥匙串(`keyring`),**数据库里永远不出现秘密**。 +- **全是真的。** SQLite(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)、本地终端(`portable-pty` + `alacritty_terminal`)、SSH(`russh`)、SFTP(双栏、传输队列、断点续传)、端口转发(`-L` / `-R` / `-D`)、凭据、连接方式(直连、多级跳板、HTTP / SOCKS5 代理)、右侧栏七个工具、内置编辑器和预览、外部 CLI、在线升级、匿名使用统计(Aptabase)。密码和口令只进系统钥匙串(`keyring`),**数据库里永远不出现秘密**。 做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/.md` 为准。 @@ -55,8 +55,13 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH - 清单地址 `https://dl.shellrs.com/update/v1/{channel}.json`、平台 key 的名字和公钥槽位一经发布就不能改。 - 默认在后台检查并下载,下好、校验完才在标题栏亮按钮(`success` 色,出错时 `warning` 色)。 - 更新对话框不列更新内容:「查看更新内容」打开整页的 `https://shellrs.com/changelog`,另一个按钮是「重启并安装」,没有「稍后」。不点也在退出时装好。 - - 「设置 › 关于」两组:「应用更新」三行(当前版本、更新渠道(稳定版 / Beta,运行时切换)、自动升级),「ShellRS」一行「官网」(打开 `https://shellrs.com`)。 + - 「设置 › 关于」三组:「应用更新」三行(当前版本、更新渠道(稳定版 / Beta,运行时切换)、自动升级),「隐私」一行(见下条),「ShellRS」一行「官网」(打开 `https://shellrs.com`)。 - 检查只发 User-Agent,不发安装标识。 +- **匿名使用统计**:发给 Aptabase(没有 Rust SDK,自己调 `POST /api/v0/events`;App Key 是 `analytics::APP_KEY`,不是秘密)。「设置 › 关于 › 隐私 › 发送匿名使用统计」默认开,关掉就清空本机攒下的计数。 + - 只发版本、系统名和版本、CPU 架构、设置快照(取值固定,主机等数量只报档位)和功能使用次数。不发主机、凭据、命令、文件名、路径、语言,也没有任何标识(Aptabase 用 IP + UA 加每日的盐算日活)。 + - 四种事件:`app_installed`(`analytics.json` 记着已报过,送达后才记)、`app_started`、`app_active`(窗口到前台且当天 UTC 还没报过,macOS 藏在后台不算)、`usage`(功能次数,从第一次使用起满 4 小时汇总一条,不是每次使用一条:免费额度每月 2 万个事件)。 + - 不用定时器:只在启动、窗口激活、用到功能、改设置时动作,满 4 小时的汇总和失败后的重试都等下一个信号。用户明确不要定时任务,数据有误差可以接受。 + - 只有发布构建发;开发构建要设 `SHELLRS_ANALYTICS_DEBUG=1`,发的是调试事件,汇总周期 1 分钟、失败 10 秒后重试(`Pace::Debug`),好在面板上马上看到。 - **编辑器**:中间区的「编辑器」Dock 标签,不用系统程序打开。 - 双击、回车、F4、右键「编辑」都打开它,本地文件也行;新建文件后直接打开。 - 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。 @@ -134,6 +139,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `settings/`:`AppSettings`、写 `settings.json` 的 `SettingsStore`、把设置落到窗口上的 `apply`、设置标签。 - `cli/`:外部 CLI(`shellrs hosts/credentials/exec/upload/download/sync`,给 AI Agent 用)、套接字协议、应用里的服务端、主机和凭据改动的校验(`manage.rs`)、PATH 和 Agent skill 的安装。 - `update/`:在线升级,包括清单、验签、下载、各平台安装器和 `Updater`。 +- `analytics/`:匿名使用统计。`model.rs` 是纯的计数、事件和发送时机(`Reporter`),`system.rs` 用 `os_info` 读系统名和版本,`client.rs` 是投递线程和工作区拿着的 `Analytics` 句柄;工作区的接线在 `workspace/analytics.rs`。 - `workspace/`:窗口壳。持有 store、`DockArea`、各类标签的注册表和**全部动作处理器**,按领域拆成多个文件(`forwards.rs`、`credentials.rs`、`links.rs`、`editors.rs`、`tools.rs`、`updates.rs`…)。另有标题栏、状态栏、左侧栏 `Sidebar`、右侧栏 `ToolSidebar`、开始页。 - `shared/`:多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、`SegmentedControl`、`RowTooltips`、`confirm_danger`、`form_error_notification` 等)。 @@ -274,6 +280,11 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **在线升级**: - `new_with_services` 建的 `Updater` 没有服务(测试从不联网),生产路径再 `set_services` 加 `start`。 - 重启走 `set_restart_path` 加 `restart`。Windows 不在退出过程中起进程,交给 `run` 返回之后的 `start_handed_over`。 +- **匿名使用统计**: + - 只在 `Workspace::new` 里启动(`AnalyticsServices::system`),`new_with_services` 没有;UI 测试用 `Analytics::new(sender)` 读信号。 + - 计数只能是 `Counter` 的变体,快照字段只有 `&'static str`、开关和数量,用户输入的东西放不进去。计数的键发布后不能改,新加的要同步 `docs/manual.md`「隐私」一节的清单。 + - UI 线程只往通道里发信号;系统检测(`os_info` 会起 `uname` 等子进程)、HTTP、写 `analytics.json` 都在 `shellrs-analytics` 线程上,退出不冲队列。 + - SSH 连上靠 `TerminalEvent::Started` → `TerminalPanelEvent::Connected`,不能在每批输出都发的 `StatusChanged` 里记;SFTP 看 `StateChanged` 时的 `connection_state()`。外部 CLI 的请求种类由 `CliServer::take_usage` 交给 `serve_cli` 的轮询。 - **发布走 tag** `v<版本>`(`release.yml`,需要人工批准,清单最后才上传)。`CHANGELOG.md` 那一节由改版本号的提交写,功能提交不改。一次性准备见 `docs/release.md`。 - **macOS 上关窗口是隐藏**(`hide_when_closed`),⌘Q 才退出。测试平台的 `hide()` 没实现,所以这条只挂在生产路径上。 diff --git a/Cargo.lock b/Cargo.lock index 8d5e3af..a6efb5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5476,6 +5476,27 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-user-notifications", +] + [[package]] name = "objc2-user-notifications" version = "0.3.2" @@ -5604,6 +5625,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix 0.31.3", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "windows-sys 0.61.2", +] + [[package]] name = "p256" version = "0.14.0" @@ -7722,6 +7758,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "os_info", "percent-encoding", "portable-pty", "rand 0.9.5", @@ -8373,7 +8410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index bf88c17..0ef1098 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,9 @@ trash = "5" minisign-verify = "0.2" reqwest = { version = "0.13", features = ["blocking"] } semver = { version = "1", features = ["serde"] } +# 匿名使用统计: the system's name and version for Aptabase, as its own Tauri +# plugin reads them. +os_info = { version = "3", default-features = false } [target.'cfg(unix)'.dependencies] # `access(2)`: whether the folder ShellRS runs from can take its update. diff --git a/README.en.md b/README.en.md index 835028e..04b3a3c 100644 --- a/README.en.md +++ b/README.en.md @@ -91,6 +91,7 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database. - Secrets stay in the system keychain. Host keys are recorded in ShellRS's own `known_hosts`, never in `~/.ssh`. - In-app updates: the update manifest is minisign-signed and each package is checked against its SHA-256. Updates download in the background and install on restart or quit. There are stable and beta channels, and an update check sends only the version, OS and CPU architecture. +- Anonymous usage statistics: sent to Aptabase with only the version, OS, CPU architecture and how often each feature is used, never hosts, credentials, commands or any identifier. Turn them off in Settings › About. ## Screenshots diff --git a/README.md b/README.md index ac7ffc9..1ddffa8 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF - 秘密只进系统钥匙串;主机信任记录存在 ShellRS 自己的 `known_hosts` 里,不改动 `~/.ssh`。 - 在线升级:更新清单经 minisign 签名,安装包按 SHA-256 校验;后台下载,重启或退出时安装。分稳定版和 Beta 两个渠道,检查更新只发送版本号、系统和 CPU 架构。 +- 匿名使用统计:经 Aptabase 只发送版本、系统、CPU 架构和各功能的使用次数,不含主机、凭据、命令或任何标识,可在「设置 › 关于」关闭。详见[使用手册](docs/manual.md#隐私)。 ## 截图 diff --git a/docs/manual.md b/docs/manual.md index bfbf101..bba7bc0 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -116,7 +116,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 - SFTP 文件列表、图片预览的快捷键和 Enter、方向键、Tab、Esc 这些固定键不在这里改。 - **外部 CLI**:见下一节。 - **应用 → 窗口**:「记住窗口大小」恢复上次的窗口大小和最大化状态,「记住窗口位置」把窗口放回上次所在的屏幕和位置,默认都打开,下次启动时生效。那块屏幕不在了、或窗口大半在屏幕外时,放在屏幕中间;窗口比屏幕大时缩到能放下。全屏不记,按退出全屏后的大小恢复。窗口的大小和位置随移动随时记在数据目录的 `window.json` 里。 -- **关于**:「应用更新」一组:当前版本和检查更新、更新渠道、自动升级,见「下载与更新」;「ShellRS」一组的「官网」用浏览器打开 shellrs.com。 +- **关于**:「应用更新」一组:当前版本和检查更新、更新渠道、自动升级,见「下载与更新」;「隐私」一组的「发送匿名使用统计」,默认打开,见「隐私」;「ShellRS」一组的「官网」用浏览器打开 shellrs.com。 ## 外部 CLI @@ -417,6 +417,30 @@ ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检 - **自动升级**:关掉之后只在手动检查时才联网,发现新版本时由你决定是否下载。 - **校验**:更新清单由 ShellRS 的发布密钥签名,安装包按清单里的大小和 SHA-256 核对,任何一步对不上都不会安装。macOS 上还会核对新版本的开发者签名。 -- **隐私**:检查更新只发送 ShellRS 的版本号、操作系统和 CPU 架构,不发送任何能识别这台电脑或用户的信息。 +- **隐私**:检查更新只发送 ShellRS 的版本号、操作系统和 CPU 架构,不发送任何能识别这台电脑或用户的信息。匿名使用统计见「隐私」一节。 - **不能自动更新的情况**:从 DMG 里直接运行、没有放进「应用程序」文件夹、对安装位置没有写权限、Linux 上不是用 AppImage 运行,或者是自己编译的开发构建。这时「关于」页会说明原因,有新版本时给出「前往下载页」。 - **退回旧版本**:数据库在升级到新结构之前会备份成 `shellrs.db.v<旧版本>.bak`。旧版本打开新结构的数据库时会拒绝打开,以免把新版本保存的数据弄乱;需要退回时,把备份改名回 `shellrs.db`。 + +## 隐私 + +ShellRS 不发送主机、凭据、会话内容、命令、文件名或路径,也不发送任何能识别这台电脑或用户的标识。联网的只有这两件事(连接主机本身除外): + +- **检查更新**:只发送 ShellRS 的版本号、操作系统和 CPU 架构,见「下载与更新」。 +- **匿名使用统计**:发给 [Aptabase](https://aptabase.com),用来了解安装量、每天有多少台设备在用、各版本的分布,以及哪些功能用得多。「设置 › 关于 › 隐私」里的「发送匿名使用统计」默认打开;关掉后不再发送,本机攒下的计数也一并清除。 + +每个统计事件都带 ShellRS 的版本号、操作系统的名称和版本(如 macOS 15.1、Windows 10.0.22631、Ubuntu 24.04)。事件只有四种: + +| 事件 | 什么时候发 | +|---|---| +| `app_installed` | 第一次启动时发一次。数据目录里记着已经报过,换一个数据目录或删掉它会再报 | +| `app_started` | 每次启动 | +| `app_active` | 当天(UTC)第一次把窗口切到前台时;启动当天不再重复。macOS 上关了窗口、留在后台不算 | +| `usage` | 各功能的使用次数:从第一次使用算起满 4 小时,在下一次启动、切到前台或使用时汇总成一条 | + +前三种附带这些设置和数量,取值都是固定的几种:CPU 架构、应用外观、界面语言、浅色和深色主题(内置主题的名字)、界面是否跟随主题、关键字高亮、外部 CLI 和自动升级是否打开、更新渠道、是否改过快捷键,以及保存的主机、凭据、端口转发、命令片段各有多少(只报档位:0、1-5、6-20、21-100、100+)。 + +`usage` 只有次数,没有内容,计的是:SSH 终端连上(另按跳板、代理、凭据、无密码、临时连接、外部连接分别计数)、SFTP 连上(另按临时连接、外部连接计数)、本地终端、端口转发开启(本地、远程、动态)、上传和下载批次、编辑器打开本地和远程文件、预览图片和 Markdown、打开右侧栏的七个工具、命令片段和历史命令送进终端、终端查找、通知(程序、响铃、关键字),外部 CLI 的 `exec`、`upload`、`download`、`sync`、`hosts`、`credentials` 命令,以及打开和关闭关键字高亮、外部 CLI。 + +- 计数先存在数据目录的 `analytics.json` 里,退出不会丢,之后再补发。离线或在内网里不影响使用:发不出去时等 1 分钟,之后每次加倍、最长 6 小时再试,而且只在使用 ShellRS 时顺带重试,空闲时不发任何请求。 +- Aptabase 按请求的 IP 地址推断国家和地区,但不保存 IP。它区分设备用的是 IP 和 User-Agent 加上每天更换的随机盐算出的散列,所以看不到同一台设备跨天的行为。 +- 自己编译的开发构建不发送统计;外部 CLI(`shellrs` 命令)本身也不发送。 diff --git a/src/analytics/client.rs b/src/analytics/client.rs new file mode 100644 index 0000000..d2ac193 --- /dev/null +++ b/src/analytics/client.rs @@ -0,0 +1,455 @@ +//! The analytics thread, the handle the workspace signals it through, and +//! the request to Aptabase. + +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use std::time::Duration; + +use chrono::Utc; +use reqwest::blocking::Client; +use reqwest::header::{CONTENT_TYPE, USER_AGENT}; + +use super::APP_KEY; +use super::model::{ + Counter, Delivery, Pace, Reporter, Signal, Snapshot, State, SystemProps, body, endpoint, +}; +use super::system::SystemInfo; +use crate::update::{Channel, build_info}; + +/// Lets a development build report, to its debug view, to try it out. +const DEBUG_ENV: &str = "SHELLRS_ANALYTICS_DEBUG"; + +/// Where the events go. +pub trait Transport: Send + Sync { + /// Post `body`, a JSON array of events, and say how it went. Blocks: it + /// runs on the analytics thread. + fn send(&self, body: &[u8], user_agent: &str) -> Delivery; +} + +/// Aptabase, over HTTPS. +pub struct HttpTransport { + endpoint: String, + app_key: String, + /// How long connecting may take, and the whole request. + connect_timeout: Duration, + timeout: Duration, + /// Whether to go through the system's proxy, as the update check does. + /// Only the loopback tests turn it off: a developer's proxy would + /// capture 127.0.0.1. + system_proxy: bool, +} + +impl HttpTransport { + pub fn new(endpoint: impl Into, app_key: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + app_key: app_key.into(), + connect_timeout: Duration::from_secs(5), + timeout: Duration::from_secs(15), + system_proxy: true, + } + } +} + +impl Transport for HttpTransport { + fn send(&self, body: &[u8], user_agent: &str) -> Delivery { + // Built for each request, on the analytics thread: the blocking + // client runs a runtime of its own, and requests are a few a day. + let builder = Client::builder() + .connect_timeout(self.connect_timeout) + .timeout(self.timeout); + let builder = if self.system_proxy { + builder + } else { + builder.no_proxy() + }; + let Ok(client) = builder.build() else { + return Delivery::Retry; + }; + let response = client + .post(&self.endpoint) + .header("App-Key", &self.app_key) + .header(CONTENT_TYPE, "application/json") + .header(USER_AGENT, user_agent) + .body(body.to_vec()) + .send(); + match response { + Ok(response) => delivery(response.status().as_u16()), + Err(_) => Delivery::Retry, + } + } +} + +/// What an answer means for the events: kept for a later try only when +/// trying again can help. +fn delivery(status: u16) -> Delivery { + match status { + 200..=299 => Delivery::Sent, + 408 | 429 | 500..=599 => Delivery::Retry, + _ => Delivery::Rejected, + } +} + +/// What the analytics thread works with: production's from `system`, the +/// tests' with a fake transport. +pub struct AnalyticsServices { + pub transport: Arc, + /// `analytics.json`. + pub state_path: PathBuf, + /// A development build's events go to the dashboard's debug view, and + /// at its quicker pace. + pub is_debug: bool, + /// Reads the system's name and version, on the thread. + pub detect: fn() -> SystemInfo, +} + +impl AnalyticsServices { + /// Production's, or `None` for a build that does not report: only + /// release builds do, and a development build with + /// `SHELLRS_ANALYTICS_DEBUG` set. + pub fn system() -> Option { + let is_debug = Channel::of_this_build().is_none(); + if is_debug && std::env::var_os(DEBUG_ENV).is_none() { + return None; + } + let endpoint = endpoint(APP_KEY)?; + Some(Self { + transport: Arc::new(HttpTransport::new(endpoint, APP_KEY)), + state_path: crate::app::analytics_path(), + is_debug, + detect: SystemInfo::detect, + }) + } +} + +/// The workspace's end. Signals go to the analytics thread and nothing +/// comes back, so the window never waits on the network; with 发送匿名使用统计 +/// off, none are sent. +pub struct Analytics { + sender: Sender, + enabled: bool, +} + +impl Analytics { + /// Signals go to `sender`. The tests read them off its receiver. + pub fn new(sender: Sender, enabled: bool) -> Self { + Self { sender, enabled } + } + + /// Start the analytics thread. Off, it only forgets what an earlier + /// run left counted. + pub fn start(services: AnalyticsServices, enabled: bool) -> Self { + let (sender, receiver) = mpsc::channel(); + // A thread that cannot start leaves the signals unheard, which is + // all it costs. + let _ = thread::Builder::new() + .name("shellrs-analytics".into()) + .spawn(move || run(services, receiver)); + let analytics = Self::new(sender, enabled); + if !enabled { + let _ = analytics.sender.send(Signal::Enabled(false)); + } + analytics + } + + pub fn started(&self, snapshot: Snapshot) { + self.send(Signal::Started(snapshot)); + } + + pub fn window_active(&self, snapshot: Snapshot) { + self.send(Signal::Active(snapshot)); + } + + pub fn count(&self, counter: Counter) { + self.send(Signal::Count(counter)); + } + + pub fn is_enabled(&self) -> bool { + self.enabled + } + + pub fn set_enabled(&mut self, enabled: bool) { + if enabled != self.enabled { + self.enabled = enabled; + let _ = self.sender.send(Signal::Enabled(enabled)); + } + } + + /// A thread that is gone is not the window's problem. + fn send(&self, signal: Signal) { + if self.enabled { + let _ = self.sender.send(signal); + } + } +} + +/// The analytics thread: wait for a signal, take it in with whatever else +/// arrived meanwhile, send what is due, keep the state. Nothing happens +/// between signals; the thread ends with the workspace's handle. +fn run(services: AnalyticsServices, signals: Receiver) { + let info = (services.detect)(); + let user_agent = info.user_agent(build_info::VERSION); + let system = SystemProps { + is_debug: services.is_debug, + os_name: info.os_name, + os_version: info.os_version, + app_version: build_info::VERSION.to_string(), + }; + let pace = if services.is_debug { + Pace::Debug + } else { + Pace::Release + }; + let mut reporter = Reporter::new(State::load(&services.state_path), pace); + while let Ok(signal) = signals.recv() { + let now = Utc::now(); + reporter.signal(signal, now); + for signal in signals.try_iter() { + reporter.signal(signal, now); + } + if let Some(batch) = reporter.take_batch(now) { + let delivery = services + .transport + .send(&body(&batch.events, &system), &user_agent); + reporter.settle(batch, delivery, Utc::now()); + } + if reporter.take_changed() { + // Unwritable, the state lives on in memory until the next try. + let _ = reporter.state().save(&services.state_path); + } + } +} + +#[cfg(test)] +mod tests { + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::Mutex; + use std::time::Instant; + + use serde_json::Value; + + use super::*; + + /// A request as the loopback server saw it. + #[derive(Clone, Debug, Default)] + struct Seen { + request_line: String, + headers: Vec<(String, String)>, + body: Vec, + } + + impl Seen { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } + } + + /// Answers every request with `status`, or with nothing at all for + /// `None`, and remembers what it was sent. + struct Server { + port: u16, + seen: Arc>>, + } + + impl Server { + fn start(status: Option) -> Self { + let listener = { + let _forks = crate::testing::no_forks(); + TcpListener::bind("127.0.0.1:0").unwrap() + }; + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Vec::new())); + let shared = seen.clone(); + thread::spawn(move || { + for stream in listener.incoming().flatten() { + let seen = shared.clone(); + thread::spawn(move || serve(stream, status, &seen)); + } + }); + Self { port, seen } + } + + fn transport(&self) -> HttpTransport { + HttpTransport { + endpoint: format!("http://127.0.0.1:{}/api/v0/events", self.port), + app_key: "A-US-1".into(), + connect_timeout: Duration::from_millis(500), + timeout: Duration::from_millis(500), + system_proxy: false, + } + } + + fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + } + + fn serve(stream: TcpStream, status: Option, seen: &Mutex>) { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request = Seen::default(); + if reader.read_line(&mut request.request_line).is_err() { + return; + } + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + request + .headers + .push((name.trim().to_string(), value.trim().to_string())); + } + } + let length = request + .header("content-length") + .and_then(|length| length.parse().ok()) + .unwrap_or(0); + request.body = vec![0; length]; + let _ = reader.read_exact(&mut request.body); + seen.lock().unwrap().push(request); + let Some(status) = status else { + // Keep the connection open and say nothing. + thread::sleep(Duration::from_secs(2)); + return; + }; + let mut stream = stream; + let _ = stream.write_all( + format!( + "HTTP/1.1 {status} Whatever\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}" + ) + .as_bytes(), + ); + } + + #[test] + fn the_request_is_what_aptabase_reads() { + let server = Server::start(Some(200)); + let delivery = server + .transport() + .send(b"[]", "ShellRS/1 (macOS 15; aarch64)"); + assert_eq!(delivery, Delivery::Sent); + let seen = server.seen(); + let request = &seen[0]; + assert!(request.request_line.starts_with("POST /api/v0/events ")); + assert_eq!(request.header("app-key"), Some("A-US-1")); + assert_eq!(request.header("content-type"), Some("application/json")); + assert_eq!( + request.header("user-agent"), + Some("ShellRS/1 (macOS 15; aarch64)") + ); + assert_eq!(request.body, b"[]"); + } + + #[test] + fn only_what_may_pass_is_tried_again() { + for (status, expected) in [ + (202, Delivery::Sent), + (500, Delivery::Retry), + (503, Delivery::Retry), + (429, Delivery::Retry), + (400, Delivery::Rejected), + (404, Delivery::Rejected), + ] { + let server = Server::start(Some(status)); + assert_eq!(server.transport().send(b"[]", "ua"), expected, "{status}"); + } + } + + #[test] + fn no_network_is_tried_again_later() { + // A port nothing listens on. + let port = { + let _forks = crate::testing::no_forks(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + let mut transport = Server::start(Some(200)).transport(); + transport.endpoint = format!("http://127.0.0.1:{port}/api/v0/events"); + assert_eq!(transport.send(b"[]", "ua"), Delivery::Retry); + } + + #[test] + fn a_server_that_never_answers_is_given_up_on() { + let server = Server::start(None); + let started = Instant::now(); + assert_eq!(server.transport().send(b"[]", "ua"), Delivery::Retry); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + /// Hands each body to the test. + struct FakeTransport(Mutex>>); + + impl Transport for FakeTransport { + fn send(&self, body: &[u8], _: &str) -> Delivery { + let _ = self.0.lock().unwrap().send(body.to_vec()); + Delivery::Sent + } + } + + fn a_mac() -> SystemInfo { + SystemInfo { + os_name: "macOS".into(), + os_version: "15.1.0".into(), + } + } + + #[test] + fn the_thread_reports_the_start_and_keeps_the_state() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("analytics.json"); + let (bodies, received) = mpsc::channel(); + let services = AnalyticsServices { + transport: Arc::new(FakeTransport(Mutex::new(bodies))), + state_path: path.clone(), + is_debug: true, + detect: a_mac, + }; + let analytics = Analytics::start(services, true); + analytics.started(Snapshot::default()); + let body = received.recv_timeout(Duration::from_secs(5)).unwrap(); + let events: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(events[0]["eventName"], "app_installed"); + assert_eq!(events[1]["eventName"], "app_started"); + assert_eq!(events[0]["systemProps"]["osName"], "macOS"); + assert_eq!(events[0]["systemProps"]["isDebug"], true); + analytics.count(Counter::Ssh); + // The count is on disk once the thread has taken it in. + let deadline = Instant::now() + Duration::from_secs(5); + let state = loop { + let state = State::load(&path); + if state.counting.is_some() || Instant::now() > deadline { + break state; + } + thread::sleep(Duration::from_millis(10)); + }; + assert!(state.installed); + assert_eq!(state.counting.unwrap().counts["ssh"], 1); + } + + #[test] + fn switched_off_nothing_is_signalled() { + let (sender, receiver) = mpsc::channel(); + let mut analytics = Analytics::new(sender, true); + analytics.count(Counter::Find); + analytics.set_enabled(false); + analytics.count(Counter::Find); + analytics.window_active(Snapshot::default()); + analytics.set_enabled(true); + let signals: Vec = receiver.try_iter().collect(); + assert_eq!( + signals, + [ + Signal::Count(Counter::Find), + Signal::Enabled(false), + Signal::Enabled(true) + ] + ); + } +} diff --git a/src/analytics/mod.rs b/src/analytics/mod.rs new file mode 100644 index 0000000..dcf1c38 --- /dev/null +++ b/src/analytics/mod.rs @@ -0,0 +1,35 @@ +//! 匿名使用统计: how many installations, how many devices a day, which +//! versions, and which features get used, reported to Aptabase. +//! +//! What goes out: the version, the operating system and its version, the +//! CPU architecture, a few settings (each from a fixed set of values, the +//! saved hosts and such only counted, in bands), and how many times each +//! feature was used. Never a host, credential, command, file name, path or +//! anything that identifies the machine: Aptabase tells devices apart from +//! the address and the User-Agent, hashed with a salt it replaces daily. +//! +//! Aptabase has no Rust SDK beyond its Tauri plugin, so this posts to its +//! REST API itself, as its other SDKs do: batches of up to 25 events, the +//! network and its 5xx retried, any other refusal dropped. Unlike them, +//! nothing runs on a timer: the workspace sends signals (started, the +//! window came forward, a feature was used, the switch flipped) to one +//! thread, which counts, works out what is due and sends it then. Feature +//! uses are summed for four hours into one `usage` event and kept on disk +//! until it is sent, so quitting loses nothing and never waits. +//! +//! Only release builds report; a development build does with +//! `SHELLRS_ANALYTICS_DEBUG` set, as debug events, summed by the minute and +//! retried after ten seconds. The external CLI, a second copy passing its +//! links on, and the UI tests never start it. + +mod client; +mod model; +mod system; + +pub use client::{Analytics, AnalyticsServices, HttpTransport, Transport}; +pub use model::{Counter, Delivery, Signal, Snapshot}; +pub use system::SystemInfo; + +/// The Aptabase app ShellRS reports to. Not a secret: every build carries +/// it, and all it allows is sending events. +pub const APP_KEY: &str = "A-US-1582708162"; diff --git a/src/analytics/model.rs b/src/analytics/model.rs new file mode 100644 index 0000000..f37c668 --- /dev/null +++ b/src/analytics/model.rs @@ -0,0 +1,1112 @@ +//! What is counted, what is sent and when. Everything here takes the time +//! as an argument, so the tests can move it on. + +use std::collections::{BTreeMap, VecDeque}; +use std::fs; +use std::io; +use std::path::Path; + +use chrono::{DateTime, NaiveDate, SecondsFormat, TimeDelta, Utc}; +use rand::Rng as _; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +/// How long uses are summed before they go out as one `usage` event, +/// counted from the first. +const SUMMARY_SPAN: TimeDelta = TimeDelta::hours(4); +/// Aptabase drops an event older than a day: nothing is sent stamped older +/// than this, and a queued event that gets older is dropped here. +const MAX_AGE: TimeDelta = TimeDelta::hours(23); +/// A session ends after this long without an event, as in Aptabase's SDKs. +const SESSION_TIMEOUT: TimeDelta = TimeDelta::hours(1); +/// The most events Aptabase takes in one request. +const BATCH_LIMIT: usize = 25; +/// The most lifecycle events kept while they cannot be sent. +const QUEUE_LIMIT: usize = 50; +/// The longest wait before trying again after failures in a row. +const LONGEST_WAIT: TimeDelta = TimeDelta::hours(6); +/// A development build's `SUMMARY_SPAN`, and its wait after any failure. +const DEBUG_SUMMARY_SPAN: TimeDelta = TimeDelta::minutes(1); +const DEBUG_WAIT: TimeDelta = TimeDelta::seconds(10); +/// Which SDK sent the events, as Aptabase's own SDKs name themselves. +const SDK_VERSION: &str = concat!("shellrs@", env!("CARGO_PKG_VERSION")); + +/// A use of a feature, summed into the next `usage` event. The keys are +/// what the dashboard shows and the manual lists: never rename one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Counter { + /// An SSH terminal connected, a reconnection too. The `Ssh*` below + /// count the same connections again, by how they were made. + Ssh, + SshJump, + SshProxy, + SshCredential, + SshNoPassword, + /// 临时连接. + SshTemporary, + /// 外部连接: opened by a link from a bastion host. + SshExternal, + /// An SFTP tab connected, a reconnection too. + Sftp, + SftpTemporary, + SftpExternal, + LocalTerminal, + /// A port forward started, by the user or with ShellRS. + ForwardLocal, + ForwardRemote, + ForwardDynamic, + /// A batch of transfers queued in an SFTP tab. + Upload, + Download, + /// A file opened in the editor. + EditorLocal, + EditorRemote, + PreviewImage, + PreviewMarkdown, + /// A tool of the right sidebar opened. + ToolSnippets, + ToolHistory, + ToolDocker, + ToolServices, + ToolProcesses, + ToolNetstat, + ToolMonitor, + /// A snippet, or a command from the history, put into a terminal. + CommandSnippet, + CommandHistory, + /// The terminal's find bar opened. + Find, + /// A notification shown: a program's, the bell, a highlight rule's. + NoticeProgram, + NoticeBell, + NoticeKeyword, + /// A `shellrs` command served. + CliExec, + CliUpload, + CliDownload, + CliSync, + CliHosts, + CliCredentials, + /// 关键字高亮 and 启用外部 CLI switched. + HighlightOn, + HighlightOff, + ExternalCliOn, + ExternalCliOff, +} + +impl Counter { + pub const ALL: [Counter; 43] = [ + Self::Ssh, + Self::SshJump, + Self::SshProxy, + Self::SshCredential, + Self::SshNoPassword, + Self::SshTemporary, + Self::SshExternal, + Self::Sftp, + Self::SftpTemporary, + Self::SftpExternal, + Self::LocalTerminal, + Self::ForwardLocal, + Self::ForwardRemote, + Self::ForwardDynamic, + Self::Upload, + Self::Download, + Self::EditorLocal, + Self::EditorRemote, + Self::PreviewImage, + Self::PreviewMarkdown, + Self::ToolSnippets, + Self::ToolHistory, + Self::ToolDocker, + Self::ToolServices, + Self::ToolProcesses, + Self::ToolNetstat, + Self::ToolMonitor, + Self::CommandSnippet, + Self::CommandHistory, + Self::Find, + Self::NoticeProgram, + Self::NoticeBell, + Self::NoticeKeyword, + Self::CliExec, + Self::CliUpload, + Self::CliDownload, + Self::CliSync, + Self::CliHosts, + Self::CliCredentials, + Self::HighlightOn, + Self::HighlightOff, + Self::ExternalCliOn, + Self::ExternalCliOff, + ]; + + pub fn key(self) -> &'static str { + match self { + Self::Ssh => "ssh", + Self::SshJump => "ssh_jump", + Self::SshProxy => "ssh_proxy", + Self::SshCredential => "ssh_credential", + Self::SshNoPassword => "ssh_no_password", + Self::SshTemporary => "ssh_temporary", + Self::SshExternal => "ssh_external", + Self::Sftp => "sftp", + Self::SftpTemporary => "sftp_temporary", + Self::SftpExternal => "sftp_external", + Self::LocalTerminal => "local_terminal", + Self::ForwardLocal => "forward_local", + Self::ForwardRemote => "forward_remote", + Self::ForwardDynamic => "forward_dynamic", + Self::Upload => "upload", + Self::Download => "download", + Self::EditorLocal => "editor_local", + Self::EditorRemote => "editor_remote", + Self::PreviewImage => "preview_image", + Self::PreviewMarkdown => "preview_markdown", + Self::ToolSnippets => "tool_snippets", + Self::ToolHistory => "tool_history", + Self::ToolDocker => "tool_docker", + Self::ToolServices => "tool_services", + Self::ToolProcesses => "tool_processes", + Self::ToolNetstat => "tool_netstat", + Self::ToolMonitor => "tool_monitor", + Self::CommandSnippet => "command_snippet", + Self::CommandHistory => "command_history", + Self::Find => "find", + Self::NoticeProgram => "notice_program", + Self::NoticeBell => "notice_bell", + Self::NoticeKeyword => "notice_keyword", + Self::CliExec => "cli_exec", + Self::CliUpload => "cli_upload", + Self::CliDownload => "cli_download", + Self::CliSync => "cli_sync", + Self::CliHosts => "cli_hosts", + Self::CliCredentials => "cli_credentials", + Self::HighlightOn => "highlight_on", + Self::HighlightOff => "highlight_off", + Self::ExternalCliOn => "external_cli_on", + Self::ExternalCliOff => "external_cli_off", + } + } + + fn from_key(key: &str) -> Option { + Self::ALL.into_iter().find(|counter| counter.key() == key) + } +} + +/// How ShellRS is set up, sent with the lifecycle events. Every value is +/// from a fixed set, which the types keep to: nothing the user typed fits. +/// The workspace fills it in. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Snapshot { + /// 应用外观: `dark`, `light` or `system`. + pub appearance: &'static str, + /// 界面语言, as the settings file spells it. + pub language: &'static str, + /// The built-in themes chosen, by key. + pub light_theme: &'static str, + pub dark_theme: &'static str, + pub ui_follows_theme: bool, + pub highlight: bool, + pub external_cli: bool, + pub auto_update: bool, + /// `stable` or `beta`. + pub update_channel: &'static str, + /// Whether any keyboard shortcut was changed. + pub custom_shortcuts: bool, + /// How many are saved; only their band goes out. + pub hosts: usize, + pub credentials: usize, + pub forwards: usize, + pub snippets: usize, +} + +impl Snapshot { + fn props(&self) -> Map { + let switch = |on: bool| if on { "on" } else { "off" }; + [ + ("arch", std::env::consts::ARCH), + ("appearance", self.appearance), + ("language", self.language), + ("light_theme", self.light_theme), + ("dark_theme", self.dark_theme), + ("ui_follows_theme", switch(self.ui_follows_theme)), + ("highlight", switch(self.highlight)), + ("external_cli", switch(self.external_cli)), + ("auto_update", switch(self.auto_update)), + ("update_channel", self.update_channel), + ( + "custom_shortcuts", + if self.custom_shortcuts { "yes" } else { "no" }, + ), + ("hosts", band(self.hosts)), + ("credentials", band(self.credentials)), + ("forwards", band(self.forwards)), + ("snippets", band(self.snippets)), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), Value::from(value))) + .collect() + } +} + +/// Roughly how many: enough to tell a few from hundreds. +fn band(count: usize) -> &'static str { + match count { + 0 => "0", + 1..=5 => "1-5", + 6..=20 => "6-20", + 21..=100 => "21-100", + _ => "100+", + } +} + +/// What the workspace tells the analytics thread. +#[derive(Clone, Debug, PartialEq)] +pub enum Signal { + /// ShellRS started. + Started(Snapshot), + /// The window came forward. + Active(Snapshot), + /// A feature was used. + Count(Counter), + /// 发送匿名使用统计 was switched. + Enabled(bool), +} + +/// One Aptabase event, before the system's part is added. +#[derive(Clone, Debug, PartialEq)] +pub struct Event { + pub name: &'static str, + pub timestamp: DateTime, + pub session_id: String, + pub props: Map, +} + +/// What Aptabase hears about the build and the machine with each event. +#[derive(Clone, Debug, PartialEq)] +pub struct SystemProps { + /// A development build's events go to the dashboard's debug view. + pub is_debug: bool, + pub os_name: String, + pub os_version: String, + pub app_version: String, +} + +/// The request body: `events` as a JSON array. Each field is cut to what +/// Aptabase takes, since one field too long fails the whole batch. +pub fn body(events: &[Event], system: &SystemProps) -> Vec { + let system = json!({ + "isDebug": system.is_debug, + "osName": clip(&system.os_name, 30), + "osVersion": clip(&system.os_version, 100), + "appVersion": clip(&system.app_version, 50), + "sdkVersion": SDK_VERSION, + }); + let events: Vec = events + .iter() + .map(|event| { + json!({ + "timestamp": event.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true), + "sessionId": event.session_id, + "eventName": event.name, + "systemProps": system, + "props": event.props, + }) + }) + .collect(); + serde_json::to_vec(&events).unwrap_or_default() +} + +fn clip(text: &str, chars: usize) -> String { + text.chars().take(chars).collect() +} + +/// Where an app key's events go: the key names Aptabase's region +/// (`A-US-…`). `None` for a key ShellRS cannot send with, a self-hosted or +/// malformed one. +pub fn endpoint(app_key: &str) -> Option<&'static str> { + let parts: Vec<&str> = app_key.split('-').collect(); + let [first, region, number] = parts.as_slice() else { + return None; + }; + if *first != "A" || number.is_empty() || !number.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + match *region { + "US" => Some("https://us.aptabase.com/api/v0/events"), + "EU" => Some("https://eu.aptabase.com/api/v0/events"), + _ => None, + } +} + +/// How a request went. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Delivery { + /// Aptabase has the events. + Sent, + /// The network or Aptabase failed for now: try again later. + Retry, + /// Aptabase refused them (a wrong key, an account over its quota): + /// sending them again would not help. + Rejected, +} + +/// Uses summed since a moment. Times are Unix seconds. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Tally { + /// The first use counted. + pub since: i64, + /// The latest. + pub last: i64, + pub counts: BTreeMap, +} + +impl Tally { + fn new(now: DateTime) -> Self { + Self { + since: now.timestamp(), + last: now.timestamp(), + counts: BTreeMap::new(), + } + } + + fn add(&mut self, counter: Counter, now: DateTime) { + let count = self.counts.entry(counter.key().to_string()).or_default(); + *count = count.saturating_add(1); + self.last = self.last.max(now.timestamp()); + } + + fn merge(&mut self, other: Tally) { + self.since = self.since.min(other.since); + self.last = self.last.max(other.last); + for (key, count) in other.counts { + let sum = self.counts.entry(key).or_default(); + *sum = sum.saturating_add(count); + } + } + + fn props(&self) -> Map { + self.counts + .iter() + .filter(|(_, count)| **count > 0) + .map(|(key, count)| (key.clone(), Value::from(*count))) + .collect() + } + + /// Only the counters this version knows, so whatever went out is one + /// of them; `None` when nothing is left. + fn known(mut self) -> Option { + self.counts + .retain(|key, count| *count > 0 && Counter::from_key(key).is_some()); + (!self.counts.is_empty()).then_some(self) + } +} + +/// What outlives the process, in `analytics.json`: whether this data +/// directory's installation was reported, and the uses not sent yet. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct State { + /// `app_installed` reached Aptabase. + pub installed: bool, + /// Uses since the last summary. + pub counting: Option, + /// Summaries that could not be sent yet, merged into one. + pub unsent: Option, +} + +impl State { + /// The state kept in `path`. None there, or one that cannot be read, + /// starts afresh: the worst that costs is an installation reported + /// twice. + pub fn load(path: &Path) -> Self { + let state: Self = fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default(); + Self { + installed: state.installed, + counting: state.counting.and_then(Tally::known), + unsent: state.unsent.and_then(Tally::known), + } + } + + /// Write it whole, through a temporary file. + pub fn save(&self, path: &Path) -> io::Result<()> { + let json = serde_json::to_vec(self)?; + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let temporary = path.with_extension("json.tmp"); + fs::write(&temporary, json)?; + fs::rename(&temporary, path) + } +} + +/// Aptabase's session: a new one after an hour without events. The id is +/// the start in seconds followed by eight random digits, as its SDKs make +/// them; the server reads the start back out of it. +#[derive(Default)] +struct Session { + id: String, + last: Option>, +} + +impl Session { + fn at(&mut self, now: DateTime) -> String { + if self.last.is_none_or(|last| now - last > SESSION_TIMEOUT) { + let random: u64 = rand::rng().random_range(0..=99_999_999); + let start = u64::try_from(now.timestamp()).unwrap_or(0); + self.id = (start * 100_000_000 + random).to_string(); + } + self.last = Some(now); + self.id.clone() + } +} + +/// A request's worth of events, and what `Reporter::settle` needs to know +/// about them. +#[derive(Debug)] +pub struct Batch { + pub events: Vec, + /// How many came from the front of the queue. + queued: usize, + /// Whether the last is the summary of `State::unsent`. + summary: bool, +} + +/// How soon uses go out, and failures are tried again. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Pace { + /// Uses summed for four hours; after a failure a minute's wait, twice + /// as long after each next, at most six hours. + #[default] + Release, + /// A development build being tried out, so what it does shows in the + /// dashboard soon, as Aptabase's SDKs flush every two seconds in debug: + /// uses summed for a minute, ten seconds' wait after any failure. + Debug, +} + +impl Pace { + fn summary_span(self) -> TimeDelta { + match self { + Pace::Release => SUMMARY_SPAN, + Pace::Debug => DEBUG_SUMMARY_SPAN, + } + } + + /// How long to wait after `failures` failures in a row. + fn wait_after(self, failures: u32) -> TimeDelta { + match self { + Pace::Release => { + let doublings = failures.saturating_sub(1).min(16); + TimeDelta::minutes(1 << doublings).min(LONGEST_WAIT) + } + Pace::Debug => DEBUG_WAIT, + } + } +} + +/// Works out what is sent and when, from the signals and the time. The +/// thread around it only moves bytes. +#[derive(Default)] +pub struct Reporter { + state: State, + pace: Pace, + /// Lifecycle events not sent yet, oldest first. + queue: VecDeque, + session: Session, + /// The UTC day of the last `app_started` or `app_active`: Aptabase + /// counts devices by UTC day. + reported_day: Option, + /// After a failure, nothing is sent before this. + retry_at: Option>, + failures: u32, + /// Whether `state` changed since `take_changed` last asked. + changed: bool, +} + +impl Reporter { + pub fn new(state: State, pace: Pace) -> Self { + Self { + state, + pace, + ..Self::default() + } + } + + pub fn state(&self) -> &State { + &self.state + } + + /// Whether the state changed since last asked, and so wants saving. + pub fn take_changed(&mut self) -> bool { + std::mem::take(&mut self.changed) + } + + pub fn signal(&mut self, signal: Signal, now: DateTime) { + match signal { + Signal::Started(snapshot) => { + if !self.state.installed { + self.push("app_installed", snapshot.props(), now); + } + self.push("app_started", snapshot.props(), now); + self.reported_day = Some(now.date_naive()); + } + Signal::Active(snapshot) => { + if self.reported_day != Some(now.date_naive()) { + self.push("app_active", snapshot.props(), now); + self.reported_day = Some(now.date_naive()); + } + } + Signal::Count(counter) => { + self.state + .counting + .get_or_insert_with(|| Tally::new(now)) + .add(counter, now); + self.changed = true; + } + Signal::Enabled(true) => {} + // Whatever was waiting goes; only knowing the installation was + // reported stays. Turned back on, the day is reported again. + Signal::Enabled(false) => { + self.queue.clear(); + let counted = self.state.counting.take().is_some(); + let unsent = self.state.unsent.take().is_some(); + self.changed |= counted || unsent; + self.reported_day = None; + self.retry_at = None; + self.failures = 0; + } + } + } + + fn push(&mut self, name: &'static str, props: Map, now: DateTime) { + let session_id = self.session.at(now); + if self.queue.len() == QUEUE_LIMIT { + self.queue.pop_front(); + } + self.queue.push_back(Event { + name, + timestamp: now, + session_id, + props, + }); + } + + /// The events to send now, or `None`: nothing is due, or a failure + /// asked to wait. Uses go out once four hours (a minute in a + /// development build) have passed since the first of them. + pub fn take_batch(&mut self, now: DateTime) -> Option { + let span = self.pace.summary_span().num_seconds(); + if let Some(counting) = self + .state + .counting + .take_if(|counting| now.timestamp() - counting.since >= span) + { + match &mut self.state.unsent { + Some(unsent) => unsent.merge(counting), + None => self.state.unsent = Some(counting), + } + self.changed = true; + } + if self.retry_at.is_some_and(|at| now < at) { + return None; + } + let oldest = now - MAX_AGE; + self.queue.retain(|event| event.timestamp >= oldest); + let summary = self.state.unsent.as_ref().map(|unsent| { + // When the uses were, as near as Aptabase still takes. + let last = DateTime::from_timestamp(unsent.last, 0).unwrap_or(now); + (last.clamp(oldest, now), unsent.props()) + }); + let room = BATCH_LIMIT - usize::from(summary.is_some()); + let queued = self.queue.len().min(room); + let mut events: Vec = self.queue.iter().take(queued).cloned().collect(); + let has_summary = summary.is_some(); + if let Some((timestamp, props)) = summary { + events.push(Event { + name: "usage", + timestamp, + session_id: self.session.at(now), + props, + }); + } + (!events.is_empty()).then_some(Batch { + events, + queued, + summary: has_summary, + }) + } + + /// Take in how `batch` went: sent or refused, its events go; failed, + /// they wait as long as the pace says. + pub fn settle(&mut self, batch: Batch, delivery: Delivery, now: DateTime) { + if delivery == Delivery::Retry { + self.failures = self.failures.saturating_add(1); + self.retry_at = Some(now + self.pace.wait_after(self.failures)); + return; + } + let installed = delivery == Delivery::Sent + && batch.events[..batch.queued] + .iter() + .any(|event| event.name == "app_installed"); + self.queue.drain(..batch.queued); + if batch.summary { + self.state.unsent = None; + self.changed = true; + } + if installed { + self.state.installed = true; + self.changed = true; + } + self.failures = 0; + self.retry_at = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(hour: u32, minute: u32) -> DateTime { + DateTime::parse_from_rfc3339(&format!("2026-10-07T{hour:02}:{minute:02}:00Z")) + .unwrap() + .to_utc() + } + + fn days_later(time: DateTime, days: i64) -> DateTime { + time + TimeDelta::days(days) + } + + fn names(batch: &Option) -> Vec<&'static str> { + batch + .iter() + .flat_map(|batch| batch.events.iter().map(|event| event.name)) + .collect() + } + + fn snapshot() -> Snapshot { + Snapshot { + appearance: "system", + language: "zh-CN", + light_theme: "shellrs-light", + dark_theme: "shellrs-dark", + update_channel: "stable", + hosts: 7, + ..Snapshot::default() + } + } + + fn system() -> SystemProps { + SystemProps { + is_debug: false, + os_name: "macOS".into(), + os_version: "15.1.0".into(), + app_version: "0.1.5".into(), + } + } + + /// A reporter that already sent this launch's events. + fn started_at(now: DateTime) -> Reporter { + let mut reporter = Reporter::new( + State { + installed: true, + ..State::default() + }, + Pace::Release, + ); + reporter.signal(Signal::Started(snapshot()), now); + let batch = reporter.take_batch(now).unwrap(); + reporter.settle(batch, Delivery::Sent, now); + reporter + } + + #[test] + fn the_region_comes_from_the_app_key() { + assert_eq!( + endpoint("A-US-1582708162"), + Some("https://us.aptabase.com/api/v0/events") + ); + assert_eq!( + endpoint("A-EU-1234567890"), + Some("https://eu.aptabase.com/api/v0/events") + ); + for key in [ + "A-SH-1234567890", + "A-DEV-1", + "A-US-", + "A-US-12x", + "B-US-1", + "A-US", + ] { + assert_eq!(endpoint(key), None, "{key}"); + } + assert!(endpoint(super::super::APP_KEY).is_some()); + } + + #[test] + fn the_body_is_what_aptabase_reads() { + let mut props = Map::new(); + props.insert("ssh".into(), Value::from(3)); + let event = Event { + name: "usage", + timestamp: at(9, 30), + session_id: "176000000012345678".into(), + props, + }; + let mut system = system(); + system.os_name = "x".repeat(40); + let json: Value = serde_json::from_slice(&body(&[event], &system)).unwrap(); + let event = &json[0]; + assert_eq!(event["timestamp"], "2026-10-07T09:30:00.000Z"); + assert_eq!(event["sessionId"], "176000000012345678"); + assert_eq!(event["eventName"], "usage"); + assert_eq!(event["props"]["ssh"], 3); + let props = &event["systemProps"]; + assert_eq!(props["isDebug"], false); + assert_eq!(props["osName"].as_str().unwrap().len(), 30); + assert_eq!(props["osVersion"], "15.1.0"); + assert_eq!(props["appVersion"], "0.1.5"); + assert!( + props["sdkVersion"] + .as_str() + .unwrap() + .starts_with("shellrs@") + ); + // Nothing about where the user is or what language they read. + assert!(props.get("locale").is_none()); + } + + #[test] + fn the_snapshot_holds_bands_and_switches() { + let props = snapshot().props(); + assert_eq!(props["arch"], std::env::consts::ARCH); + assert_eq!(props["hosts"], "6-20"); + assert_eq!(props["credentials"], "0"); + assert_eq!(props["highlight"], "off"); + assert_eq!(props["custom_shortcuts"], "no"); + assert!(props.values().all(Value::is_string)); + assert_eq!(band(5), "1-5"); + assert_eq!(band(100), "21-100"); + assert_eq!(band(101), "100+"); + } + + #[test] + fn every_counter_has_a_key_of_its_own() { + let mut keys: Vec<&str> = Counter::ALL.iter().map(|c| c.key()).collect(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), Counter::ALL.len()); + assert!(keys.iter().all(|key| key.len() <= 40)); + for counter in Counter::ALL { + assert_eq!(Counter::from_key(counter.key()), Some(counter)); + } + } + + #[test] + fn the_first_start_also_reports_the_installation() { + let mut reporter = Reporter::new(State::default(), Pace::Release); + reporter.signal(Signal::Started(snapshot()), at(9, 0)); + let batch = reporter.take_batch(at(9, 0)); + assert_eq!(names(&batch), ["app_installed", "app_started"]); + let batch = batch.unwrap(); + assert_eq!(batch.events[0].session_id, batch.events[1].session_id); + reporter.settle(batch, Delivery::Sent, at(9, 0)); + assert!(reporter.state().installed); + assert!(reporter.take_changed()); + assert!(reporter.take_batch(at(9, 1)).is_none()); + } + + #[test] + fn an_installation_counts_as_reported_only_once_it_arrived() { + let mut reporter = Reporter::new(State::default(), Pace::Release); + reporter.signal(Signal::Started(snapshot()), at(9, 0)); + let batch = reporter.take_batch(at(9, 0)).unwrap(); + reporter.settle(batch, Delivery::Rejected, at(9, 0)); + assert!(!reporter.state().installed); + } + + #[test] + fn a_later_start_reports_only_itself() { + let mut reporter = Reporter::new( + State { + installed: true, + ..State::default() + }, + Pace::Release, + ); + reporter.signal(Signal::Started(snapshot()), at(9, 0)); + assert_eq!(names(&reporter.take_batch(at(9, 0))), ["app_started"]); + } + + #[test] + fn the_window_coming_forward_reports_each_day_once() { + let mut reporter = started_at(at(9, 0)); + reporter.signal(Signal::Active(snapshot()), at(15, 0)); + assert!(reporter.take_batch(at(15, 0)).is_none()); + let tomorrow = days_later(at(1, 0), 1); + reporter.signal(Signal::Active(snapshot()), tomorrow); + let batch = reporter.take_batch(tomorrow); + assert_eq!(names(&batch), ["app_active"]); + reporter.settle(batch.unwrap(), Delivery::Sent, tomorrow); + reporter.signal(Signal::Active(snapshot()), tomorrow + TimeDelta::hours(2)); + assert!( + reporter + .take_batch(tomorrow + TimeDelta::hours(2)) + .is_none() + ); + } + + #[test] + fn uses_go_out_four_hours_after_the_first() { + let mut reporter = started_at(at(9, 0)); + reporter.signal(Signal::Count(Counter::Ssh), at(9, 10)); + reporter.signal(Signal::Count(Counter::Ssh), at(10, 0)); + reporter.signal(Signal::Count(Counter::Upload), at(12, 0)); + assert!(reporter.take_changed()); + assert!(reporter.take_batch(at(13, 9)).is_none()); + let batch = reporter.take_batch(at(13, 10)); + assert_eq!(names(&batch), ["usage"]); + let batch = batch.unwrap(); + let usage = &batch.events[0]; + assert_eq!(usage.props["ssh"], 2); + assert_eq!(usage.props["upload"], 1); + assert_eq!(usage.props.len(), 2); + // Stamped when the uses were. + assert_eq!(usage.timestamp, at(12, 0)); + reporter.settle(batch, Delivery::Sent, at(13, 10)); + assert_eq!(reporter.state().counting, None); + assert_eq!(reporter.state().unsent, None); + // The next uses start a summary of their own. + reporter.signal(Signal::Count(Counter::Find), at(14, 0)); + assert!(reporter.take_batch(at(14, 0)).is_none()); + } + + #[test] + fn uses_from_an_earlier_run_go_out_with_the_start() { + let first = started_at(at(9, 0)); + let mut first = first; + first.signal(Signal::Count(Counter::Sftp), at(10, 0)); + let saved = first.state().clone(); + // Quit before the four hours; started again the next day. + let mut reporter = Reporter::new(saved, Pace::Release); + let tomorrow = days_later(at(8, 0), 1); + reporter.signal(Signal::Started(snapshot()), tomorrow); + let batch = reporter.take_batch(tomorrow); + assert_eq!(names(&batch), ["app_started", "usage"]); + assert_eq!(batch.unwrap().events[1].timestamp, at(10, 0)); + } + + #[test] + fn uses_kept_for_days_are_stamped_as_late_as_aptabase_takes() { + let mut reporter = started_at(at(9, 0)); + reporter.signal(Signal::Count(Counter::Find), at(10, 0)); + let later = days_later(at(12, 0), 3); + reporter.signal(Signal::Active(snapshot()), later); + let batch = reporter.take_batch(later).unwrap(); + let usage = batch.events.iter().find(|e| e.name == "usage").unwrap(); + assert_eq!(usage.timestamp, later - MAX_AGE); + } + + #[test] + fn a_summary_that_failed_is_sent_with_the_next() { + let mut reporter = started_at(at(0, 0)); + reporter.signal(Signal::Count(Counter::Ssh), at(0, 10)); + let batch = reporter.take_batch(at(4, 10)).unwrap(); + reporter.settle(batch, Delivery::Retry, at(4, 10)); + reporter.signal(Signal::Count(Counter::Ssh), at(5, 0)); + reporter.signal(Signal::Count(Counter::Download), at(6, 0)); + // Sent again once the wait is over, on the next signal; the newer + // uses are still summing. + let batch = reporter.take_batch(at(6, 0)).unwrap(); + assert_eq!(batch.events.len(), 1); + assert_eq!(batch.events[0].props["ssh"], 1); + reporter.settle(batch, Delivery::Retry, at(6, 0)); + let batch = reporter.take_batch(at(9, 30)).unwrap(); + assert_eq!(batch.events[0].props["ssh"], 2); + assert_eq!(batch.events[0].props["download"], 1); + } + + #[test] + fn failures_wait_longer_each_time_up_to_six_hours() { + assert_eq!(Pace::Release.wait_after(1), TimeDelta::minutes(1)); + assert_eq!(Pace::Release.wait_after(2), TimeDelta::minutes(2)); + assert_eq!(Pace::Release.wait_after(5), TimeDelta::minutes(16)); + assert_eq!( + Pace::Release.wait_after(9), + TimeDelta::hours(4) + TimeDelta::minutes(16) + ); + assert_eq!(Pace::Release.wait_after(10), TimeDelta::hours(6)); + assert_eq!(Pace::Release.wait_after(u32::MAX), TimeDelta::hours(6)); + + let mut reporter = Reporter::new(State::default(), Pace::Release); + reporter.signal(Signal::Started(snapshot()), at(9, 0)); + let batch = reporter.take_batch(at(9, 0)).unwrap(); + reporter.settle(batch, Delivery::Retry, at(9, 0)); + // Nothing goes out while waiting, whatever happens meanwhile. + reporter.signal(Signal::Count(Counter::Ssh), at(9, 0)); + assert!(reporter.take_batch(at(9, 0)).is_none()); + let batch = reporter.take_batch(at(9, 1)).unwrap(); + assert_eq!(names(&Some(batch)), ["app_installed", "app_started"]); + } + + #[test] + fn a_development_build_sums_up_by_the_minute_and_retries_soon() { + let start = at(9, 0); + let mut reporter = Reporter::new(State::default(), Pace::Debug); + reporter.signal(Signal::Started(snapshot()), start); + reporter.signal(Signal::Count(Counter::Ssh), start); + let batch = reporter.take_batch(start); + assert_eq!(names(&batch), ["app_installed", "app_started"]); + reporter.settle(batch.unwrap(), Delivery::Retry, start); + assert!(reporter.take_batch(start + TimeDelta::seconds(9)).is_none()); + let soon = start + TimeDelta::seconds(10); + let batch = reporter.take_batch(soon).unwrap(); + reporter.settle(batch, Delivery::Sent, soon); + assert!(reporter.take_batch(soon).is_none()); + let batch = reporter.take_batch(at(9, 1)); + assert_eq!(names(&batch), ["usage"]); + assert_eq!(batch.unwrap().events[0].props["ssh"], 1); + assert_eq!(Pace::Debug.wait_after(8), DEBUG_WAIT); + } + + #[test] + fn a_refused_batch_is_dropped() { + let mut reporter = started_at(at(0, 0)); + reporter.signal(Signal::Count(Counter::Ssh), at(0, 0)); + let batch = reporter.take_batch(at(5, 0)).unwrap(); + reporter.settle(batch, Delivery::Rejected, at(5, 0)); + assert!(reporter.take_batch(at(5, 0)).is_none()); + assert_eq!(reporter.state().unsent, None); + } + + #[test] + fn a_batch_holds_at_most_twenty_five_events() { + let mut reporter = Reporter::new( + State { + installed: true, + ..State::default() + }, + Pace::Release, + ); + for minute in 0..40 { + reporter.signal(Signal::Started(snapshot()), at(9, minute)); + } + reporter.signal(Signal::Count(Counter::Ssh), at(5, 0)); + let batch = reporter.take_batch(at(9, 40)).unwrap(); + assert_eq!(batch.events.len(), 25); + assert_eq!(batch.events.last().unwrap().name, "usage"); + reporter.settle(batch, Delivery::Sent, at(9, 40)); + assert_eq!(reporter.take_batch(at(9, 40)).unwrap().events.len(), 16); + } + + #[test] + fn events_waiting_too_long_are_dropped() { + let mut reporter = Reporter::new( + State { + installed: true, + ..State::default() + }, + Pace::Release, + ); + for _ in 0..60 { + reporter.signal(Signal::Started(snapshot()), at(9, 0)); + } + assert_eq!(reporter.queue.len(), QUEUE_LIMIT); + let batch = reporter.take_batch(at(9, 0)).unwrap(); + reporter.settle(batch, Delivery::Retry, at(9, 0)); + assert!(reporter.take_batch(days_later(at(9, 0), 1)).is_none()); + assert!(reporter.queue.is_empty()); + } + + #[test] + fn turning_it_off_forgets_all_but_the_installation() { + let mut reporter = started_at(at(9, 0)); + reporter.take_changed(); + reporter.signal(Signal::Count(Counter::Ssh), at(9, 5)); + reporter.signal(Signal::Active(snapshot()), days_later(at(9, 0), 1)); + reporter.signal(Signal::Enabled(false), days_later(at(9, 1), 1)); + assert!(reporter.take_changed()); + assert_eq!( + reporter.state(), + &State { + installed: true, + ..State::default() + } + ); + assert!(reporter.take_batch(days_later(at(15, 0), 1)).is_none()); + // Back on, the day is reported again. + reporter.signal(Signal::Enabled(true), days_later(at(16, 0), 1)); + reporter.signal(Signal::Active(snapshot()), days_later(at(16, 0), 1)); + assert_eq!( + names(&reporter.take_batch(days_later(at(16, 0), 1))), + ["app_active"] + ); + } + + #[test] + fn a_session_lasts_until_an_hour_without_events() { + let mut session = Session::default(); + let first = session.at(at(9, 0)); + assert!(first.starts_with(&at(9, 0).timestamp().to_string())); + assert_eq!(first.len(), 18); + assert_eq!(session.at(at(9, 50)), first); + assert_eq!(session.at(at(10, 49)), first); + let next = session.at(at(11, 50)); + assert_ne!(next, first); + assert!(next.starts_with(&at(11, 50).timestamp().to_string())); + } + + #[test] + fn the_state_is_kept_on_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("analytics.json"); + assert_eq!(State::load(&path), State::default()); + let mut tally = Tally::new(at(9, 0)); + tally.add(Counter::Upload, at(9, 30)); + let state = State { + installed: true, + counting: Some(tally), + unsent: None, + }; + state.save(&path).unwrap(); + assert_eq!(State::load(&path), state); + } + + #[test] + fn a_state_file_that_cannot_be_read_starts_afresh() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("analytics.json"); + fs::write(&path, "{not json").unwrap(); + assert_eq!(State::load(&path), State::default()); + // Counters this version does not know are left out. + fs::write( + &path, + r#"{"installed":true,"counting":{"since":1,"last":2,"counts":{"ssh":2,"later":5}}, + "unsent":{"since":1,"last":2,"counts":{"later":1}}}"#, + ) + .unwrap(); + let state = State::load(&path); + assert!(state.installed); + assert_eq!( + state.counting.unwrap().counts, + BTreeMap::from([("ssh".to_string(), 2)]) + ); + assert_eq!(state.unsent, None); + } + + #[test] + fn a_state_that_cannot_be_written_says_so() { + let dir = tempfile::tempdir().unwrap(); + let blocked = dir.path().join("file"); + fs::write(&blocked, "").unwrap(); + assert!( + State::default() + .save(&blocked.join("analytics.json")) + .is_err() + ); + } +} diff --git a/src/analytics/system.rs b/src/analytics/system.rs new file mode 100644 index 0000000..77bfe20 --- /dev/null +++ b/src/analytics/system.rs @@ -0,0 +1,97 @@ +//! The machine as Aptabase hears of it: the system's name and version. + +use os_info::{Type, Version}; + +/// The operating system, named as Aptabase's dashboard knows them: +/// `macOS`, `Windows`, or a Linux distribution such as `Ubuntu`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SystemInfo { + pub os_name: String, + /// Empty when the system does not say. + pub os_version: String, +} + +impl SystemInfo { + /// Ask the system. It may start a process or two (`uname`, + /// `lsb_release`, `sw_vers`), so only the analytics thread asks. + pub fn detect() -> Self { + let info = os_info::get(); + Self::from_os(info.os_type(), info.version()) + } + + fn from_os(kind: Type, version: &Version) -> Self { + let os_name = match kind { + // os_info's own name for it is "Mac OS". + Type::Macos => "macOS".to_string(), + Type::Windows => "Windows".to_string(), + Type::Unknown => std::env::consts::OS.to_string(), + kind => kind.to_string(), + }; + let os_version = match version { + Version::Unknown => String::new(), + version => version.to_string(), + }; + Self { + os_name, + os_version, + } + } + + /// `ShellRS/0.1.5 (macOS 15.1.0; aarch64)`. Aptabase tells the devices + /// behind one address apart by it, so it says what differs between + /// machines and nothing that names one. + pub fn user_agent(&self, version: &str) -> String { + let system = format!("{} {}", self.os_name, self.os_version); + format!( + "ShellRS/{version} ({}; {})", + system.trim(), + std::env::consts::ARCH + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn systems_are_named_as_aptabase_knows_them() { + let macos = SystemInfo::from_os(Type::Macos, &Version::Semantic(15, 1, 0)); + assert_eq!(macos.os_name, "macOS"); + assert_eq!(macos.os_version, "15.1.0"); + let windows = SystemInfo::from_os(Type::Windows, &Version::Semantic(10, 0, 22631)); + assert_eq!(windows.os_name, "Windows"); + assert_eq!(windows.os_version, "10.0.22631"); + let ubuntu = SystemInfo::from_os(Type::Ubuntu, &Version::Custom("24.04".into())); + assert_eq!(ubuntu.os_name, "Ubuntu"); + let unknown = SystemInfo::from_os(Type::Unknown, &Version::Unknown); + assert_eq!(unknown.os_name, std::env::consts::OS); + assert_eq!(unknown.os_version, ""); + } + + #[test] + fn the_user_agent_names_the_system_and_architecture() { + let macos = SystemInfo::from_os(Type::Macos, &Version::Semantic(15, 1, 0)); + assert_eq!( + macos.user_agent("0.1.5"), + format!("ShellRS/0.1.5 (macOS 15.1.0; {})", std::env::consts::ARCH) + ); + let bare = SystemInfo::from_os(Type::Unknown, &Version::Unknown); + assert_eq!( + bare.user_agent("0.1.5"), + format!( + "ShellRS/0.1.5 ({}; {})", + std::env::consts::OS, + std::env::consts::ARCH + ) + ); + } + + #[test] + fn this_machine_has_a_name() { + // os_info starts `uname` and the like. + let _forks = crate::testing::no_forks(); + let info = SystemInfo::detect(); + assert!(!info.os_name.is_empty()); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index c9cd90a..393eafd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,8 +12,8 @@ pub use actions::*; pub use app_icon::show_logo_when_unbundled; pub use assets::{AppAssets, CatalogIcon, DOCKER_ICON}; pub use paths::{ - cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, settings_path, updates_dir, - window_state_path, + analytics_path, cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, + settings_path, updates_dir, window_state_path, }; pub use quit::{quit_held_back, set_quit_guard}; pub use shortcuts::{ diff --git a/src/app/paths.rs b/src/app/paths.rs index c132200..b9b455c 100644 --- a/src/app/paths.rs +++ b/src/app/paths.rs @@ -57,6 +57,13 @@ pub fn window_state_path() -> PathBuf { data_dir().join("window.json") } +/// What 匿名使用统计 keeps between runs: whether this installation was +/// reported, and the uses not sent yet. Beside the database, so a fresh +/// data directory is a fresh installation. +pub fn analytics_path() -> PathBuf { + data_dir().join("analytics.json") +} + /// Where the running app listens for the `shellrs` command. The command /// and the app both ask here, so both follow `SHELLRS_DATA_DIR`. pub fn cli_socket_path() -> PathBuf { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 564317d..5f339d9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -46,7 +46,7 @@ pub use protocol::{ CredentialKindChoice, ErrorCode, HostDeleted, HostDetails, HostFields, HostInfo, ProxyChoice, Reply, Request, RouteDetails, RouteFields, Secret, TransferCounters, TransferSummary, }; -pub use server::{ChangeReply, CliBackend, CliServer, CliTarget}; +pub use server::{ChangeReply, CliBackend, CliServer, CliTarget, CliUse}; const AFTER_HELP: &str = "\ Hosts are named by the 16-character ID that `shellrs hosts list` prints (the one ShellRS copies with 复制 ID). diff --git a/src/cli/server.rs b/src/cli/server.rs index 0f5b4e5..c02f9b9 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -189,6 +189,43 @@ struct Shared { /// them up: only the UI thread can change the store. changes: Mutex>, next_change: AtomicU64, + /// What kinds of command were served, for 匿名使用统计, until the app + /// takes them. + usage: Mutex>, +} + +/// The kind of a `shellrs` command, as 匿名使用统计 counts them: nothing +/// of what it asked for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CliUse { + Exec, + Upload, + Download, + Sync, + Hosts, + Credentials, +} + +impl CliUse { + fn of(request: &Request) -> Option { + match request { + Request::Exec { .. } => Some(Self::Exec), + Request::Upload { .. } => Some(Self::Upload), + Request::Download { .. } => Some(Self::Download), + Request::Sync { .. } => Some(Self::Sync), + Request::List { .. } + | Request::ShowHost { .. } + | Request::CreateHost { .. } + | Request::UpdateHost { .. } + | Request::DeleteHost { .. } => Some(Self::Hosts), + Request::ListCredentials { .. } + | Request::ShowCredential { .. } + | Request::CreateCredential { .. } + | Request::UpdateCredential { .. } + | Request::DeleteCredential { .. } => Some(Self::Credentials), + Request::Activate { .. } => None, + } + } } /// Listens on the CLI socket until dropped. Always listening, even with @@ -212,6 +249,7 @@ impl CliServer { activation: Mutex::new(None), changes: Mutex::new(VecDeque::new()), next_change: AtomicU64::new(0), + usage: Mutex::new(Vec::new()), }); let listener = listen(endpoint, shared.clone())?; Ok(Self { @@ -269,6 +307,18 @@ impl CliServer { .unwrap_or_else(|error| error.into_inner()) .take() } + + /// The kinds of command served since this was last asked, oldest first. + /// Asked on the same timer as `take_activation`. + pub fn take_usage(&self) -> Vec { + std::mem::take( + &mut *self + .shared + .usage + .lock() + .unwrap_or_else(|error| error.into_inner()), + ) + } } impl Drop for CliServer { @@ -554,6 +604,13 @@ fn respond( "ShellRS 未启用外部 CLI:请在 ShellRS 的 设置 → 外部 CLI 中打开「启用外部 CLI」", )); } + if let Some(usage) = CliUse::of(&envelope.request) { + shared + .usage + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(usage); + } match envelope.request { Request::List { query } => { let targets = shared diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 8efa25e..db6fbef 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -17,7 +17,7 @@ use super::protocol::{ AuthChoice, CliError, CredentialDetails, ErrorCode, HostDetails, HostFields, HostInfo, Reply, Request, RouteDetails, Secret, TransferCounters, TransferSummary, }; -use super::server::{CliBackend, CliServer, CliTarget}; +use super::server::{CliBackend, CliServer, CliTarget, CliUse}; use super::{Cli, Command, CredentialsCommand, HostsCommand}; use super::{ConsoleText, exec_request, normalize_command}; use crate::app::cli_endpoint; @@ -366,6 +366,27 @@ fn without_the_app_the_command_says_it_is_not_running() { } } +#[test] +fn the_kinds_of_command_served_are_counted_and_nothing_else() { + let fixture = fixture(); + run(&fixture.socket, Request::List { query: None }, true); + run( + &fixture.socket, + Request::Exec { + host: id(&fixture.web), + command: "uname -a".into(), + }, + false, + ); + assert_eq!(fixture.server.take_usage(), [CliUse::Hosts, CliUse::Exec]); + assert_eq!(fixture.server.take_usage(), []); + // Refused, or ShellRS opened again: not a command served. + fixture.server.set_enabled(false); + run(&fixture.socket, Request::List { query: None }, true); + assert!(client::activate_running_app(&fixture.socket, None)); + assert_eq!(fixture.server.take_usage(), []); +} + #[test] fn opening_shellrs_again_brings_the_running_one_forward() { let fixture = fixture(); diff --git a/src/lib.rs b/src/lib.rs index cb0577f..8579793 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,10 @@ //! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential` //! (凭据), `docker` (Docker), `monitor` (系统监控), `services` (系统服务), `processes` (进程管理), //! `netstat` (网络连接), `history` (历史命令), `snippets` (命令片段), `editor` (内置编辑器), -//! `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与 -//! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源). +//! `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `analytics` +//! (匿名使用统计), `connection` 与 `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源). +pub mod analytics; pub mod app; pub mod cli; pub mod connection; diff --git a/src/settings/mod.rs b/src/settings/mod.rs index d9bd0ba..5b3efe5 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -11,9 +11,9 @@ mod terminal_themes; pub use apply::apply; pub use model::{ - AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, NotificationSettings, - TerminalFontSettings, TerminalHighlightSettings, TerminalThemeSettings, UpdateSettings, - WindowSettings, + AnalyticsSettings, AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, + NotificationSettings, TerminalFontSettings, TerminalHighlightSettings, TerminalThemeSettings, + UpdateSettings, WindowSettings, }; pub use settings_panel::{SettingsPanel, SettingsPanelEvent}; pub use store::{SettingsStore, SettingsStoreEvent}; diff --git a/src/settings/model.rs b/src/settings/model.rs index 53ffb9e..b82c650 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -23,6 +23,8 @@ pub struct AppSettings { pub terminal_font: TerminalFontSettings, pub external_cli: ExternalCliSettings, pub update: UpdateSettings, + /// 关于 → 隐私. + pub analytics: AnalyticsSettings, /// The SFTP 大小 column, switched from the column title's menu rather /// than the settings page, as in WinSCP. pub file_size_format: FileSizeFormat, @@ -213,6 +215,20 @@ impl Default for UpdateSettings { } } +/// 关于 → 隐私. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AnalyticsSettings { + /// 发送匿名使用统计. On unless the user turns it off. + pub enabled: bool, +} + +impl Default for AnalyticsSettings { + fn default() -> Self { + Self { enabled: true } + } +} + impl Choice for Channel { const ALL: &'static [Self] = &[Self::Stable, Self::Beta]; @@ -576,6 +592,17 @@ mod tests { assert!(!settings.update.automatic); } + #[test] + fn usage_statistics_are_sent_unless_the_file_says_otherwise() { + let settings: AppSettings = serde_json::from_str("{}").unwrap(); + assert!(settings.analytics.enabled); + let settings: AppSettings = serde_json::from_str(r#"{"analytics":{}}"#).unwrap(); + assert!(settings.analytics.enabled); + let settings: AppSettings = + serde_json::from_str(r#"{"analytics":{"enabled":false}}"#).unwrap(); + assert!(!settings.analytics.enabled); + } + #[test] fn the_update_channel_is_the_builds_until_one_is_chosen() { // A development build has no channel of its own. diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index aa67e20..f3bab05 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -619,10 +619,11 @@ fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec { } /// 关于: 应用更新, as three rows: the version with where updating stands, -/// the channel, and 自动升级. +/// the channel, and 自动升级; then 隐私, whether usage statistics are sent. fn about_groups(panel: &SettingsPanel, cx: &App) -> Vec { let snapshot = panel.updater.read(cx).snapshot(); let (reader, writer) = (panel.store.clone(), panel.store.clone()); + let (analytics_reader, analytics_writer) = (panel.store.clone(), panel.store.clone()); vec![ SettingGroup::new().title("应用更新").items([ current_version_item(snapshot), @@ -649,6 +650,22 @@ fn about_groups(panel: &SettingsPanel, cx: &App) -> Vec { ) .description("检测到新版本后自动下载,下载完成后点击「重启并安装」完成更新。"), ]), + SettingGroup::new().title("隐私").items([SettingItem::new( + "发送匿名使用统计", + SettingField::switch( + move |cx| analytics_reader.read(cx).settings().analytics.enabled, + move |enabled, cx| { + analytics_writer.update(cx, |store, cx| { + store.update(|settings| settings.analytics.enabled = enabled, cx) + }); + }, + ) + .default_value(true), + ) + .description( + "只发送版本、系统、CPU 架构和各功能的使用次数,用来改进 ShellRS;\ + 不含主机、凭据、命令或文件内容。", + )]), SettingGroup::new() .title("ShellRS") .items([SettingItem::new( diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 836ca5c..84f5f35 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -186,7 +186,10 @@ impl TerminalEngine { } match event.kind { - TerminalUiEventKind::Started => self.lifecycle = TerminalLifecycle::Running, + TerminalUiEventKind::Started => { + self.lifecycle = TerminalLifecycle::Running; + return Some(TerminalEvent::Started); + } TerminalUiEventKind::Wakeup => { self.runtime.wakeup_pending.store(false, Ordering::Release); } @@ -617,6 +620,9 @@ impl Drop for TerminalEngine { #[derive(Clone, Debug)] pub enum TerminalEvent { + /// The shell is there: connected, for a remote terminal, again after + /// each reconnection. + Started, PromptRequested(ConnectionPrompt), HostOsDetected(HostOs), /// Something to show the user while they look elsewhere. diff --git a/src/terminal/terminal_panel.rs b/src/terminal/terminal_panel.rs index 2c8101f..c708aa2 100644 --- a/src/terminal/terminal_panel.rs +++ b/src/terminal/terminal_panel.rs @@ -26,6 +26,8 @@ pub enum TerminalPanelEvent { Activated(RemoteTerminalId, HostId), Closed(RemoteTerminalId, HostId), StatusChanged(RemoteTerminalId, HostId), + /// Connected, again after each reconnection. + Connected(RemoteTerminalId, HostId), PromptRequested(RemoteTerminalId, HostId, ConnectionPrompt), HostOsDetected(HostId, HostOs), Notice(RemoteTerminalId, TerminalNotice), @@ -84,6 +86,9 @@ impl TerminalPanel { cx.subscribe( &terminal, |this, _, event: &TerminalEvent, cx| match event { + TerminalEvent::Started => { + cx.emit(TerminalPanelEvent::Connected(this.id, this.host_id)); + } TerminalEvent::PromptRequested(prompt) => { cx.emit(TerminalPanelEvent::PromptRequested( this.id, diff --git a/src/workspace/analytics.rs b/src/workspace/analytics.rs new file mode 100644 index 0000000..ee2dfb3 --- /dev/null +++ b/src/workspace/analytics.rs @@ -0,0 +1,199 @@ +//! 匿名使用统计 as the workspace runs it: the window coming forward, the +//! features used and the settings, told to the analytics thread. Nothing +//! here waits for anything: the thread works out what to send, and when. + +use gpui_kit::component::ThemeMode; +use gpui_kit::*; + +use crate::analytics::{Analytics, Counter, Snapshot}; +use crate::app::{ShortcutOverrides, ToolKind}; +use crate::host::{AuthKind, ForwardKind, HostId, Route}; +use crate::settings::Choice as _; + +use super::Workspace; + +/// The handle, and the switches as last seen, to count their changes. +pub(super) struct Reporting { + analytics: Analytics, + highlight: bool, + external_cli: bool, +} + +impl Workspace { + /// Report to `analytics` from now on. `Workspace::new` hands in the real + /// one; the tests one whose signals they read. + pub fn set_analytics( + &mut self, + analytics: Analytics, + window: &mut Window, + cx: &mut Context, + ) { + let settings = self.settings().read(cx).settings(); + self.analytics = Some(Reporting { + analytics, + highlight: settings.terminal_highlight.enabled, + external_cli: settings.external_cli.enabled, + }); + // The system's event, not a timer: a window left in front across + // midnight misses that day, which is close enough. + let subscription = cx.observe_window_activation(window, |this, window, cx| { + if window.is_window_active() { + this.report_active(cx); + } + }); + self._subscriptions.push(subscription); + } + + /// ShellRS has started, with what is set up. + pub(super) fn report_started(&self, cx: &App) { + if let Some(reporting) = &self.analytics { + reporting.analytics.started(self.usage_snapshot(cx)); + } + } + + fn report_active(&self, cx: &App) { + if let Some(reporting) = &self.analytics { + reporting.analytics.window_active(self.usage_snapshot(cx)); + } + } + + /// A use of a feature. + pub(super) fn count(&self, counter: Counter) { + if let Some(reporting) = &self.analytics { + reporting.analytics.count(counter); + } + } + + /// An SSH terminal of `host` connected, or an SFTP tab: counted, then + /// counted again by how. + pub(super) fn count_connection(&self, host: HostId, sftp: bool, cx: &App) { + if self.analytics.is_none() { + return; + } + let store = self.store.read(cx); + let Some(saved) = store.host(host) else { + return; + }; + let mut counters = Vec::new(); + if sftp { + counters.push(Counter::Sftp); + if store.is_external(host) { + counters.push(Counter::SftpExternal); + } else if store.is_temporary(host) { + counters.push(Counter::SftpTemporary); + } + } else { + counters.push(Counter::Ssh); + match saved.route { + Route::Direct => {} + Route::Jump(_) => counters.push(Counter::SshJump), + Route::Proxy(_) => counters.push(Counter::SshProxy), + } + if saved.credential.is_some() { + counters.push(Counter::SshCredential); + } else if saved.auth == AuthKind::NoPassword { + counters.push(Counter::SshNoPassword); + } + if store.is_external(host) { + counters.push(Counter::SshExternal); + } else if store.is_temporary(host) { + counters.push(Counter::SshTemporary); + } + } + for counter in counters { + self.count(counter); + } + } + + pub(super) fn count_forward(&self, kind: ForwardKind) { + self.count(match kind { + ForwardKind::Local => Counter::ForwardLocal, + ForwardKind::Remote => Counter::ForwardRemote, + ForwardKind::Dynamic => Counter::ForwardDynamic, + }); + } + + pub(super) fn count_tool(&self, tool: ToolKind) { + self.count(match tool { + ToolKind::Snippets => Counter::ToolSnippets, + ToolKind::History => Counter::ToolHistory, + ToolKind::Docker => Counter::ToolDocker, + ToolKind::Services => Counter::ToolServices, + ToolKind::Processes => Counter::ToolProcesses, + ToolKind::Connections => Counter::ToolNetstat, + ToolKind::Monitor => Counter::ToolMonitor, + }); + } + + /// The rules marked to start with ShellRS, which started with it. + pub(super) fn count_automatic_forwards(&self, cx: &App) { + let kinds: Vec = self + .store + .read(cx) + .forwards() + .iter() + .filter(|rule| rule.auto_start) + .map(|rule| rule.kind) + .collect(); + for kind in kinds { + self.count_forward(kind); + } + } + + /// 发送匿名使用统计 from the settings, and the switches whose changes + /// are counted. Turned on, today is reported if it was not yet. + pub(super) fn sync_analytics(&mut self, window: &Window, cx: &App) { + let settings = self.settings().read(cx).settings(); + let snapshot = self.usage_snapshot(cx); + let Some(reporting) = &mut self.analytics else { + return; + }; + let turned_on = settings.analytics.enabled && !reporting.analytics.is_enabled(); + reporting.analytics.set_enabled(settings.analytics.enabled); + let highlight = settings.terminal_highlight.enabled; + if highlight != reporting.highlight { + reporting.highlight = highlight; + reporting.analytics.count(if highlight { + Counter::HighlightOn + } else { + Counter::HighlightOff + }); + } + let external_cli = settings.external_cli.enabled; + if external_cli != reporting.external_cli { + reporting.external_cli = external_cli; + reporting.analytics.count(if external_cli { + Counter::ExternalCliOn + } else { + Counter::ExternalCliOff + }); + } + if turned_on && window.is_window_active() { + reporting.analytics.window_active(snapshot); + } + } + + /// How ShellRS is set up, in the terms the statistics use: values from + /// fixed sets, and how many hosts and such there are, never what they + /// are. + fn usage_snapshot(&self, cx: &App) -> Snapshot { + let settings = self.settings().read(cx).settings(); + let store = self.store.read(cx); + Snapshot { + appearance: settings.appearance.key(), + language: settings.language.key(), + light_theme: settings.terminal_theme.theme(ThemeMode::Light).key(), + dark_theme: settings.terminal_theme.theme(ThemeMode::Dark).key(), + ui_follows_theme: settings.terminal_theme.app_follows, + highlight: settings.terminal_highlight.enabled, + external_cli: settings.external_cli.enabled, + auto_update: settings.update.automatic, + update_channel: settings.update.channel.key(), + custom_shortcuts: settings.shortcuts != ShortcutOverrides::default(), + hosts: store.hosts().len(), + credentials: store.credentials().len(), + forwards: store.forwards().len(), + snippets: store.snippets().len(), + } + } +} diff --git a/src/workspace/cli_changes.rs b/src/workspace/cli_changes.rs index 1b40202..a0fb562 100644 --- a/src/workspace/cli_changes.rs +++ b/src/workspace/cli_changes.rs @@ -7,9 +7,10 @@ use std::time::Duration; use gpui_kit::*; +use crate::analytics::Counter; use crate::cli::{ - CliChange, CliError, CredentialDeleted, CredentialFields, ErrorCode, HostDeleted, HostFields, - Reply, + CliChange, CliError, CliUse, CredentialDeleted, CredentialFields, ErrorCode, HostDeleted, + HostFields, Reply, }; use crate::cli::{ CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets, @@ -43,6 +44,16 @@ impl Workspace { let server = this.cli_server.as_ref()?; let links = server.take_activation(); let change = server.take_change(); + for usage in server.take_usage() { + this.count(match usage { + CliUse::Exec => Counter::CliExec, + CliUse::Upload => Counter::CliUpload, + CliUse::Download => Counter::CliDownload, + CliUse::Sync => Counter::CliSync, + CliUse::Hosts => Counter::CliHosts, + CliUse::Credentials => Counter::CliCredentials, + }); + } if let Some(links) = links { crate::app::bring_forward(window, cx); for link in links { diff --git a/src/workspace/editors.rs b/src/workspace/editors.rs index 32019e3..02e3e8e 100644 --- a/src/workspace/editors.rs +++ b/src/workspace/editors.rs @@ -8,6 +8,7 @@ use gpui_kit::component::dock::{DockPlacement, panel_handle}; use gpui_kit::*; use super::Workspace; +use crate::analytics::Counter; use crate::app::{CenterTab, CloseEditor, EditorAction, EditorCommand, EditorShortcut}; use crate::editor::{EditorId, EditorKey, EditorPanel, EditorPanelEvent, EditorSource}; use crate::explorer::{ExplorerId, FileLocation}; @@ -70,6 +71,11 @@ impl Workspace { let Some(panel) = self.explorers.get(&explorer) else { return; }; + self.count(if location.is_remote() { + Counter::EditorRemote + } else { + Counter::EditorLocal + }); let source = match &location { FileLocation::Remote(_) => EditorSource::Remote { explorer: panel.downgrade(), diff --git a/src/workspace/forwards.rs b/src/workspace/forwards.rs index 6a84571..78697e0 100644 --- a/src/workspace/forwards.rs +++ b/src/workspace/forwards.rs @@ -132,6 +132,9 @@ impl Workspace { ) { self.forwards .update(cx, |forwards, cx| forwards.start(action.0, cx)); + if let Some(rule) = self.store.read(cx).forward(action.0) { + self.count_forward(rule.kind); + } } pub(super) fn on_stop_forward( diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index b161b96..ba82b2c 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -2,6 +2,7 @@ //! the right sidebar's tools, status bar, and the start page (recent hosts) //! the center shows while no tab is open. +mod analytics; mod cli_changes; mod credentials; mod dock_skin; diff --git a/src/workspace/notices.rs b/src/workspace/notices.rs index 7e2dfff..a26439b 100644 --- a/src/workspace/notices.rs +++ b/src/workspace/notices.rs @@ -9,6 +9,7 @@ use gpui_kit::component::{WindowExt as _, notification::Notification}; use gpui_kit::*; use super::Workspace; +use crate::analytics::Counter; use crate::app::CenterTab; use crate::settings::NotificationSettings; use crate::shared::RenamableTab as _; @@ -129,6 +130,11 @@ impl Workspace { return; } self.notice_times.insert(key, now); + self.count(match notice { + TerminalNotice::Program { .. } => Counter::NoticeProgram, + TerminalNotice::Bell { .. } => Counter::NoticeBell, + TerminalNotice::Keyword { .. } => Counter::NoticeKeyword, + }); let (title, body) = notice_text(&tab_title, notice); match delivery { Delivery::System => cx.show_system_notification(SystemNotification { diff --git a/src/workspace/tools.rs b/src/workspace/tools.rs index 73e85b9..1a9b0e8 100644 --- a/src/workspace/tools.rs +++ b/src/workspace/tools.rs @@ -17,6 +17,7 @@ use gpui_kit::component::{ }; use gpui_kit::*; +use crate::analytics::Counter; use crate::app::{ CenterTab, ControlContainers, ControlService, CopyCommand, EndProcess, EnterCommand, RefreshConnections, RefreshDocker, RefreshHistory, RefreshProcesses, RefreshServices, @@ -109,6 +110,7 @@ impl Workspace { self.tools .update(cx, |tools, cx| tools.show(tool, window, cx)); self.tool_sidebar_wanted = true; + self.count_tool(tool); } self.sync_tool_sidebar(window, cx); } @@ -217,6 +219,11 @@ impl Workspace { Ok(()) => { let focus = view.read(cx).focus_handle(); window.focus(&focus, cx); + // Only the snippets and the history put commands in. + self.count(match self.tools.read(cx).tool() { + ToolKind::History => Counter::CommandHistory, + _ => Counter::CommandSnippet, + }); } Err(why) => { let verb = if *run { "执行" } else { "输入" }; diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index ccd9d99..3ef14db 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -20,6 +20,7 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::editors::describe_files; +use crate::analytics::{Analytics, AnalyticsServices, Counter}; use crate::app::ExplorerDispatch as _; use crate::app::{ CenterTab, ClearTerminal, CloseActiveTab, CloseEditor, CloseExplorer, CloseLocalTerminal, @@ -42,7 +43,9 @@ use crate::connection::{ }; use crate::credential::CredentialPanel; use crate::editor::{EditorId, EditorPanel}; -use crate::explorer::{ExplorerId, ExplorerPanel, ExplorerPanelEvent, confirm_close_transfer}; +use crate::explorer::{ + ExplorerId, ExplorerPanel, ExplorerPanelEvent, PreviewKind, confirm_close_transfer, +}; use crate::forward::{ ForwardManager, ForwardManagerEvent, ForwardPanel, SharedForwardTransportProvider, SshForwardTransportProvider, @@ -58,7 +61,7 @@ use crate::settings::{ }; use crate::sftp::{ SharedLocalDirectoryProvider, SharedSftpTransportProvider, SshSftpTransportProvider, - SystemLocalDirectoryProvider, + SystemLocalDirectoryProvider, TransferDirection, }; use crate::shared::{commit_footer, confirm_danger, open_rename_tab_dialog}; use crate::terminal::{ @@ -171,6 +174,9 @@ pub struct Workspace { /// in UI tests, so it never reaches the network unless a test hands it /// fakes. pub(super) updater: Entity, + /// 匿名使用统计. Production only, like the CLI server: UI tests hand in + /// one that only records. + pub(super) analytics: Option, local_terminal_factory: SharedTerminalTransportFactory, remote_terminal_provider: SharedRemoteTerminalTransportProvider, sftp_provider: SharedSftpTransportProvider, @@ -266,6 +272,12 @@ impl Workspace { updater.set_services(UpdateServices::system(bundle), cx); updater.start(cx); }); + if let Some(services) = AnalyticsServices::system() { + let enabled = this.settings.read(cx).settings().analytics.enabled; + this.set_analytics(Analytics::start(services, enabled), window, cx); + this.report_started(cx); + this.count_automatic_forwards(cx); + } this } @@ -381,6 +393,7 @@ impl Workspace { crate::settings::apply(settings.read(cx).settings(), window, cx); this.sync_cli_server(cx); this.sync_updater(cx); + this.sync_analytics(window, cx); }), cx.subscribe_in( &updater, @@ -511,6 +524,7 @@ impl Workspace { cli_integration: cx.new(|_| CliIntegration::new(None)), cli_server: None, updater, + analytics: None, local_terminal_factory, remote_terminal_provider, sftp_provider, @@ -1099,6 +1113,7 @@ impl Workspace { new_local_terminal_panel(id, self.local_terminal_factory.clone(), window, cx); self._subscriptions.push(subscription); self.local_terminals.insert(id, panel.clone()); + self.count(Counter::LocalTerminal); self.dock_area.update(cx, |area, cx| { area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); }); @@ -1417,6 +1432,22 @@ impl Workspace { self.show_editor(&editor, window, cx); return; } + match action.command() { + ExplorerCommand::Enqueue(job) => self.count(match job.direction() { + TransferDirection::Upload => Counter::Upload, + TransferDirection::Download => Counter::Download, + }), + ExplorerCommand::Preview { remote, path } => { + if let Some((_, kind)) = panel.read(cx).preview_target(*remote, path.as_deref(), cx) + { + self.count(match kind { + PreviewKind::Image(_) => Counter::PreviewImage, + PreviewKind::Markdown => Counter::PreviewMarkdown, + }); + } + } + _ => {} + } if matches!(action.command(), ExplorerCommand::CloseConfirmed) { self.remove_explorer(action.explorer(), window, cx); } else { @@ -1669,6 +1700,7 @@ impl Workspace { ) { if let Some(terminal) = self.active_terminal(cx) { terminal.update(cx, |terminal, cx| terminal.open_find(window, cx)); + self.count(Counter::Find); } } @@ -2179,6 +2211,7 @@ fn new_terminal_panel( this.refresh_host_connection_state(*host_id, cx); cx.notify(); } + TerminalPanelEvent::Connected(_, host_id) => this.count_connection(*host_id, false, cx), TerminalPanelEvent::PromptRequested(terminal_id, host_id, prompt) => this .enqueue_prompt( PromptOwner::Terminal(*terminal_id), @@ -2301,6 +2334,12 @@ fn new_explorer_panel( this.cancel_prompts_for_owner(owner,window,cx); } } + // Only connecting, connected and disconnected change it. + if this.explorers.get(id).is_some_and(|panel| { + panel.read(cx).connection_state() == ConnectionState::Connected + }) { + this.count_connection(*host_id, true, cx); + } this.refresh_host_connection_state(*host_id, cx); }, ExplorerPanelEvent::StatusChanged(id) => { diff --git a/tests/ui/analytics.rs b/tests/ui/analytics.rs new file mode 100644 index 0000000..f3132f8 --- /dev/null +++ b/tests/ui/analytics.rs @@ -0,0 +1,172 @@ +//! 匿名使用统计: what the workspace tells the analytics thread. What the +//! thread makes of it, and sends, is tested in `shellrs::analytics`. + +use shellrs::analytics::{Analytics, Counter, Signal}; + +use crate::support::*; + +/// Have the workspace report to a channel the test reads. +fn report( + cx: &mut TestAppContext, + handle: WindowHandle, + workspace: &Entity, +) -> mpsc::Receiver { + let (sender, receiver) = mpsc::channel(); + cx.update_window(handle.into(), |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.set_analytics(Analytics::new(sender, true), window, cx) + }); + }) + .unwrap(); + cx.run_until_parked(); + receiver +} + +fn counted(signals: &[Signal]) -> Vec { + signals + .iter() + .filter_map(|signal| match signal { + Signal::Count(counter) => Some(*counter), + _ => None, + }) + .collect() +} + +fn came_forward(signals: &[Signal]) -> bool { + signals + .iter() + .any(|signal| matches!(signal, Signal::Active(_))) +} + +fn bring_forward(cx: &mut TestAppContext, handle: WindowHandle) { + gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window(); + in_frame(cx, handle, |window, _| window.activate_window()); +} + +#[gpui_kit::test] +fn the_window_coming_forward_is_reported_with_the_setup(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let signals = report(cx, handle, &workspace); + bring_forward(cx, handle); + let received: Vec = signals.try_iter().collect(); + let snapshot = received.iter().find_map(|signal| match signal { + Signal::Active(snapshot) => Some(snapshot.clone()), + _ => None, + }); + let snapshot = snapshot.expect("the window coming forward is reported"); + // How much is saved, never what. + assert_eq!(snapshot.hosts, 6); + assert_eq!(snapshot.appearance, "system"); + assert!(!snapshot.highlight); +} + +#[gpui_kit::test] +async fn the_features_used_are_counted(cx: &mut TestAppContext) { + let (store, host) = one_host_store(AuthKind::NoPassword); + let (handle, workspace) = open_workspace_with_store(cx, store); + let signals = report(cx, handle, &workspace); + + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(NewLocalTerminal), cx) + }); + assert_eq!( + counted(&signals.try_iter().collect::>()), + [Counter::LocalTerminal] + ); + + // An SSH terminal is counted once connected, and by how. + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(ConnectHost(host)), cx) + }); + let mut received = Vec::new(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| { + received.extend(signals.try_iter()); + counted(&received).contains(&Counter::Ssh) + }) + .await; + assert_eq!(counted(&received), [Counter::Ssh, Counter::SshNoPassword]); + + // With it in front: a tool of the right sidebar, and the find bar. + in_frame(cx, handle, |window, cx| window.click("tool-history", cx)); + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(FindInTerminal), cx) + }); + assert_eq!( + counted(&signals.try_iter().collect::>()), + [Counter::ToolHistory, Counter::Find] + ); + + // An SFTP tab, once connected. + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(OpenExplorer(host)), cx) + }); + let mut received = Vec::new(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| { + received.extend(signals.try_iter()); + counted(&received).contains(&Counter::Sftp) + }) + .await; + assert_eq!(counted(&received), [Counter::Sftp]); +} + +#[gpui_kit::test] +fn switches_turned_in_the_settings_are_counted(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let signals = report(cx, handle, &workspace); + let set_highlight = |cx: &mut TestAppContext, on: bool| { + workspace.update(cx, |workspace, cx| { + workspace.settings().update(cx, |settings, cx| { + settings.update(|settings| settings.terminal_highlight.enabled = on, cx) + }); + }); + cx.run_until_parked(); + }; + set_highlight(cx, true); + set_highlight(cx, false); + assert_eq!( + counted(&signals.try_iter().collect::>()), + [Counter::HighlightOn, Counter::HighlightOff] + ); +} + +#[gpui_kit::test] +fn turned_off_in_the_settings_nothing_is_reported(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let signals = report(cx, handle, &workspace); + let enabled = |cx: &mut TestAppContext| { + workspace.read_with(cx, |workspace, cx| { + workspace.settings().read(cx).settings().analytics.enabled + }) + }; + assert!(enabled(cx)); + + // 设置 › 关于 › 隐私 › 发送匿名使用统计. + in_frame(cx, handle, |window, cx| window.click("open-settings", cx)); + in_frame(cx, handle, |window, cx| { + window.within("settings").click("0-6", cx) + }); + in_frame(cx, handle, |window, cx| { + let switch = window + .within("settings") + .within("group-1") + .within("item-0") + .find("check"); + assert_eq!(switch.checked(), Some(true)); + window + .within("settings") + .within("group-1") + .within("item-0") + .click("check", cx); + }); + assert!(!enabled(cx)); + let received: Vec = signals.try_iter().collect(); + assert!(received.contains(&Signal::Enabled(false)), "{received:?}"); + + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(NewLocalTerminal), cx) + }); + bring_forward(cx, handle); + let received: Vec = signals.try_iter().collect(); + assert!(counted(&received).is_empty(), "{received:?}"); + assert!(!came_forward(&received), "{received:?}"); +} diff --git a/tests/ui/main.rs b/tests/ui/main.rs index 4b78d39..0a898e8 100644 --- a/tests/ui/main.rs +++ b/tests/ui/main.rs @@ -7,6 +7,7 @@ mod support; +mod analytics; mod cli; mod connection; mod credential;