Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
395 changes: 248 additions & 147 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ regex = "1.11.1"
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
windows = "0.62.2"
tempfile = "3.27.0"
ureq = { version = "3.1.2", default-features = false, features = ["json", "rustls", "gzip"] }
verhub-sdk = { version = "0.2.5", default-features = false, features = ["native-tls"] }

[profile.release]
opt-level = "z"
Expand Down
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

<img src="https://img.shields.io/github/v/release/IvanHanloth/Boss-Key?style=flat-square" alt="Github Release Version">
<img src="https://img.shields.io/github/license/IvanHanloth/Boss-Key?style=flat-square" alt="Github Repo License">
<img src="https://img.shields.io/github/actions/workflow/status/IvanHanloth/Boss-Key/tag-release.yml?style=flat-square" alt="GitHub Actions Workflow Status">
<img src="https://img.shields.io/github/actions/workflow/status/IvanHanloth/Boss-Key/release.yml?style=flat-square" alt="GitHub Actions Workflow Status">
<img src="https://img.shields.io/badge/Platform-Windows_10\+-cornflowerblue?style=flat-square" alt="Supported Platform">

</p>
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

<img src="https://img.shields.io/github/v/release/IvanHanloth/Boss-Key?style=flat-square" alt="Github Release Version">
<img src="https://img.shields.io/github/license/IvanHanloth/Boss-Key?style=flat-square" alt="Github Repo License">
<img src="https://img.shields.io/github/actions/workflow/status/IvanHanloth/Boss-Key/tag-release.yml?style=flat-square" alt="GitHub Actions Workflow Status">
<img src="https://img.shields.io/github/actions/workflow/status/IvanHanloth/Boss-Key/release.yml?style=flat-square" alt="GitHub Actions Workflow Status">
<img src="https://img.shields.io/badge/Platform-Windows_10\+-cornflowerblue?style=flat-square" alt="Supported Platform">

</p>
Expand Down
2 changes: 1 addition & 1 deletion README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

<img src="https://img.shields.io/github/v/release/IvanHanloth/Boss-Key?style=flat-square" alt="Github Release Version">
<img src="https://img.shields.io/github/license/IvanHanloth/Boss-Key?style=flat-square" alt="Github Repo License">
<img src="https://img.shields.io/github/actions/workflow/status/IvanHanloth/Boss-Key/tag-release.yml?style=flat-square" alt="GitHub Actions Workflow Status">
<img src="https://img.shields.io/github/actions/workflow/status/IvanHanloth/Boss-Key/release.yml?style=flat-square" alt="GitHub Actions Workflow Status">
<img src="https://img.shields.io/badge/Platform-Windows_10\+-cornflowerblue?style=flat-square" alt="Supported Platform">

</p>
Expand Down
1 change: 1 addition & 0 deletions apps/config/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ serde_json = { workspace = true }
bosskey-common = { path = "../../../crates/common" }
bosskey-core = { path = "../../../crates/core" }
tauri-plugin-single-instance = "2.4.2"
verhub-sdk = { workspace = true }
58 changes: 24 additions & 34 deletions apps/config/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ use std::time::Duration;
use bosskey_common::Config;
use bosskey_common::ipc::{Command, PipeClient, Response};
use bosskey_common::model::WindowInfo;
use bosskey_common::verhub;
use bosskey_core::i18n::{self, Msg};
use serde::Serialize;
use tauri::{Emitter, Manager};

mod verhub;

const CORE_EXE: &str = "Boss Key.exe";

fn exe_dir() -> PathBuf {
Expand Down Expand Up @@ -387,10 +388,10 @@ fn app_info() -> AppInfo {
}
}

