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
5 changes: 3 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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"
verhub-sdk = { version = "0.2.5", default-features = false, features = ["native-tls"] }
verhub-sdk = { version = "0.2.6", default-features = false, features = ["native-tls"] }

[profile.release]
opt-level = "z"
Expand Down
3 changes: 3 additions & 0 deletions apps/config/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ bosskey-common = { path = "../../../crates/common" }
bosskey-core = { path = "../../../crates/core" }
tauri-plugin-single-instance = "2.4.2"
verhub-sdk = { workspace = true }

[dev-dependencies]
tempfile = { workspace = true }
35 changes: 33 additions & 2 deletions apps/config/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,25 @@ async fn show_all_windows() -> Result<(), String> {
blocking(|| notify_core(&Command::Show).map(|_| ())).await
}

/// 恢复显示指定窗口(窗口恢复工具):直接对选中的句柄 ShowWindow。
/// 把恢复工具的窗口操作交给核心执行;核心未应答(未运行 / 出错)时返回 false,
/// 由调用方退回直接操作。
fn try_core_window_op(command: &Command) -> bool {
matches!(
PipeClient::connect_default().fast().send(command),
Ok(Response::Ok)
)
}

/// 恢复显示指定窗口(窗口恢复工具)。优先经核心释放并更新记录;
/// 核心不在运行才直接对句柄 ShowWindow。
#[tauri::command]
async fn show_windows(hwnds: Vec<i64>) {
blocking(move || {
if try_core_window_op(&Command::ReleaseWindows {
hwnds: hwnds.clone(),
}) {
return;
}
use bosskey_core::platform::WindowManager;
let mgr = bosskey_core::platform::manager();
for h in hwnds {
Expand All @@ -137,10 +152,16 @@ async fn show_windows(hwnds: Vec<i64>) {
.await
}

/// 隐藏指定窗口(窗口恢复工具):直接对选中的句柄隐藏。
/// 隐藏指定窗口(窗口恢复工具)。优先经核心纳入隐藏记录(不施加静音 / 冻结);
/// 核心不在运行才直接对句柄隐藏。
#[tauri::command]
async fn hide_windows(hwnds: Vec<i64>) {
blocking(move || {
if try_core_window_op(&Command::AdoptWindows {
hwnds: hwnds.clone(),
}) {
return;
}
use bosskey_core::platform::WindowManager;
let mgr = bosskey_core::platform::manager();
for h in hwnds {
Expand Down Expand Up @@ -402,6 +423,15 @@ async fn open_external(url: String) -> Result<(), String> {
blocking(move || bosskey_core::shell::open(&url)).await
}

/// 项目公开链接(主页 / 仓库 / 文档等)。带缓存(内存 + exe 同目录磁盘文件,
/// 有效期一天),过期才请求 Verhub;请求失败退回过期缓存。
#[tauri::command]
async fn verhub_project_links() -> Result<verhub::ProjectLinks, String> {
verhub::project_links(&exe_dir().join("verhub_cache.json"))
.await
.map_err(|e| e.to_string())
}

/// 检查更新。`required=true` 即强制更新,界面须阻断使用。
#[tauri::command]
async fn verhub_check_update(include_preview: bool) -> Result<verhub::CheckUpdate, String> {
Expand Down Expand Up @@ -528,6 +558,7 @@ pub fn run() {
startup_action,
app_info,
open_external,
verhub_project_links,
verhub_check_update,
verhub_announcements,
verhub_submit_feedback,
Expand Down
141 changes: 138 additions & 3 deletions apps/config/src-tauri/src/verhub.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
//! Verhub 客户端:版本 / 公告 / 反馈 / 日志,基于官方 verhub-sdk。
//! Verhub 客户端:版本 / 公告 / 反馈 / 日志 / 项目链接,基于官方 verhub-sdk。
//!
//! 只用公开端点(无需凭据)。HTTP 由 SDK 完成;本模块把 SDK 的响应类型映射成
//! 前端 IPC 契约所需的可序列化 DTO,字段名保持不变。

use std::path::Path;
use std::sync::Mutex;
use std::time::Duration;

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

/// Verhub 基础路径。
Expand Down Expand Up @@ -190,6 +192,101 @@ pub async fn upload_log(content: &str, device_info: serde_json::Value) -> Result
Ok(())
}

/// 项目公开链接(主页 / 仓库 / 文档等)的缓存有效期。链接极少变动,一天刷新一次足够。
const PROJECT_CACHE_TTL_SECS: i64 = 24 * 60 * 60;

/// 项目公开链接。所有字段都可能缺省(Verhub 上未填写);前端须自备回退链接。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProjectLinks {
pub name: Option<String>,
pub website_url: Option<String>,
pub repo_url: Option<String>,
pub docs_url: Option<String>,
pub author: Option<String>,
pub author_homepage_url: Option<String>,
/// 拉取时刻(Unix 秒),用于判断缓存新鲜度。
pub fetched_at: i64,
}

/// 进程内缓存,避免每次都读盘。
static PROJECT_CACHE: Mutex<Option<ProjectLinks>> = Mutex::new(None);

fn map_project(item: ProjectItem, fetched_at: i64) -> ProjectLinks {
ProjectLinks {
name: Some(item.name),
website_url: item.website_url,
repo_url: item.repo_url,
docs_url: item.docs_url,
author: item.author,
author_homepage_url: item.author_homepage_url,
fetched_at,
}
}

/// 缓存是否仍在有效期内。`fetched_at` 在未来(时钟回拨)按过期处理。
fn cache_fresh(links: &ProjectLinks, now: i64) -> bool {
(0..PROJECT_CACHE_TTL_SECS).contains(&(now - links.fetched_at))
}

fn cache_get() -> Option<ProjectLinks> {
PROJECT_CACHE.lock().ok()?.clone()
}

fn cache_put(links: ProjectLinks) {
if let Ok(mut cache) = PROJECT_CACHE.lock() {
*cache = Some(links);
}
}

/// 读磁盘缓存;文件不存在或损坏一律当作没有缓存。
fn read_cache_file(path: &Path) -> Option<ProjectLinks> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}

/// 写磁盘缓存;尽力而为,写失败只影响下次冷启动的命中率。
fn write_cache_file(path: &Path, links: &ProjectLinks) {
if let Ok(json) = serde_json::to_string_pretty(links) {
let _ = std::fs::write(path, json);
}
}

fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}

/// 项目公开链接:内存缓存 → 磁盘缓存(`cache_path`)→ Verhub API 逐级回退。
/// API 拉取失败时退回过期缓存(有旧数据总比没有强),完全没有缓存才报错。
pub async fn project_links(cache_path: &Path) -> Result<ProjectLinks> {
let now = unix_now();
if let Some(cached) = cache_get()
&& cache_fresh(&cached, now)
{
return Ok(cached);
}
if let Some(cached) = read_cache_file(cache_path)
&& cache_fresh(&cached, now)
{
cache_put(cached.clone());
return Ok(cached);
}
match client()?.public().get_project().await {
Ok(item) => {
let links = map_project(item, now);
write_cache_file(cache_path, &links);
cache_put(links.clone());
Ok(links)
}
Err(err) => match cache_get().or_else(|| read_cache_file(cache_path)) {
Some(stale) => Ok(stale),
None => Err(err),
},
}
}

/// 截到上限以内,按字符边界切以避免切碎多字节字符。
fn truncate_log(content: &str) -> String {
if content.len() <= LOG_CONTENT_MAX {
Expand Down Expand Up @@ -232,4 +329,42 @@ mod tests {
assert_eq!(u8::from(LogLevel::Debug), 0);
assert_eq!(u8::from(LogLevel::Error), 3);
}

#[test]
fn cache_fresh_within_ttl_only() {
let links = ProjectLinks {
fetched_at: 1_000_000,
..Default::default()
};
assert!(cache_fresh(&links, 1_000_000)); // 刚拉取
assert!(cache_fresh(&links, 1_000_000 + PROJECT_CACHE_TTL_SECS - 1));
assert!(!cache_fresh(&links, 1_000_000 + PROJECT_CACHE_TTL_SECS)); // 到期
assert!(!cache_fresh(&links, 999_999)); // 时钟回拨
}

#[test]
fn cache_file_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("verhub_cache.json");
let links = ProjectLinks {
name: Some("Boss Key".into()),
website_url: Some("https://example.com/".into()),
fetched_at: 42,
..Default::default()
};
write_cache_file(&path, &links);
let read = read_cache_file(&path).expect("缓存应可读回");
assert_eq!(read.name.as_deref(), Some("Boss Key"));
assert_eq!(read.website_url.as_deref(), Some("https://example.com/"));
assert_eq!(read.fetched_at, 42);
}

#[test]
fn cache_file_tolerates_missing_and_corrupt() {
let dir = tempfile::tempdir().unwrap();
assert!(read_cache_file(&dir.path().join("absent.json")).is_none());
let bad = dir.path().join("bad.json");
std::fs::write(&bad, "not json{{").unwrap();
assert!(read_cache_file(&bad).is_none());
}
}
20 changes: 14 additions & 6 deletions apps/config/ui/src/components/AboutPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@
const v = $derived(app.config.verhub);
const year = new Date().getFullYear();

// 链接以 Verhub 项目信息为准(后端带缓存),拉不到时退回内置地址。
const links = $derived(app.project);
const homepageUrl = $derived(links?.website_url || "https://boss-key.ivan-hanloth.cn/");
const repoUrl = $derived(links?.repo_url || info?.website || "https://github.com/IvanHanloth/Boss-Key");
const docsUrl = $derived(links?.docs_url || "https://boss-key.ivan-hanloth.cn/guide/");
const authorUrl = $derived(links?.author_homepage_url || info?.blog || "https://www.ivan-hanloth.cn/");
const authorName = $derived(links?.author || info?.author || "Ivan Hanloth");

let content = $state("");
let rating = $state(0);
let hoverRating = $state(0);
Expand Down Expand Up @@ -81,22 +89,22 @@
<p class="muted">{t("about.version", { version: info?.version ?? "…" })}</p>
<p>{t("about.tagline")}</p>
<p>
<button class="val link" onclick={() => open(info?.website ?? "https://boss-key.ivan-hanloth.cn/")}>
<button class="val link" onclick={() => open(homepageUrl)}>
{t("about.homepage")}
</button>
<span> • </span>
<button class="val link" onclick={() => open("https://github.com/IvanHanloth/Boss-Key")}>
<button class="val link" onclick={() => open(repoUrl)}>
{t("about.repository")}
</button>
<span> • </span>
<button class="val link" onclick={() => open("https://boss-key.ivan-hanloth.cn/guide/")}>
<button class="val link" onclick={() => open(docsUrl)}>
{t("about.docs")}
</button>

</p>
<p>Copyright © 2022-{year}
<button class="val link" onclick={() => open(info?.blog ?? "https://www.ivan-hanloth.cn/")}>
Ivan Hanloth
<button class="val link" onclick={() => open(authorUrl)}>
{authorName}
</button> All Rights Reserved.</p>
</div>

Expand Down
10 changes: 10 additions & 0 deletions apps/config/ui/src/lib/ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ function mockInvoke(cmd, args) {
blog: "https://blog.ivan-hanloth.cn/",
license: "MIT",
};
case "verhub_project_links":
return {
name: "Boss Key",
website_url: "https://boss-key.ivan-hanloth.cn/",
repo_url: "https://github.com/IvanHanloth/Boss-Key",
docs_url: "https://boss-key.ivan-hanloth.cn/guide/",
author: "Ivan Hanloth",
author_homepage_url: "https://www.ivan-hanloth.cn/",
fetched_at: Math.floor(Date.now() / 1000),
};
case "verhub_check_update":
return {
should_update: false,
Expand Down
7 changes: 7 additions & 0 deletions apps/config/ui/src/lib/state.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const app = $state({
/** 当前自启注册方式:"task"|"registry"|null(未注册)。 */
autostartMethod: null,
info: null,
/** Verhub 上的项目公开链接(主页 / 仓库 / 文档等);null 时用内置回退链接。 */
project: null,
maximized: false,
saving: false,
/** 程序目录下是否存在 pssuspend64.exe(增强冻结的前置条件)。 */
Expand Down Expand Up @@ -139,6 +141,11 @@ export async function loadAll() {
}),
invoke("pssuspend_available").then((v) => (app.pssuspend = !!v)),
];
// 项目链接拉不到时静默——「关于」页有内置回退链接,不值得打扰用户。
verhub
.projectLinks()
.then((p) => (app.project = p))
.catch(() => {});
const results = await Promise.allSettled(tasks);
const failed = results.find((r) => r.status === "rejected");
if (failed) toast(t("state.partialLoadFailed", { reason: failed.reason }), true);
Expand Down
5 changes: 5 additions & 0 deletions apps/config/ui/src/lib/verhub.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

import { invoke } from "./ipc.js";

/** 项目公开链接(主页 / 仓库 / 文档等)。后端带缓存,可随意调用。 */
export function projectLinks() {
return invoke("verhub_project_links");
}

/** 检查更新。返回 { should_update, required, target_version, latest_version, … }。 */
export function checkUpdate(includePreview = false) {
return invoke("verhub_check_update", { includePreview });
Expand Down
15 changes: 15 additions & 0 deletions crates/common/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ pub enum Command {
SetHotkeys {
enabled: bool,
},
/// 窗口恢复工具:恢复显示指定句柄。在核心隐藏记录里的窗口按整进程释放
/// (连同解冻 / 取消静音);不在记录里的句柄直接恢复显示。
ReleaseWindows {
hwnds: Vec<i64>,
},
/// 窗口恢复工具:隐藏指定句柄并纳入核心隐藏记录(享受崩溃恢复保护),
/// 不施加静音 / 冻结 / 暂停键。
AdoptWindows {
hwnds: Vec<i64>,
},
Quit,
}

Expand Down Expand Up @@ -164,6 +174,11 @@ mod tests {
},
Command::SetHotkeys { enabled: true },
Command::SetHotkeys { enabled: false },
Command::ReleaseWindows {
hwnds: vec![1, 2, 3],
},
Command::ReleaseWindows { hwnds: vec![] },
Command::AdoptWindows { hwnds: vec![42] },
Command::Quit,
];
for c in cases {
Expand Down
Loading
Loading