接入 Aptabase 匿名使用统计,「设置 › 关于」新增开关

- 自己调 Aptabase 的批量接口 POST /api/v0/events(没有纯 Rust SDK)。只发版本、系统名和版本、CPU 架构、设置快照(取值固定,主机等数量只报档位)和功能使用次数;不发主机、凭据、命令、文件名、路径,也没有任何标识
- 四种事件:app_installed(首次启动,送达后记进 analytics.json)、app_started、app_active(窗口到前台且当天 UTC 还没报过)、usage(43 项功能计数,从第一次使用起满 4 小时汇总一条)
- 不用定时器:只在启动、窗口激活、用到功能、改设置时动作,满期的汇总和失败重试都等下一个信号;计数存盘,退出不等网络、不丢数据
- 内网断网不受影响:联网、系统检测、写文件都在后台线程,连接 5 秒、整体 15 秒超时,失败不提示,重试从 1 分钟起翻倍、最长 6 小时,4xx 不重试
- 「设置 › 关于」新增「隐私」组的「发送匿名使用统计」,默认开,关掉清空本机攒下的计数
- 只有发布构建发;开发构建设 SHELLRS_ANALYTICS_DEBUG=1 才发调试事件,汇总周期 1 分钟、失败 10 秒后重试
- 手册新增「隐私」一节列出全部上报内容,README 同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-07 20:04:38 +08:00
1 parent 56491658a6
commit cd3d41c496
32 files changed
+2383 -20

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ on:
branches: ["**"]
paths:
- "src/cli/**"
- "src/analytics/**"
- "src/update/**"
- "src/app/paths.rs"
- "src/app/window_hiding.rs"
+13 -2
View File
@@ -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/<name>.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()` 没实现,所以这条只挂在生产路径上。
Generated
+38 -1
View File
@@ -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",
+3
View File
@@ -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.
+1
View File
@@ -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
+1
View File
@@ -91,6 +91,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
- 秘密只进系统钥匙串;主机信任记录存在 ShellRS 自己的 `known_hosts` 里,不改动 `~/.ssh`。
- 在线升级:更新清单经 minisign 签名,安装包按 SHA-256 校验;后台下载,重启或退出时安装。分稳定版和 Beta 两个渠道,检查更新只发送版本号、系统和 CPU 架构。
- 匿名使用统计:经 Aptabase 只发送版本、系统、CPU 架构和各功能的使用次数,不含主机、凭据、命令或任何标识,可在「设置 › 关于」关闭。详见[使用手册](docs/manual.md#隐私)。
## 截图
+26 -2
View File
@@ -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` 命令)本身也不发送。
+455
View File
@@ -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<String>, app_key: impl Into<String>) -> 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<dyn Transport>,
/// `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<Self> {
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<Signal>,
enabled: bool,
}
impl Analytics {
/// Signals go to `sender`. The tests read them off its receiver.
pub fn new(sender: Sender<Signal>, 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<Signal>) {
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<u8>,
}
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<Mutex<Vec<Seen>>>,
}
impl Server {
fn start(status: Option<u16>) -> 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<Seen> {
self.seen.lock().unwrap().clone()
}
}
fn serve(stream: TcpStream, status: Option<u16>, seen: &Mutex<Vec<Seen>>) {
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<Sender<Vec<u8>>>);
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<Signal> = receiver.try_iter().collect();
assert_eq!(
signals,
[
Signal::Count(Counter::Find),
Signal::Enabled(false),
Signal::Enabled(true)
]
);
}
}
+35
View File
@@ -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";
File diff suppressed because it is too large. Load diff
+97
View File
@@ -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());
}
}
+2 -2
View File
@@ -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::{
+7
View File
@@ -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 {
+1 -1
View File
@@ -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).
+57
View File
@@ -189,6 +189,43 @@ struct Shared {
/// them up: only the UI thread can change the store.
changes: Mutex<VecDeque<PendingChange>>,
next_change: AtomicU64,
/// What kinds of command were served, for 匿名使用统计, until the app
/// takes them.
usage: Mutex<Vec<CliUse>>,
}
/// 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<Self> {
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<CliUse> {
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
+22 -1
View File
@@ -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();
+3 -2
View File
@@ -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;
+3 -3
View File
@@ -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};
+27
View File
@@ -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.
+18 -1
View File
@@ -619,10 +619,11 @@ fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
}
/// 关于: 应用更新, 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<SettingGroup> {
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<SettingGroup> {
)
.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(
+7 -1
View File
@@ -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.
+5
View File
@@ -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,
+199
View File
@@ -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<Self>,
) {
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<ForwardKind> = 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(),
}
}
}
+13 -2
View File
@@ -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 {
+6
View File
@@ -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(),
+3
View File
@@ -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(
+1
View File
@@ -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;
+6
View File
@@ -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 {
+7
View File
@@ -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 { "输入" };
+41 -2
View File
@@ -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<Updater>,
/// 匿名使用统计. Production only, like the CLI server: UI tests hand in
/// one that only records.
pub(super) analytics: Option<super::analytics::Reporting>,
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) => {
+172
View File
@@ -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<Root>,
workspace: &Entity<Workspace>,
) -> mpsc::Receiver<Signal> {
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<Counter> {
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<Root>) {
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<Signal> = 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::<Vec<_>>()),
[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::<Vec<_>>()),
[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::<Vec<_>>()),
[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<Signal> = 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<Signal> = signals.try_iter().collect();
assert!(counted(&received).is_empty(), "{received:?}");
assert!(!came_forward(&received), "{received:?}");
}
+1
View File
@@ -7,6 +7,7 @@
mod support;
mod analytics;
mod cli;
mod connection;
mod credential;