Compare commits

...

2 Commits

Author SHA1 Message Date
wangjia 6277e86d0f feat(client): SSO 用户中心按钮 + 自动更新检查(镜像 jiu)
Deploy Server / deploy-server (push) Successful in 2m56s
SSO: web_launch.openWebUserCenter → POST /v1/auth/web-ticket → 打开 app.yanmeiai.com/sso?t=…;
设置页加「用户中心(网页)」入口。自动更新: update_provider 查 /version 语义比对 + update_dialog
「发现新版本」;设置页「检查更新」由空 onTap 接上。加 url_launcher(原缺)。flutter analyze 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 00:13:50 +08:00
wangjia 4baf24f6f8 feat(server): 自动更新 /version 清单端点(镜像 jiu)
GET /version(公开)读 /etc/pangolin/version.yaml(每请求重载,免重启);缺省回退。
download_urls 接 /downloads。deploy.sh 装清单(存在则不覆盖, 保住线上 bump)。含 4 测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 00:12:46 +08:00
13 changed files with 610 additions and 1 deletions
+8
View File
@@ -148,6 +148,14 @@ abstract class AppText {
String get killSwitch;
String get killSwitchSub;
String get checkUpdate;
String get webUserCenter; // 「用户中心(网页)」入口(App→Web SSO 免登)
// 更新检查(设置页「检查更新」手动触发)
String updateAvailableTitle(String version); // 「发现新版本 vX.Y.Z」
String get updateNotesFallback; // 无 release_notes 时的兜底文案
String get updateLater;
String get updateDownload;
String get updateUpToDate; // 检查后:已是最新版本
String get updateCheckFailed; // 检查失败(网络异常)
// ── 套餐选择 ──
String get choosePlan;
+14
View File
@@ -235,6 +235,20 @@ class StringsEn extends AppText {
String get killSwitchSub => 'Block traffic if the link drops';
@override
String get checkUpdate => 'Check for updates';
@override
String get webUserCenter => 'User Center (Web)';
@override
String updateAvailableTitle(String version) => 'New version v$version available';
@override
String get updateNotesFallback => 'This update includes fixes and stability improvements.';
@override
String get updateLater => 'Later';
@override
String get updateDownload => 'Download update';
@override
String get updateUpToDate => 'You are up to date';
@override
String get updateCheckFailed => 'Update check failed, try again later';
@override
String get choosePlan => 'Choose plan';
+14
View File
@@ -234,6 +234,20 @@ class StringsZh extends AppText {
String get killSwitchSub => '断线时阻断网络,防止泄露';
@override
String get checkUpdate => '检查更新';
@override
String get webUserCenter => '用户中心(网页)';
@override
String updateAvailableTitle(String version) => '发现新版本 v$version';
@override
String get updateNotesFallback => '本次更新修复了一些问题并提升了稳定性,建议尽快更新。';
@override
String get updateLater => '稍后';
@override
String get updateDownload => '下载更新';
@override
String get updateUpToDate => '已是最新版本';
@override
String get updateCheckFailed => '检查更新失败,请稍后重试';
@override
String get choosePlan => '选择套餐';
+22 -1
View File
@@ -9,9 +9,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../services/web_launch.dart';
import '../state/app_providers.dart';
import '../state/settings_provider.dart';
import '../state/update_provider.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/pangolin_toast.dart';
import '../widgets/update_dialog.dart';
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
/// 有更新则弹 [showUpdateDialog]。
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
final info = await ref.read(updateCheckerProvider).check();
if (!context.mounted) return;
if (info == null) {
showPangolinToast(context, t.updateCheckFailed);
return;
}
if (!info.hasUpdate) {
showPangolinToast(context, t.updateUpToDate);
return;
}
await showUpdateDialog(context, t, info);
}
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@@ -59,7 +79,8 @@ class SettingsPage extends ConsumerWidget {
right: sw(isDark, (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light),
),
_Row(title: t.protocol, right: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () {}),
_Row(title: t.webUserCenter, right: Icon(PangolinIcons.externalLink, size: 18, color: c.fg3), onTap: () => openWebUserCenter(ref)),
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () => _checkForUpdate(context, ref, t)),
_Row(title: 'Version', last: true, right: Text(version, style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
]),
]),
+29
View File
@@ -0,0 +1,29 @@
// web_launch.dart — App→Web 单点登录跳转(SSO 换票)。
//
// 「用户中心(网页)」入口:先向控制面签一张短时单次票据
// (POST /v1/auth/web-ticket,需登录),再打开 用户中心网页版 的
// /sso?t=<票>&redirect=<路径> 落地页兑票登录——避免用户在网页端重新输入密码。
// 签票失败(未登录/网络异常)时降级为直接打开目标页(未登录态浏览)。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../state/account_providers.dart';
/// 用户中心(网页版)基址。
const String kWebUserCenterBaseUrl = 'https://app.yanmeiai.com';
/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。
Future<void> openWebUserCenter(WidgetRef ref, {String path = '/'}) async {
Uri target = Uri.parse('$kWebUserCenterBaseUrl$path');
try {
final resp = await ref.read(apiClientProvider).postJson('/v1/auth/web-ticket');
final ticket = resp['ticket'] as String?;
if (ticket != null && ticket.isNotEmpty) {
target = Uri.parse(
'$kWebUserCenterBaseUrl/sso?t=$ticket&redirect=${Uri.encodeComponent(path)}');
}
} catch (_) {
// 签票失败(未登录/网络异常)降级:直接打开目标页(未登录态浏览)。
}
await launchUrl(target, mode: LaunchMode.externalApplication);
}
+105
View File
@@ -0,0 +1,105 @@
// update_provider.dart — 应用更新检查(轻量版,无后台轮询)。
//
// 手动触发(设置页「检查更新」)向控制面 GET $kApiBaseUrl/version 拉取最新版本信息,
// 与本地版本(package_info_plus)做语义比较;有更新则由调用方(settings_page)弹窗
// 展示(见 widgets/update_dialog.dart)。检查失败静默返回 null,不影响主流程。
//
// 对照 jiu client/lib/providers/update_provider.dart,去掉了定时轮询 Timer 与
// dismiss 状态——按需求这里只做「手动检查」,启动期自动检查留作后续 TODO。
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import '../services/api_config.dart';
/// 一次更新检查的结果。
class AppUpdateInfo {
const AppUpdateInfo({
required this.latestVersion,
required this.buildNumber,
required this.forceUpdate,
required this.releaseNotes,
required this.downloadUrls,
required this.hasUpdate,
});
final String latestVersion;
final int buildNumber;
final bool forceUpdate;
final String releaseNotes;
final Map<String, String> downloadUrls;
final bool hasUpdate;
}
/// 无状态更新检查服务,供设置页「检查更新」按钮直接调用。
final updateCheckerProvider = Provider<UpdateChecker>((ref) => const UpdateChecker());
class UpdateChecker {
const UpdateChecker();
/// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。
Future<AppUpdateInfo?> check() async {
try {
final resp = await http
.get(Uri.parse('$kApiBaseUrl/version'))
.timeout(const Duration(seconds: 8));
if (resp.statusCode != 200) return null;
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final latestVersion = data['version'] as String? ?? '0.0.0';
final buildNumber = (data['build_number'] as num?)?.toInt() ?? 0;
final forceUpdate = data['force_update'] as bool? ?? false;
final releaseNotes = data['release_notes'] as String? ?? '';
final rawUrls = data['download_urls'] as Map<String, dynamic>? ?? const {};
final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
final info = await PackageInfo.fromPlatform();
final hasUpdate = _isNewer(latestVersion, info.version);
return AppUpdateInfo(
latestVersion: latestVersion,
buildNumber: buildNumber,
forceUpdate: forceUpdate,
releaseNotes: releaseNotes,
downloadUrls: downloadUrls,
hasUpdate: hasUpdate,
);
} catch (_) {
return null;
}
}
/// 语义化版本比较:latest > current → true。容忍 1.1.4-dev / 1.1.4+7 等后缀。
bool _isNewer(String latest, String current) {
final l = _parse(latest);
final c = _parse(current);
for (var i = 0; i < 3; i++) {
if (l[i] > c[i]) return true;
if (l[i] < c[i]) return false;
}
return false;
}
List<int> _parse(String v) {
final parts = v.split('.').map((s) {
final m = RegExp(r'^\d+').firstMatch(s);
return m == null ? 0 : int.parse(m.group(0)!);
}).toList();
while (parts.length < 3) {
parts.add(0);
}
return parts;
}
}
/// 按当前平台从服务端 download_urls 里取对应下载直链。
String? platformDownloadUrl(Map<String, String> downloadUrls) {
if (Platform.isMacOS) return downloadUrls['macos'];
if (Platform.isWindows) return downloadUrls['windows'];
if (Platform.isIOS) return downloadUrls['ios'];
if (Platform.isAndroid) return downloadUrls['android'];
return downloadUrls['web'];
}
+81
View File
@@ -0,0 +1,81 @@
// update_dialog.dart — 「发现新版本」更新提示弹窗。
//
// 只做「展示 + 引导下载」:点「下载更新」用 url_launcher 打开对应平台的下载直链
// (浏览器下载),不做应用内静默安装/自动重启。force_update=true 时不可关闭
// (无「稍后」按钮、点遮罩/返回也关不掉)。
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../state/update_provider.dart';
import 'pangolin_icons.dart';
/// 展示更新弹窗。调用方(设置页「检查更新」)在拿到 `info.hasUpdate == true` 时调用。
Future<void> showUpdateDialog(BuildContext context, AppText t, AppUpdateInfo info) {
return showDialog<void>(
context: context,
barrierDismissible: !info.forceUpdate,
builder: (ctx) => PopScope(
canPop: !info.forceUpdate,
child: _UpdateDialog(t: t, info: info),
),
);
}
class _UpdateDialog extends StatelessWidget {
const _UpdateDialog({required this.t, required this.info});
final AppText t;
final AppUpdateInfo info;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return AlertDialog(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
title: Row(children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(color: c.accentSubtle, shape: BoxShape.circle),
child: Icon(PangolinIcons.zap, size: 18, color: c.accent),
),
const SizedBox(width: 12),
Expanded(
child: Text(
t.updateAvailableTitle(info.latestVersion),
overflow: TextOverflow.ellipsis,
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700),
),
),
]),
content: SingleChildScrollView(
child: Text(
info.releaseNotes.isEmpty ? t.updateNotesFallback : info.releaseNotes,
style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5),
),
),
actions: [
if (!info.forceUpdate)
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.updateLater, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
),
TextButton(
onPressed: () async {
final url = platformDownloadUrl(info.downloadUrls);
if (url != null && url.isNotEmpty) {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
if (context.mounted) Navigator.of(context).pop();
},
child: Text(t.updateDownload, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
),
],
);
}
}
+1
View File
@@ -20,6 +20,7 @@ dependencies:
flutter_secure_storage: ^9.2.2 # JWT token 安全存储 + 稳定 device_id 持久化
shared_preferences: ^2.5.5
package_info_plus: ^9.0.1
url_launcher: ^6.3.0 # 打开外部链接(用户中心 SSO 免登 / 更新下载页)
device_info_plus: ^11.2.0 # 设备名/平台(「我的设备」上报)
uuid: ^4.5.1 # 客户端生成稳定 device_id (UUID v4)
launch_at_startup: ^0.5.1
+10
View File
@@ -178,6 +178,7 @@ GRPC_KEY_PATH=$ETC/grpc.key
PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com
PANGOLIN_RULES_DIR=$DATA_DIR/rules
DOWNLOADS_DIR=$DATA_DIR/downloads
VERSION_MANIFEST=$ETC/version.yaml
EOF
if [ -n "${SMTP_HOST:-}" ]; then
cat >> "$ETC/server.env" <<EOF
@@ -211,6 +212,15 @@ curl -fsSL -o "$RULES_DIR/geosite-cn.srs" \
DOWNLOADS_DIR="$DATA_DIR/downloads"
install -d -m 755 "$DOWNLOADS_DIR"
# ── 6d. 客户端自动更新版本清单 ─────────────────────────────────────────────────
# GET /version(公开、免鉴权)按 VERSION_MANIFEST(见上 server.env)读取该文件;
# scripts/ci/release-client.sh 每次 client-v* 发版都会以本仓库这份文件为模板,
# 改写 version/build_number 后 SSH 推到这个路径覆盖 —— 幂等重跑本脚本不应该把
# 已发布的最新版本号退回仓库里的默认值,所以文件已存在时不覆盖。
if [ ! -f "$ETC/version.yaml" ]; then
install -m 644 "$HERE/version.yaml" "$ETC/version.yaml"
fi
# ── 7. 迁移 + seed(SQLite)────────────────────────────────────────────────────
log "执行迁移(sqlite)..."
DB_DRIVER=sqlite DB_DSN="$DB_FILE" "$BIN/pangolin-migrate" up
+22
View File
@@ -0,0 +1,22 @@
# version.yaml — 客户端自动更新版本清单(committed default)。
#
# deploy/single-node/deploy.sh 把这份文件安装到 /etc/pangolin/version.yaml
# (VERSION_MANIFEST 默认路径,见 server/internal/httpapi/version.go)。
# scripts/ci/release-client.sh 在每次 client-v* 发版时,以这份文件为模板改写
# version / build_number 字段,再原样(SSH)推到 pangolin1 的
# /etc/pangolin/version.yaml —— 控制面每次请求都重新读该文件,发版立即生效,
# 不需要重启/重新部署控制面。
#
# download_urls 目前是固定「仅保留最新一份」的稳定 URL(deploy-client.sh 每次
# 用同名文件覆盖),不是按版本变化的路径,因此这里不需要随发版改写;
# macos / ios 产物尚未产出,先留空字符串。
version: "1.0.48"
build_number: 10048
force_update: false
release_notes: ""
download_urls:
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
macos: ""
ios: ""
changelog: []
+7
View File
@@ -138,6 +138,13 @@ func main() {
downloadsHandler := httpapi.NewDownloadsHandler(os.Getenv("DOWNLOADS_DIR"))
r.Get("/downloads/*", downloadsHandler.Serve)
// Public (no auth): 客户端自动更新版本清单。VERSION_MANIFEST 可配置清单路径
// (默认 /etc/pangolin/version.yaml);deploy/single-node/deploy.sh 安装仓库内
// 默认清单,scripts/ci/release-client.sh 在每次 client-v* 发版时改写其
// version/build_number。每次请求都重新读文件,发版脚本改完立即生效,无需重启。
versionHandler := httpapi.NewVersionHandler(os.Getenv("VERSION_MANIFEST"))
r.Get("/version", versionHandler.Serve)
// Optional probe ingest route. sharedProbeStore is reused by the scheduler
// (below) when both are enabled, so they share one Redis-backed store.
var sharedProbeStore *probe.Store
+137
View File
@@ -0,0 +1,137 @@
package httpapi
import (
"encoding/json"
"net/http"
"os"
"gopkg.in/yaml.v3"
)
// defaultVersionManifestPath is VERSION_MANIFEST's default when unset — mirrors
// how server.env wires DOWNLOADS_DIR alongside DownloadsHandler's own built-in
// default (see downloads.go / deploy/single-node/deploy.sh).
const defaultVersionManifestPath = "/etc/pangolin/version.yaml"
// Fallback values used ONLY when the manifest file itself is missing (fresh
// box that hasn't run deploy/single-node/deploy.sh's manifest-install step
// yet, or a local dev server). These are hand-set, not auto-derived — once a
// box is deployed, the real source of truth is the on-disk manifest that
// scripts/ci/release-client.sh overwrites on every client-v* release.
const (
defaultManifestVersion = "1.0.48"
defaultManifestBuildNumber = 10048
)
// changelogSection / changelogEntry mirror jiu's backend/config/version.yaml
// shape (~/code/jiu/backend/internal/handler/version.go) so any future shared
// tooling (e.g. a website changelog widget) can treat both services'
// /version responses identically. Pangolin's release pipeline doesn't
// populate Changelog yet (no CHANGELOG-client.md parsing wired in
// scripts/ci/release-client.sh) — the field exists for shape-compatibility
// and always serializes as [] rather than null.
type changelogSection struct {
Type string `yaml:"type" json:"type"`
Items []string `yaml:"items" json:"items"`
}
type changelogEntry struct {
Version string `yaml:"version" json:"version"`
Date string `yaml:"date" json:"date"`
Intro string `yaml:"intro" json:"intro"`
Sections []changelogSection `yaml:"sections" json:"sections"`
}
// versionManifest is the on-disk (and wire) shape of the auto-update
// manifest. download_urls keys in practice: macos, windows, ios, android
// (web is intentionally not used — pangolin's client is native-only).
type versionManifest struct {
Version string `yaml:"version" json:"version"`
BuildNumber int `yaml:"build_number" json:"build_number"`
ForceUpdate bool `yaml:"force_update" json:"force_update"`
ReleaseNotes string `yaml:"release_notes" json:"release_notes"`
DownloadURLs map[string]string `yaml:"download_urls" json:"download_urls"`
Changelog []changelogEntry `yaml:"changelog" json:"changelog"`
}
// VersionHandler serves GET /version (public, no auth — mounted directly in
// main.go next to /healthz and /downloads/*). Unlike most handlers in this
// package it re-reads its manifest file from disk on EVERY request rather
// than caching it in memory: scripts/ci/release-client.sh rewrites
// /etc/pangolin/version.yaml's version/build_number on each client-v*
// release, and that must take effect immediately without a control-plane
// restart or redeploy (mirrors jiu's loadVersionConfig()-per-request).
type VersionHandler struct {
path string
}
// NewVersionHandler builds a VersionHandler reading the manifest at path.
// path=="" falls back to defaultVersionManifestPath (in production this is
// overridden via the VERSION_MANIFEST env var — see main.go).
func NewVersionHandler(path string) *VersionHandler {
if path == "" {
path = defaultVersionManifestPath
}
return &VersionHandler{path: path}
}
// defaultManifest is returned when the manifest file doesn't exist yet, so
// GET /version still answers usefully (client update-checks shouldn't hard
// fail just because deploy/single-node/deploy.sh hasn't run on this box).
func defaultManifest() versionManifest {
return versionManifest{
Version: defaultManifestVersion,
BuildNumber: defaultManifestBuildNumber,
ForceUpdate: false,
ReleaseNotes: "",
DownloadURLs: map[string]string{
"android": "https://api.yanmeiai.com/downloads/pangolin-android.apk",
"windows": "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe",
"macos": "",
"ios": "",
},
Changelog: []changelogEntry{},
}
}
// loadManifest reads+parses h.path. A missing file is NOT an error (falls
// back to defaultManifest()); any other read/parse failure IS, so ServeHTTP
// can 500 instead of silently masking a corrupt manifest written by a bad
// release-client.sh run.
func (h *VersionHandler) loadManifest() (versionManifest, error) {
data, err := os.ReadFile(h.path)
if err != nil {
if os.IsNotExist(err) {
return defaultManifest(), nil
}
return versionManifest{}, err
}
var m versionManifest
if err := yaml.Unmarshal(data, &m); err != nil {
return versionManifest{}, err
}
// Keep the JSON response shape stable (empty object/array, never null)
// regardless of what the manifest on disk happens to omit.
if m.DownloadURLs == nil {
m.DownloadURLs = map[string]string{}
}
if m.Changelog == nil {
m.Changelog = []changelogEntry{}
}
return m, nil
}
// Serve handles GET /version. Named Serve (not ServeHTTP) to match
// DownloadsHandler's convention in this package (see downloads.go).
func (h *VersionHandler) Serve(w http.ResponseWriter, r *http.Request) {
m, err := h.loadManifest()
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "version manifest unavailable"})
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(m)
}
+160
View File
@@ -0,0 +1,160 @@
package httpapi
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
)
// buildVersionRouter mounts VersionHandler on a real chi router (mirrors how
// main.go mounts GET /version) so routing behaves exactly as in production.
func buildVersionRouter(path string) chi.Router {
h := NewVersionHandler(path)
r := chi.NewRouter()
r.Get("/version", h.Serve)
return r
}
func TestVersionHandler_ServesManifestFromDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "version.yaml")
yamlContent := `version: "1.2.3"
build_number: 10203
force_update: true
release_notes: "测试发布说明"
download_urls:
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
macos: ""
ios: ""
changelog:
- version: "1.2.3"
date: "2026-07-06"
intro: "小版本更新"
sections:
- type: "新增"
items:
- "示例条目"
`
if err := os.WriteFile(path, []byte(yamlContent), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
r := buildVersionRouter(path)
req := httptest.NewRequest("GET", "/version", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got versionManifest
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v; body=%s", err, rec.Body.String())
}
if got.Version != "1.2.3" {
t.Errorf("version = %q, want %q", got.Version, "1.2.3")
}
if got.BuildNumber != 10203 {
t.Errorf("build_number = %d, want %d", got.BuildNumber, 10203)
}
if !got.ForceUpdate {
t.Errorf("force_update = false, want true")
}
if got.ReleaseNotes != "测试发布说明" {
t.Errorf("release_notes = %q, want %q", got.ReleaseNotes, "测试发布说明")
}
if got.DownloadURLs["android"] != "https://api.yanmeiai.com/downloads/pangolin-android.apk" {
t.Errorf("download_urls.android = %q", got.DownloadURLs["android"])
}
if got.DownloadURLs["windows"] != "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe" {
t.Errorf("download_urls.windows = %q", got.DownloadURLs["windows"])
}
if got.DownloadURLs["macos"] != "" || got.DownloadURLs["ios"] != "" {
t.Errorf("expected empty macos/ios download URLs, got macos=%q ios=%q", got.DownloadURLs["macos"], got.DownloadURLs["ios"])
}
if len(got.Changelog) != 1 || got.Changelog[0].Version != "1.2.3" {
t.Errorf("changelog = %+v, want 1 entry for version 1.2.3", got.Changelog)
}
}
func TestVersionHandler_MissingFileReturnsDefault(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "does-not-exist.yaml")
r := buildVersionRouter(path)
req := httptest.NewRequest("GET", "/version", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200 (missing file falls back to default manifest); body=%s", rec.Code, rec.Body.String())
}
var got versionManifest
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v; body=%s", err, rec.Body.String())
}
if got.Version == "" {
t.Errorf("default manifest: version should not be empty")
}
if got.DownloadURLs["android"] == "" {
t.Errorf("default manifest: download_urls.android should not be empty")
}
if got.DownloadURLs["windows"] == "" {
t.Errorf("default manifest: download_urls.windows should not be empty")
}
if got.Changelog == nil {
t.Errorf("default manifest: changelog should be [] not null")
}
}
func TestVersionHandler_JSONShape(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "version.yaml")
if err := os.WriteFile(path, []byte(`version: "1.0.0"
build_number: 10000
force_update: false
release_notes: ""
download_urls:
android: "https://example.com/a.apk"
`), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
r := buildVersionRouter(path)
req := httptest.NewRequest("GET", "/version", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var raw map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatalf("unmarshal raw response: %v", err)
}
for _, key := range []string{"version", "build_number", "force_update", "release_notes", "download_urls", "changelog"} {
if _, ok := raw[key]; !ok {
t.Errorf("response missing expected top-level key %q; body=%s", key, rec.Body.String())
}
}
// changelog must serialize as an array even when the manifest omits it —
// front-ends should be able to blindly .map()/range over it.
if string(raw["changelog"]) != "[]" {
t.Errorf("changelog = %s, want []", raw["changelog"])
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct)
}
}
func TestVersionHandler_DefaultPathFallback(t *testing.T) {
h := NewVersionHandler("")
if h.path != defaultVersionManifestPath {
t.Errorf("NewVersionHandler(\"\").path = %q, want %q", h.path, defaultVersionManifestPath)
}
}