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
4 changes: 2 additions & 2 deletions openless-all/app/package-lock.json

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

2 changes: 1 addition & 1 deletion openless-all/app/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "openless-app",
"private": true,
"version": "1.3.4-1",
"version": "1.3.4-2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/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 openless-all/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "openless"
version = "1.3.4-1"
version = "1.3.4-2"
description = "OpenLess — local voice input that types where your cursor is"
authors = ["OpenLess"]
edition = "2021"
Expand Down
107 changes: 102 additions & 5 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,13 @@ pub(crate) fn activate_builtin_style_mode(
// 渠道偏好的写入路径跟 set_settings 复用 persist_settings:保持热键兜底归一化
// 跟其他 prefs 写入一致,且写完后 emit "prefs:changed",让前端跨 webview 同步。
//
// 注意:plugin-updater 2.10 的 Builder 不暴露 endpoints() 运行时 API,因此切到 Beta
// 渠道**不会**改变 in-app「检查更新」的行为——它仍然只看正式版 manifest。Beta 用户
// 通过 `fetch_latest_beta_release` 获取最新 prerelease,由前端跳浏览器手动下载,
// 物理隔离 Beta 包不会通过 auto-update 推到正式版用户。详见 PR-B-2 description 与
// CLAUDE.md `Branch & release-channel workflow` 段落。
// 更新:plugin-updater 2.10.1 的 Builder 现在暴露 .endpoints() runtime API(CLAUDE.md
// 当年记的"不支持"已不成立)。本节配合 `app_check_update_with_channel` 命令实现
// Beta auto-update:Stable 渠道 → 走 tauri.conf 的默认 endpoints;Beta 渠道 →
// fetch_latest_beta_release 拿最新 prerelease tag → 拼成 -beta manifest URL →
// builder.endpoints(vec![url]).build().check()。Stable 用户绝对不会撞到 Beta 包
// (Beta tag 的 manifest 文件名带 `-beta` 后缀,跟 Stable manifest 在 GitHub
// Release assets 里物理分离)。

#[tauri::command]
pub fn get_update_channel(coord: CoordinatorState<'_>) -> UpdateChannel {
Expand Down Expand Up @@ -377,6 +379,101 @@ fn extract_between(haystack: &str, open: &str, close: &str) -> Option<String> {
Some(haystack[start..start + end].to_string())
}

// ─────────────────────── Channel-aware updater check ────────────────────────
//
// 替换前端原来直接 import('@tauri-apps/plugin-updater').check() 的路径:
// - Stable 渠道:builder 不动 endpoints,沿用 tauri.conf 配的 stable manifest URL。
// - Beta 渠道:先 fetch_latest_beta_release 拿最新 prerelease tag,拼成 -beta manifest
// URL(同时给一对 mirror + direct),再 builder.endpoints(vec![url])?.build()?.check()。
//
// 返回的 Metadata 形状与 plugin-updater 的 JS UpdateMetadata 完全一致(rid +
// currentVersion 等驼峰字段),前端可以直接 `new Update(metadata)` 复用 plugin
// 的 download / install / close 实现,无需我们自己写下载和签名校验。
//
// 物理隔离:Beta tag 推出来的 manifest 文件名带 `-beta` 后缀(参见 release-tauri.yml
// 第 382 行注释),跟 Stable 的 `latest-{tgt}-{arch}.json` 在 GitHub Release assets
// 里是分开的两份文件 —— 即使代码逻辑写错把 Beta URL 传给 Stable 用户,HTTP 也是
// 直接 404,绝不会拿到错档。

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppUpdateMetadata {
pub rid: tauri::ResourceId,
pub current_version: String,
pub version: String,
pub date: Option<String>,
pub body: Option<String>,
/// 原始 manifest JSON——`new Update(metadata)` 在 JS 那边会校验它存在;
/// 我们透传 plugin 自己 check 时拿到的字段。
pub raw_json: serde_json::Value,
}

/// 按 prefs.update_channel 决定 manifest 来源,再走 plugin-updater 的标准 check 流程。
/// 返回 None = 当前是最新;Some(metadata) = 有新版可装。
#[tauri::command]
pub async fn app_check_update_with_channel<R: tauri::Runtime>(
coord: CoordinatorState<'_>,
webview: tauri::Webview<R>,
timeout_ms: Option<u64>,
) -> Result<Option<AppUpdateMetadata>, String> {
use tauri_plugin_updater::UpdaterExt;

let channel = coord.prefs().get().update_channel;
let mut builder = webview.updater_builder();
if let Some(ms) = timeout_ms {
builder = builder.timeout(std::time::Duration::from_millis(ms));
}
if matches!(channel, UpdateChannel::Beta) {
let urls = resolve_beta_manifest_endpoints().await?;
builder = builder
.endpoints(urls)
.map_err(|e| format!("set beta endpoints: {e}"))?;
}
let updater = builder.build().map_err(|e| format!("build updater: {e}"))?;
let update = updater
.check()
.await
.map_err(|e| format!("check update failed: {e}"))?;

let Some(update) = update else {
return Ok(None);
};
// date 字段透传需要引 time crate;前端 AutoUpdate.tsx 实际并不用 date,所以这里
// 直接置 None,避免拉一个新 dep 进 src-tauri/Cargo.toml。
let metadata = AppUpdateMetadata {
current_version: update.current_version.clone(),
version: update.version.clone(),
date: None,
body: update.body.clone(),
raw_json: update.raw_json.clone(),
rid: webview.resources_table().add(update),
};
Ok(Some(metadata))
}

/// 把 fetch_latest_beta_release 找到的最新 prerelease tag 拼成 -beta manifest URL 对。
/// 顺序:先镜像(fastgit.cc 代理 GitHub),后直连 —— 跟 tauri.conf 现有 Stable
/// endpoints 一致,让国内访问优先打到 CDN。
async fn resolve_beta_manifest_endpoints() -> Result<Vec<url::Url>, String> {
let Some(latest) = fetch_latest_beta_release().await? else {
return Err("尚未发布过 Beta 版本".to_string());
};
let tag = latest.tag_name;
// {{target}} / {{arch}} 占位符由 plugin 在 check 时替换。Rust raw string 用 r#""#
// 不需要转义双花括号,比 format! 干净。
let mirror = format!(
"https://fastgit.cc/https://github.com/appergb/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta-mirror.json"
);
let direct = format!(
"https://github.com/appergb/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta.json"
);
let mirror_url =
url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?;
let direct_url =
url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?;
Ok(vec![mirror_url, direct_url])
}

#[tauri::command]
pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus {
coord.hotkey_status()
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ pub fn run() {
commands::get_update_channel,
commands::set_update_channel,
commands::fetch_latest_beta_release,
commands::app_check_update_with_channel,
commands::get_hotkey_status,
commands::get_hotkey_capability,
commands::is_wayland_cli_mode,
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenLess",
"version": "1.3.4-1",
"version": "1.3.4-2",
"identifier": "com.openless.app",
"build": {
"beforeDevCommand": "npm run dev",
Expand Down
44 changes: 39 additions & 5 deletions openless-all/app/src/components/AutoUpdate.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
// 自动更新共用模块 — Settings 的"关于"section 和 footer 按钮共用同一套
// 状态机 + 对话框 UI。两边各自调用 useAutoUpdate(),dialog 渲染条件相同。
//
// 渠道感知:check 不再走 plugin-updater 的 JS check()(它只看 tauri.conf 配的
// Stable manifest URL),改为 invoke('app_check_update_with_channel')。
// Rust 那边按 prefs.update_channel 决定 manifest URL;返回的 metadata 直接
// `new Update(metadata)` 复用 plugin 的 download / install / close 实现,
// 我们不重复造下载和签名校验。

import { useEffect, useRef, useState } from 'react';
import type { Update, DownloadEvent } from '@tauri-apps/plugin-updater';
import { invoke } from '@tauri-apps/api/core';
import type { DownloadEvent } from '@tauri-apps/plugin-updater';
import { Update } from '@tauri-apps/plugin-updater';
import { useTranslation } from 'react-i18next';
import { isTauri, restartApp } from '../lib/ipc';
import { Btn } from '../pages/_atoms';

const UPDATE_CHECK_TIMEOUT_MS = 8_000; // 缩短超时,让镜像站慢的情况能更快 fallback

interface AppUpdateMetadata {
rid: number;
currentVersion: string;
version: string;
date?: string | null;
body?: string | null;
rawJson: Record<string, unknown>;
}

export type UpdateStatus =
| 'idle'
| 'checking'
Expand Down Expand Up @@ -79,11 +96,28 @@ export function useAutoUpdate(): UseAutoUpdate {
setStatus('none');
return;
}
const { check } = await import('@tauri-apps/plugin-updater');
const next = await check({ timeout: UPDATE_CHECK_TIMEOUT_MS });
// Rust 侧按 update_channel 拼 manifest URL:Stable → tauri.conf 默认;
// Beta → fetch_latest_beta_release 拼出 -beta manifest URL 后再 check。
const metadata = await invoke<AppUpdateMetadata | null>('app_check_update_with_channel', {
timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
});
if (!metadata) {
setStatus('none');
return;
}
// metadata 形状跟 plugin 自己 check 返回的 UpdateMetadata 完全一致;
// new Update(metadata) 直接复用 plugin 的 download/install/close 实现。
const next = new Update({
rid: metadata.rid,
currentVersion: metadata.currentVersion,
version: metadata.version,
date: metadata.date ?? undefined,
body: metadata.body ?? undefined,
rawJson: metadata.rawJson,
});
updateRef.current = next;
setVersion(next?.version ?? '');
setStatus(next ? 'available' : 'none');
setVersion(next.version);
setStatus('available');
} catch (error) {
console.error('[updater] failed to check update', error);
setStatus('error');
Expand Down
85 changes: 78 additions & 7 deletions openless-all/app/src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,38 @@ import {
type LatestBetaRelease,
type UpdateChannel,
} from '../lib/ipc';
import { APP_VERSION } from '../lib/appVersion';
import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate';
import type { OS } from './WindowChrome';

// 把 Beta tag 名(如 "v1.3.4-1-beta-tauri" / "v1.3.2-3-beta-tauri")解析回 semver 版本号
// "1.3.4-1" / "1.3.2-3"。失败返回 null(让 UI fallback 到字符串相等比对)。
function parseVersionFromBetaTag(tag: string): string | null {
const trimmed = tag.replace(/^v/, '').replace(/-beta-tauri$/, '');
return /^\d+\.\d+\.\d+(-\d+)?$/.test(trimmed) ? trimmed : null;
}

// 简化版 semver 比较 —— 只覆盖本仓库实际用到的两种形态:X.Y.Z 和 X.Y.Z-N。
// 返回 true 表示 a > b。按 SemVer 规则:major→minor→patch→prerelease(none > some)。
function semverGreater(a: string, b: string): boolean {
const parse = (v: string) => {
const [main, pre] = v.split('-');
const [major, minor, patch] = main.split('.').map(n => Number.parseInt(n, 10));
const preNum = pre === undefined ? null : Number.parseInt(pre, 10);
return { major, minor, patch, preNum };
};
const A = parse(a);
const B = parse(b);
if (A.major !== B.major) return A.major > B.major;
if (A.minor !== B.minor) return A.minor > B.minor;
if (A.patch !== B.patch) return A.patch > B.patch;
// pre: null(无 prerelease)> 任何 prerelease
if (A.preNum === B.preNum) return false;
if (A.preNum === null) return true;
if (B.preNum === null) return false;
return A.preNum > B.preNum;
}

interface SettingsModalProps {
os: OS;
onClose: () => void;
Expand Down Expand Up @@ -417,17 +447,32 @@ function AboutMini() {
);
}

// Beta 渠道开关:物理隔离的 opt-in,不接 auto-update。
// - 关闭状态 = 正式版渠道,默认行为,用户从「检查更新」拿正式 release
// - 打开 = 用户主动加入 Beta;写 prefs(无重启需要)+ 拉一次最新 prerelease 信息
// - 点"打开 GitHub"跳浏览器到具体的 Beta release 页面,用户手动下载安装
// Beta 渠道开关:物理隔离的 opt-in,**已接 auto-update**(PR feat/beta-auto-update)。
// - 关闭 = Stable 渠道,「检查更新」走 tauri.conf 默认 endpoints
// - 打开 = 写 prefs.update_channel = 'beta';Rust 端 app_check_update_with_channel
// 命令会自动拉最新 prerelease tag 拼成 -beta manifest URL,再走 plugin-updater
// 的 check/download/install 标准流程
// - 这里拉一次 fetch_latest_beta_release 仅用于「告诉用户最新 Beta 是哪个版本」做
// 信息透明;不再渲染手动下载按钮(auto-update 接管了那条路)。
// 不在 Beta 渠道时不发起 GitHub API 请求,避免空切换浪费配额。
function BetaChannelControl() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannel>('stable');
const [latest, setLatest] = useState<LatestBetaRelease | null>(null);
const [status, setStatus] = useState<'idle' | 'fetching' | 'empty' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string>('');
const updater = useAutoUpdate();

// 本机 vs 最新 Beta tag 比对:
// - 'unknown' → 还没拿到 latest 或解析失败,按"未知"渲染
// - 'up-to-date' → 本机版本 >= 远端最新 Beta,按"已是最新"渲染、隐藏更新按钮
// - 'newer' → 远端有更新版本,渲染「立即更新」按钮触发 auto-update 流程
const remoteVersion = latest ? parseVersionFromBetaTag(latest.tagName) : null;
const comparison: 'unknown' | 'up-to-date' | 'newer' = !remoteVersion
? 'unknown'
: semverGreater(remoteVersion, APP_VERSION)
? 'newer'
: 'up-to-date';

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -495,9 +540,24 @@ function BetaChannelControl() {
<span>
{t('settings.about.betaChannelLatestPrefix')} <code style={{ fontFamily: 'var(--ol-font-mono)' }}>{latest.tagName}</code>
</span>
<button style={btnGhost} onClick={() => openExternal(latest.htmlUrl)}>
{t('settings.about.betaChannelDownloadBtn')}
</button>
{comparison === 'up-to-date' && (
<span style={{
fontSize: 11, padding: '2px 8px', borderRadius: 999,
background: 'var(--ol-blue-soft)', color: 'var(--ol-blue)', fontWeight: 500,
}}>{t('settings.about.betaChannelUpToDate')}</span>
)}
{comparison === 'newer' && (
<button
style={{ ...btnGhost, borderColor: 'var(--ol-blue)', color: 'var(--ol-blue)' }}
onClick={() => void updater.checkForUpdates()}
disabled={updater.checking || updater.busy}
title={t('settings.about.betaChannelUpdateNowTitle')}
>
{updater.checking
? t('settings.about.betaChannelChecking')
: t('settings.about.betaChannelUpdateNow')}
</button>
)}
<button style={btnGhost} onClick={fetchBeta} title={t('settings.about.betaChannelRefresh')}>
<Icon name="refresh" size={12} />
</button>
Expand All @@ -510,6 +570,17 @@ function BetaChannelControl() {
)}
</div>
)}
{isDialogStatus(updater.status) && (
<UpdateDialog
status={updater.status}
version={updater.version}
progress={updater.progress}
downloaded={updater.downloaded}
contentLength={updater.contentLength}
onInstall={() => void updater.installUpdate()}
onClose={() => void updater.dismissDialog()}
/>
)}
</>
);
}
Expand Down
6 changes: 5 additions & 1 deletion openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,14 +782,18 @@ export const en: typeof zhCN = {
privacyDesc: 'All transcripts stay on this device. Cloud APIs are only called for real-time transcription/polish; no recordings are retained.',
localFirst: 'Local-first',
betaChannelLabel: 'Join Beta channel',
betaChannelDesc: 'Stable channel is the default. Enabling this exposes a manual download link to the latest Beta below; Beta builds are NOT pushed to regular users via auto-update — you have to download and install them yourself. May be unstable, only recommended if you are willing to test pre-release builds and report issues.',
betaChannelDesc: 'Stable channel is the default. When enabled, the app’s "Check for updates" auto-fetches the latest Beta release (with features not yet promoted to Stable). Beta builds are physically isolated from Stable manifests, so regular users are never affected. May be unstable; only recommended if you are willing to test pre-release builds and report issues.',
betaChannelFetching: 'Fetching the latest Beta…',
betaChannelFetchBtn: 'Look up latest Beta',
betaChannelLatestPrefix: 'Latest Beta:',
betaChannelDownloadBtn: 'Open download page',
betaChannelRefresh: 'Refresh',
betaChannelNoBeta: 'No Beta release has been published yet.',
betaChannelFetchError: 'Failed to fetch Beta release info. Please try again later.',
betaChannelUpToDate: 'Up to date',
betaChannelUpdateNow: 'Update now',
betaChannelUpdateNowTitle: 'Check and download the latest Beta, then show the update dialog',
betaChannelChecking: 'Checking…',
updateDialog: {
available: {
title: 'Update available',
Expand Down
Loading
Loading