接入 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:
1 parent
56491658a6
commit
cd3d41c496
32 files changed
+2383
-20
No files matched your search
@@ -15,6 +15,7 @@ on:
|
||||
branches: ["**"]
|
||||
paths:
|
||||
- "src/cli/**"
|
||||
- "src/analytics/**"
|
||||
- "src/update/**"
|
||||
- "src/app/paths.rs"
|
||||
- "src/app/window_hiding.rs"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
|
||||
|
||||
- 秘密只进系统钥匙串;主机信任记录存在 ShellRS 自己的 `known_hosts` 里,不改动 `~/.ssh`。
|
||||
- 在线升级:更新清单经 minisign 签名,安装包按 SHA-256 校验;后台下载,重启或退出时安装。分稳定版和 Beta 两个渠道,检查更新只发送版本号、系统和 CPU 架构。
|
||||
- 匿名使用统计:经 Aptabase 只发送版本、系统、CPU 架构和各功能的使用次数,不含主机、凭据、命令或任何标识,可在「设置 › 关于」关闭。详见[使用手册](docs/manual.md#隐私)。
|
||||
|
||||
## 截图
|
||||
|
||||
|
||||
+26
-2
@@ -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` 命令)本身也不发送。
|
||||
@@ -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)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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::{
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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};
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 { "输入" };
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
mod support;
|
||||
|
||||
mod analytics;
|
||||
mod cli;
|
||||
mod connection;
|
||||
mod credential;
|
||||
|
||||
Reference in new issue
Block a user