/// 用系统默认浏览器打开外部链接。仅放行 http/https。
/// 用系统默认浏览器打开外部链接。仅放行 http/https/mailto(与前端 markdown 白名单一致)
#[tauri::command]
async fn open_external(url: String) -> Result<(), String> {
if !url.starts_with("https://") && !url.starts_with("http://") {
if !url.starts_with("https://") && !url.starts_with("http://") && !url.starts_with("mailto:") {
return Err(i18n::t(Msg::ErrUrlSchemeNotAllowed).to_string());
}
blocking(move || bosskey_core::shell::open(&url)).await
Expand All @@ -399,16 +400,17 @@ async fn open_external(url: String) -> Result<(), String> {
/// 检查更新。`required=true` 即强制更新,界面须阻断使用。
#[tauri::command]
async fn verhub_check_update(include_preview: bool) -> Result<verhub::CheckUpdate, String> {
blocking(move || {
verhub::check_update(env!("CARGO_PKG_VERSION"), include_preview).map_err(|e| e.to_string())
})
.await
verhub::check_update(env!("CARGO_PKG_VERSION"), include_preview)
.await
.map_err(|e| e.to_string())
}

/// 公告列表(本平台可见的,从新到旧)。
#[tauri::command]
async fn verhub_announcements(limit: u32) -> Result<Vec<verhub::Announcement>, String> {
blocking(move || verhub::announcements(limit.clamp(1, 50)).map_err(|e| e.to_string())).await
verhub::announcements(limit.clamp(1, 50))
.await
.map_err(|e| e.to_string())
}

/// `contact` 可空——留了才好回复用户。
Expand All @@ -421,38 +423,26 @@ async fn verhub_submit_feedback(
if content.trim().is_empty() {
return Err(i18n::t(Msg::ErrFeedbackEmpty).to_string());
}
blocking(move || {
let feedback = verhub::Feedback {
rating: rating.map(|r| r.clamp(1, 5)),
content,
platform: verhub::PLATFORM,
custom_data: serde_json::json!({
"app_version": env!("CARGO_PKG_VERSION"),
"os": os_description(),
"contact": contact.trim(),
}),
};
verhub::submit_feedback(&feedback).map_err(|e| e.to_string())
})
.await
let custom_data = serde_json::json!({
"app_version": env!("CARGO_PKG_VERSION"),
"os": os_description(),
"contact": contact.trim(),
});
verhub::submit_feedback(content, rating.map(|r| r.clamp(1, 5)), custom_data)
.await
.map_err(|e| e.to_string())
}

/// 上报一段日志
#[tauri::command]
async fn verhub_upload_log(content: String) -> Result<(), String> {
blocking(move || {
verhub::upload_log(
verhub::LogLevel::Error,
&content,
serde_json::json!({
"app_version": env!("CARGO_PKG_VERSION"),
"os": os_description(),

}),
)
let device_info = serde_json::json!({
"app_version": env!("CARGO_PKG_VERSION"),
"os": os_description(),
});
verhub::upload_log(&content, device_info)
.await
.map_err(|e| e.to_string())
})
.await
}

/// 最新日志文件的末尾若干行。
Expand Down
235 changes: 235 additions & 0 deletions apps/config/src-tauri/src/verhub.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
//! Verhub 客户端:版本 / 公告 / 反馈 / 日志,基于官方 verhub-sdk。
//!
//! 只用公开端点(无需凭据)。HTTP 由 SDK 完成;本模块把 SDK 的响应类型映射成
//! 前端 IPC 契约所需的可序列化 DTO,字段名保持不变。

use std::time::Duration;

use serde::Serialize;
use verhub_sdk::VerhubClient;
use verhub_sdk::models::{
AnnouncementItem, CheckUpdateInput, CreateFeedbackInput, JsonObject, ListAnnouncementsOptions,
LogLevel, Platform, UploadLogInput, VersionDownloadLink, VersionItem,
};

/// Verhub 基础路径。
pub const BASE_URL: &str = "https://verhub.hanloth.cn/api/v1";
pub const PROJECT_KEY: &str = "ivanhanloth-boss-key";
/// 客户端平台(本程序只发行 Windows 版)。
pub const PLATFORM: Platform = Platform::Windows;

const TIMEOUT: Duration = Duration::from_secs(10);
const LOG_CONTENT_MAX: usize = 4096;

type Result<T> = verhub_sdk::Result<T>;

/// 构造公开接口客户端;User-Agent 追加 `BossKey/{版本}` 以便服务端识别。
fn client() -> Result<VerhubClient> {
VerhubClient::builder(BASE_URL)
.project_key(PROJECT_KEY)
.platform(PLATFORM)
.timeout(TIMEOUT)
.app_identifier(concat!("BossKey/", env!("CARGO_PKG_VERSION")))
.build()
}

/// 把 `serde_json::Value` 收敛为 JSON 对象;非对象一律丢弃。
fn json_object(value: serde_json::Value) -> Option<JsonObject> {
match value {
serde_json::Value::Object(map) => Some(map),
_ => None,
}
}

#[derive(Debug, Clone, Default, Serialize)]
pub struct DownloadLink {
pub url: String,
pub name: Option<String>,
pub platform: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize)]
pub struct Version {
pub id: String,
pub version: String,
pub comparable_version: String,
pub title: Option<String>,
/// 更新说明(Markdown)。
pub content: Option<String>,
pub download_url: Option<String>,
pub download_links: Vec<DownloadLink>,
pub forced: bool,
pub is_latest: bool,
pub is_preview: bool,
pub is_milestone: bool,
pub is_deprecated: bool,
pub published_at: i64,
}

#[derive(Debug, Clone, Default, Serialize)]
pub struct CheckUpdate {
pub should_update: bool,
/// 强制更新。
pub required: bool,
pub reason_codes: Vec<String>,
pub current_version: Option<String>,
pub latest_version: Option<Version>,
/// 该升到哪个版本(可能是里程碑版本,而非最新版)。
pub target_version: Option<Version>,
}

#[derive(Debug, Clone, Default, Serialize)]
pub struct Announcement {
pub id: String,
pub title: String,
pub content: String,
pub is_pinned: bool,
pub is_hidden: bool,
pub author: Option<String>,
pub published_at: i64,
}

fn map_link(link: VersionDownloadLink) -> DownloadLink {
DownloadLink {
url: link.url,
name: link.name,
platform: link.platform,
}
}

fn map_version(version: VersionItem) -> Version {
Version {
id: version.id,
version: version.version,
comparable_version: version.comparable_version,
title: version.title,
content: version.content,
download_url: version.download_url,
download_links: version.download_links.into_iter().map(map_link).collect(),
forced: version.forced,
is_latest: version.is_latest,
is_preview: version.is_preview,
is_milestone: version.is_milestone,
is_deprecated: version.is_deprecated,
published_at: version.published_at,
}
}

fn map_announcement(item: AnnouncementItem) -> Announcement {
Announcement {
id: item.id,
title: item.title,
content: item.content,
is_pinned: item.is_pinned,
is_hidden: item.is_hidden,
author: item.author,
published_at: item.published_at,
}
}

/// 检查更新:把当前版本发给 Verhub,由服务端判断是否需要更新、是否强制。
pub async fn check_update(current_version: &str, include_preview: bool) -> Result<CheckUpdate> {
let input = CheckUpdateInput {
current_version: Some(current_version.to_string()),
current_comparable_version: Some(current_version.to_string()),
include_preview: Some(include_preview),
};
let resp = client()?.public().check_update(&input).await?;
Ok(CheckUpdate {
should_update: resp.should_update,
required: resp.required,
reason_codes: resp.reason_codes,
current_version: resp.current_version,
latest_version: Some(map_version(resp.latest_version)),
target_version: resp.target_version.map(map_version),
})
}

/// 公告列表(只要本平台 / 全平台的),从新到旧,并滤掉隐藏公告。
pub async fn announcements(limit: u32) -> Result<Vec<Announcement>> {
let options = ListAnnouncementsOptions {
limit: Some(limit),
platform: Some(PLATFORM),
..Default::default()
};
let resp = client()?.public().list_announcements(&options).await?;
Ok(resp
.data
.into_iter()
.filter(|a| !a.is_hidden)
.map(map_announcement)
.collect())
}

/// 提交客户端反馈。`rating` 为 1..=5;`custom_data` 携带附加信息。
pub async fn submit_feedback(
content: String,
rating: Option<u8>,
custom_data: serde_json::Value,
) -> Result<()> {
let input = CreateFeedbackInput {
content,
rating,
platform: Some(PLATFORM),
custom_data: json_object(custom_data),
..Default::default()
};
client()?.public().create_feedback(&input).await?;
Ok(())
}

/// 上报一条错误日志;内容超长会被截断到 Verhub 的上限内。
pub async fn upload_log(content: &str, device_info: serde_json::Value) -> Result<()> {
let input = UploadLogInput {
level: LogLevel::Error.into(),
content: truncate_log(content),
device_info: json_object(device_info),
custom_data: None,
};
client()?.public().upload_log(&input).await?;
Ok(())
}

/// 截到上限以内,按字符边界切以避免切碎多字节字符。
fn truncate_log(content: &str) -> String {
if content.len() <= LOG_CONTENT_MAX {
return content.to_string();
}
const MARK: &str = "…(日志过长,已截断前半部分)\n";
let budget = LOG_CONTENT_MAX - MARK.len();
// 保留末尾(出错现场)。
let start = content.len() - budget;
let start = (start..content.len())
.find(|i| content.is_char_boundary(*i))
.unwrap_or(content.len());
format!("{MARK}{}", &content[start..])
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn truncate_keeps_tail_within_limit() {
let long = "错误".repeat(4000); // 远超 4096 字节
let out = truncate_log(&long);
assert!(
out.len() <= LOG_CONTENT_MAX,
"截断后仍超上限: {}",
out.len()
);
assert!(out.contains("已截断"));
assert!(out.ends_with('误')); // 保的是末尾(出错现场)
}

#[test]
fn truncate_leaves_short_content_alone() {
assert_eq!(truncate_log("崩了"), "崩了");
}

#[test]
fn log_level_maps_to_verhub_numbers() {
assert_eq!(u8::from(LogLevel::Debug), 0);
assert_eq!(u8::from(LogLevel::Error), 3);
}
}
Loading
Loading