Merge pull request #1146 from metasequoiaime/feat/octagram-model-update-check
feat(server): 支持在线更新万象模型
This commit is contained in:
6 files changed
+835
-52
No files matched your search
@@ -26,6 +26,7 @@
|
||||
#include "utils/hanzi_pinyin.h"
|
||||
#include "utils/single_instance.h"
|
||||
#include "session/session_factory.h"
|
||||
#include "settings/collocation_model.h"
|
||||
#include "statistics/stats_pipe.h"
|
||||
#include "voice-input/voice_input_service.h"
|
||||
|
||||
@@ -120,6 +121,8 @@ int CALLBACK WinMain(_In_ HINSTANCE hInstance, _In_ HINSTANCE /*hPrevInstance*/,
|
||||
|
||||
// Initialize config
|
||||
InitImeConfig();
|
||||
// 上一次退出时未能落位的模型更新在这里生效:必须早于任何会话创建(此时还没有 .gram 映射)。
|
||||
collocation::ApplyPendingUpdates();
|
||||
VoiceInput::Initialize();
|
||||
Global::candidate_ui.page_size = GetConfiguredCandidatePageSize();
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
#include <ShObjIdl.h>
|
||||
#include <winhttp.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
@@ -29,7 +32,7 @@ const std::vector<CatalogEntry> &Catalog()
|
||||
static const std::vector<CatalogEntry> kCatalog = {
|
||||
{kRecommendedModelId, "万象 LTS(推荐)", "万象", L"github.com",
|
||||
L"/amzxyz/RIME-LMDG/releases/download/LTS/wanxiang-lts-zh-hans.gram", "约 390 MB", "CC-BY-4.0",
|
||||
"© amzxyz / RIME-LMDG 项目"},
|
||||
"© amzxyz / RIME-LMDG 项目", true},
|
||||
{"zh-hans-t-essay-bgw", "八股文·词级", "八股", L"github.com",
|
||||
L"/lotem/rime-octagram-data/releases/download/20260712/zh-hans-t-essay-bgw.gram", "约 197 MB", "LGPL", ""},
|
||||
{"zh-hans-t-essay-bgw-compact", "八股文·词级紧凑", "八股", L"github.com",
|
||||
@@ -71,6 +74,70 @@ std::filesystem::path PartFile(const std::string &model_id)
|
||||
return ModelDirectory(model_id) / (model_id + ".gram.part");
|
||||
}
|
||||
|
||||
// 已校验、等待输入法进程重启后落位的新版本;只在校验通过后才创建。
|
||||
std::filesystem::path PendingModelFile(const std::string &model_id)
|
||||
{
|
||||
return ModelDirectory(model_id) / (model_id + ".gram.new");
|
||||
}
|
||||
|
||||
// <id>.gram 的远端身份(etag / last-modified / length),一行一个 key=value。
|
||||
std::filesystem::path RemoteFile(const std::string &model_id)
|
||||
{
|
||||
return ModelDirectory(model_id) / ".remote";
|
||||
}
|
||||
|
||||
// <id>.gram.new 的远端身份;落位时一并换到 .remote。
|
||||
std::filesystem::path PendingRemoteFile(const std::string &model_id)
|
||||
{
|
||||
return ModelDirectory(model_id) / ".remote.new";
|
||||
}
|
||||
|
||||
std::string TrimSpaces(std::string value)
|
||||
{
|
||||
const size_t begin = value.find_first_not_of(" \t");
|
||||
if (begin == std::string::npos)
|
||||
return {};
|
||||
const size_t end = value.find_last_not_of(" \t");
|
||||
return value.substr(begin, end - begin + 1);
|
||||
}
|
||||
|
||||
// 先写 <file>.tmp 再原子 rename:半截文件不会被下一次探测当成有效身份。
|
||||
bool WriteIdentityFile(const std::filesystem::path &file, const RemoteIdentity &identity)
|
||||
{
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(file.parent_path(), error);
|
||||
if (error)
|
||||
return false;
|
||||
const std::filesystem::path temporary = file.wstring() + L".tmp";
|
||||
{
|
||||
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
|
||||
if (!output)
|
||||
return false;
|
||||
auto write_value = [&output](const char *key, std::string value) {
|
||||
// 值里不允许换行:一行一个 key=value,坏了就让下一次读取把它当空值。
|
||||
std::replace(value.begin(), value.end(), '\r', ' ');
|
||||
std::replace(value.begin(), value.end(), '\n', ' ');
|
||||
output << key << '=' << value << '\n';
|
||||
};
|
||||
write_value("etag", identity.etag);
|
||||
write_value("last-modified", identity.last_modified);
|
||||
output << "length=" << identity.length << '\n';
|
||||
if (!output)
|
||||
return false;
|
||||
}
|
||||
if (!MoveFileExW(temporary.c_str(), file.c_str(), MOVEFILE_REPLACE_EXISTING))
|
||||
{
|
||||
std::filesystem::remove(temporary, error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HasIdentity(const RemoteIdentity &identity)
|
||||
{
|
||||
return !identity.etag.empty() || !identity.last_modified.empty() || identity.length > 0;
|
||||
}
|
||||
|
||||
// 逻辑删除的清扫:.gram 被引擎的内存映射占着时物理删除会失败,DeleteModel 落下的
|
||||
// .trash 标记把目录转成「待清扫」。这里在状态查询与重新下载前重试,映射一旦释放
|
||||
// (切换模型或重启 Server)目录就被清干净。
|
||||
@@ -121,6 +188,78 @@ bool ValidateFormat(const std::filesystem::path &file, std::string &error)
|
||||
return false;
|
||||
}
|
||||
|
||||
// 读最终响应的字符串头。WinHTTP 对自定义头要求先问长度再取缓冲区;缺失时返回空串。
|
||||
std::wstring QueryStringHeader(HINTERNET request, const wchar_t *name)
|
||||
{
|
||||
DWORD bytes = 0;
|
||||
if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_CUSTOM, name, WINHTTP_NO_OUTPUT_BUFFER, &bytes,
|
||||
WINHTTP_NO_HEADER_INDEX) &&
|
||||
GetLastError() != ERROR_INSUFFICIENT_BUFFER)
|
||||
{
|
||||
return {};
|
||||
}
|
||||
if (bytes == 0)
|
||||
return {};
|
||||
std::wstring value(bytes / sizeof(wchar_t), L'\0');
|
||||
if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_CUSTOM, name, value.data(), &bytes, WINHTTP_NO_HEADER_INDEX))
|
||||
return {};
|
||||
value.resize(bytes / sizeof(wchar_t));
|
||||
while (!value.empty() && value.back() == L'\0')
|
||||
value.pop_back();
|
||||
return value;
|
||||
}
|
||||
|
||||
// 只取响应头的一次 HEAD 探测。失败(离线、代理不可用、404/403、超时)一律返回 false,
|
||||
// 调用方静默保留上一结论;不做 Range 兜底,GitHub 支持 HEAD。
|
||||
bool ProbeRemoteIdentity(const CatalogEntry &entry, RemoteIdentity &identity)
|
||||
{
|
||||
HINTERNET session = NetworkProxy::OpenWinHttpSession(L"MetasequoiaImeServer/1.0");
|
||||
if (session == nullptr)
|
||||
return false;
|
||||
// 探测必须短:它跑在设置进程的后台线程里,但一次挂住会拖慢「打开设置即可见更新」。
|
||||
WinHttpSetTimeouts(session, 10000, 10000, 10000, 10000);
|
||||
HINTERNET connection = WinHttpConnect(session, entry.host, INTERNET_DEFAULT_HTTPS_PORT, 0);
|
||||
HINTERNET request = nullptr;
|
||||
bool ok = false;
|
||||
if (connection != nullptr)
|
||||
{
|
||||
request = WinHttpOpenRequest(connection, L"HEAD", entry.path, nullptr, WINHTTP_NO_REFERER,
|
||||
WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE);
|
||||
if (request != nullptr)
|
||||
{
|
||||
DWORD policy = WINHTTP_OPTION_REDIRECT_POLICY_ALWAYS;
|
||||
WinHttpSetOption(request, WINHTTP_OPTION_REDIRECT_POLICY, &policy, sizeof(policy));
|
||||
ok = WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0) &&
|
||||
WinHttpReceiveResponse(request, nullptr);
|
||||
}
|
||||
}
|
||||
if (ok)
|
||||
{
|
||||
DWORD status = 0;
|
||||
DWORD status_size = sizeof(status);
|
||||
ok = WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX) &&
|
||||
status == HTTP_STATUS_OK;
|
||||
}
|
||||
if (ok)
|
||||
{
|
||||
identity.etag = Utf8FromWide(QueryStringHeader(request, L"ETag").c_str());
|
||||
identity.last_modified = Utf8FromWide(QueryStringHeader(request, L"Last-Modified").c_str());
|
||||
DWORD length = 0;
|
||||
DWORD length_size = sizeof(length);
|
||||
if (WinHttpQueryHeaders(request, WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER,
|
||||
WINHTTP_HEADER_NAME_BY_INDEX, &length, &length_size, WINHTTP_NO_HEADER_INDEX))
|
||||
identity.length = length;
|
||||
ok = HasIdentity(identity);
|
||||
}
|
||||
if (request != nullptr)
|
||||
WinHttpCloseHandle(request);
|
||||
if (connection != nullptr)
|
||||
WinHttpCloseHandle(connection);
|
||||
WinHttpCloseHandle(session);
|
||||
return ok;
|
||||
}
|
||||
|
||||
enum class DownloadState
|
||||
{
|
||||
Idle,
|
||||
@@ -146,6 +285,23 @@ std::mutex g_mutex;
|
||||
// 按 id 的下载态,map 大小就是目录条目数。成功后该 id 留下一个 Idle 条目,无碍:
|
||||
// 查询侧以磁盘为准,runtime 态只在文件缺席时补充 downloading/error。
|
||||
std::map<std::string, RuntimeState> g_states;
|
||||
// 本进程探测出的「该 id 有新版本」。只在 g_mutex 下访问;进程生命周期内不做后台轮询。
|
||||
std::map<std::string, bool> g_updates;
|
||||
// CheckForUpdates 一个进程只跑一轮,重复调用(比如窗口重复激活)直接返回。
|
||||
std::atomic<bool> g_update_check_started{false};
|
||||
|
||||
void ClearUpdateFlag(const std::string &model_id)
|
||||
{
|
||||
std::lock_guard lock(g_mutex);
|
||||
g_updates.erase(model_id);
|
||||
}
|
||||
|
||||
bool HasUpdateFlag(const std::string &model_id)
|
||||
{
|
||||
std::lock_guard lock(g_mutex);
|
||||
const auto it = g_updates.find(model_id);
|
||||
return it != g_updates.end() && it->second;
|
||||
}
|
||||
|
||||
void SetState(const std::string &model_id, DownloadState state, int progress, std::string error)
|
||||
{
|
||||
@@ -156,12 +312,13 @@ void SetState(const std::string &model_id, DownloadState state, int progress, st
|
||||
runtime.error = std::move(error);
|
||||
}
|
||||
|
||||
void InstallPart(const CatalogEntry &entry, bool imported);
|
||||
void InstallPart(const CatalogEntry &entry, bool imported, const RemoteIdentity &identity, bool replacing);
|
||||
|
||||
// 后台线程:下载 -> 校验 -> 原子落位。全程只碰 PartFile,失败时不留半个模型
|
||||
// 在正式位置上。条目按值收进线程:detached 线程的生命周期长于调用栈,host/path/id
|
||||
// 必须自持。release 资产与 raw 文件都经 302 跳转到 CDN,显式放开自动重定向。
|
||||
void DownloadThread(CatalogEntry entry)
|
||||
// replacing 表示这次下载是针对已就绪旧模型的更新(只有确认有更新时才为 true)。
|
||||
void DownloadThread(CatalogEntry entry, bool replacing)
|
||||
{
|
||||
const std::string model_id = entry.id;
|
||||
SetState(model_id, DownloadState::Downloading, 0, {});
|
||||
@@ -174,6 +331,9 @@ void DownloadThread(CatalogEntry entry)
|
||||
SetState(model_id, DownloadState::Error, 0, "无法创建模型目录");
|
||||
return;
|
||||
}
|
||||
// 上次失败可能留下半截 .part;重新下载前先清掉,避免续写错位的字节。
|
||||
std::error_code cleanup_error;
|
||||
std::filesystem::remove(PartFile(model_id), cleanup_error);
|
||||
|
||||
HINTERNET session = NetworkProxy::OpenWinHttpSession(L"MetasequoiaImeServer/1.0");
|
||||
if (session == nullptr)
|
||||
@@ -210,6 +370,7 @@ void DownloadThread(CatalogEntry entry)
|
||||
// 总长取响应自己声明的 Content-Length,不再钉死字节数常量:上游重训会让字节数
|
||||
// 变,而这个值顺带充当截断检测的基准(收完比对一次),比钉常量更贴近实际。
|
||||
long long expected = 0;
|
||||
RemoteIdentity identity;
|
||||
if (ok)
|
||||
{
|
||||
DWORD length = 0;
|
||||
@@ -218,7 +379,11 @@ void DownloadThread(CatalogEntry entry)
|
||||
WINHTTP_HEADER_NAME_BY_INDEX, &length, &length_size, WINHTTP_NO_HEADER_INDEX))
|
||||
{
|
||||
expected = length;
|
||||
identity.length = length;
|
||||
}
|
||||
// 安装成功时把最终响应的校验器记进 .remote,下一次探测只比这几百字节的头。
|
||||
identity.etag = Utf8FromWide(QueryStringHeader(request, L"ETag").c_str());
|
||||
identity.last_modified = Utf8FromWide(QueryStringHeader(request, L"Last-Modified").c_str());
|
||||
}
|
||||
|
||||
std::ofstream out;
|
||||
@@ -285,7 +450,7 @@ void DownloadThread(CatalogEntry entry)
|
||||
return;
|
||||
}
|
||||
|
||||
InstallPart(entry, false);
|
||||
InstallPart(entry, false, identity, replacing);
|
||||
}
|
||||
|
||||
// 后台线程:把用户在浏览器里下好的文件复制成 PartFile,之后与下载走同一条校验落位路径。
|
||||
@@ -309,12 +474,29 @@ void ImportThread(CatalogEntry entry, std::filesystem::path source)
|
||||
std::filesystem::remove(PartFile(model_id), cleanup);
|
||||
return;
|
||||
}
|
||||
InstallPart(entry, true);
|
||||
InstallPart(entry, true, {}, false);
|
||||
}
|
||||
|
||||
// 署名义务随模型走:NOTICE 与模型同目录,注明来源与许可;各条目许可不同,
|
||||
// zh-moqi 的许可状态在 license_note 里如实说明。字节数按当前 .gram 重写。
|
||||
void WriteNotice(const CatalogEntry &entry, bool imported)
|
||||
{
|
||||
std::error_code size_error;
|
||||
const long long model_bytes = static_cast<long long>(std::filesystem::file_size(ModelFile(entry.id), size_error));
|
||||
std::ofstream notice(ModelDirectory(entry.id) / "NOTICE.md", std::ios::binary | std::ios::trunc);
|
||||
if (!notice)
|
||||
return;
|
||||
const std::string note = entry.license_note == nullptr ? std::string() : std::string(entry.license_note);
|
||||
notice << "# " << entry.display_name << "(" << entry.id << ")\n\n"
|
||||
<< "- 来源:" << SourceUrl(entry) << (imported ? "(用户自行下载后本地导入)" : "") << "\n"
|
||||
<< "- 许可:" << entry.license << (note.empty() ? "" : "(" + note + ")") << "\n"
|
||||
<< "- 文件:" << entry.id << ".gram(" << model_bytes << " 字节)\n";
|
||||
}
|
||||
|
||||
// PartFile 已完整写好之后的公共尾段:格式校验 -> 原子落位 -> NOTICE。下载与本地导入都走这里,
|
||||
// 所以「文件存在 = 曾通过校验」对两条来源同样成立。
|
||||
void InstallPart(const CatalogEntry &entry, bool imported)
|
||||
// 所以「文件存在 = 曾通过校验」对两条来源同样成立。replacing 只在「确认有更新、覆盖已就绪
|
||||
// 旧模型」时为 true;其余情况(首次下载、逻辑删除后重下)沿用原有的 rename + error 语义。
|
||||
void InstallPart(const CatalogEntry &entry, bool imported, const RemoteIdentity &identity, bool replacing)
|
||||
{
|
||||
const std::string model_id = entry.id;
|
||||
|
||||
@@ -331,56 +513,115 @@ void InstallPart(const CatalogEntry &entry, bool imported)
|
||||
|
||||
// ValidateFormat 的局部 GramDb 已析构、映射已解除,否则下面的 rename 会被
|
||||
// 以 FILE_SHARE_READ 打开的旧句柄拒绝。
|
||||
std::error_code rename_error;
|
||||
std::filesystem::rename(PartFile(model_id), ModelFile(model_id), rename_error);
|
||||
if (rename_error)
|
||||
const std::filesystem::path part = PartFile(model_id);
|
||||
const std::filesystem::path target = ModelFile(model_id);
|
||||
std::error_code exists_error;
|
||||
const bool target_exists = std::filesystem::exists(target, exists_error) && !exists_error;
|
||||
bool placed = false;
|
||||
bool target_busy = false;
|
||||
if (replacing && target_exists)
|
||||
{
|
||||
// 目标 .gram 的旧映射还在(.trash 场景下词典未松手)时 rename 会失败:进
|
||||
// Error 态,等映射释放后重试即可。与其他失败路径一致,.part 不留——重试
|
||||
// 反正会整个重下,留着没有收益。
|
||||
// 更新路径必须覆盖旧模型。目标还被输入法进程以 FILE_SHARE_READ 映射着时,
|
||||
// MoveFileExW 会以共享冲突失败——这不是错误,转「待重启」。
|
||||
if (MoveFileExW(part.c_str(), target.c_str(), MOVEFILE_REPLACE_EXISTING))
|
||||
{
|
||||
placed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
const DWORD move_error = GetLastError();
|
||||
target_busy = move_error == ERROR_SHARING_VIOLATION || move_error == ERROR_ACCESS_DENIED ||
|
||||
move_error == ERROR_LOCK_VIOLATION;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
std::error_code rename_error;
|
||||
std::filesystem::rename(part, target, rename_error);
|
||||
placed = !rename_error;
|
||||
}
|
||||
|
||||
if (!placed && target_busy && replacing && target_exists)
|
||||
{
|
||||
// 新版本已通过校验,先留在 .gram.new(连同身份)。旧模型继续可用,下一次
|
||||
// Server 启动或设置进程启动时由 ApplyPendingUpdates 落位。只有「覆盖已就绪的
|
||||
// 旧模型」才走这条路:逻辑删除后的重下仍按原来的失败处理,不能把已删除的包复活。
|
||||
const std::filesystem::path pending = PendingModelFile(model_id);
|
||||
std::error_code pending_error;
|
||||
std::filesystem::remove(pending, pending_error);
|
||||
pending_error.clear();
|
||||
std::filesystem::rename(part, pending, pending_error);
|
||||
if (pending_error)
|
||||
{
|
||||
SetState(model_id, DownloadState::Error, 0, "模型落位失败");
|
||||
std::error_code cleanup;
|
||||
std::filesystem::remove(part, cleanup);
|
||||
return;
|
||||
}
|
||||
if (HasIdentity(identity))
|
||||
WriteIdentityFile(PendingRemoteFile(model_id), identity);
|
||||
else
|
||||
std::filesystem::remove(PendingRemoteFile(model_id), pending_error);
|
||||
ClearUpdateFlag(model_id);
|
||||
SetState(model_id, DownloadState::Idle, 100, {});
|
||||
return; // NOTICE 等真正落位后再按新文件字节数重写
|
||||
}
|
||||
|
||||
if (!placed)
|
||||
{
|
||||
// 其他失败(目标存在但不是映射冲突、同一目录 rename 被拒)沿用原有 error 路径:
|
||||
// .part 不留,重试会整个重下,旧模型不受影响。
|
||||
SetState(model_id, DownloadState::Error, 0, "模型落位失败");
|
||||
std::error_code cleanup;
|
||||
std::filesystem::remove(PartFile(model_id), cleanup);
|
||||
std::filesystem::remove(part, cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
// 署名义务随模型走:NOTICE 与模型同目录,注明来源与许可;各条目许可不同,
|
||||
// zh-moqi 的许可状态在 license_note 里如实说明。
|
||||
std::error_code size_error;
|
||||
const long long model_bytes = static_cast<long long>(std::filesystem::file_size(ModelFile(model_id), size_error));
|
||||
std::ofstream notice(ModelDirectory(model_id) / "NOTICE.md", std::ios::binary | std::ios::trunc);
|
||||
if (notice)
|
||||
{
|
||||
const std::string note = entry.license_note == nullptr ? std::string() : std::string(entry.license_note);
|
||||
notice << "# " << entry.display_name << "(" << model_id << ")\n\n"
|
||||
<< "- 来源:" << SourceUrl(entry) << (imported ? "(用户自行下载后本地导入)" : "") << "\n"
|
||||
<< "- 许可:" << entry.license << (note.empty() ? "" : "(" + note + ")") << "\n"
|
||||
<< "- 文件:" << model_id << ".gram(" << model_bytes << " 字节)\n";
|
||||
}
|
||||
// 身份随文件走:下载记最终响应头;本地导入没有响应头,删掉旧记录,让下一次
|
||||
// 探测按「旧安装」口径用长度补记录或提示更新。
|
||||
if (imported || !HasIdentity(identity))
|
||||
std::filesystem::remove(RemoteFile(model_id), exists_error);
|
||||
else
|
||||
WriteRemoteIdentity(ModelDirectory(model_id), identity);
|
||||
ClearUpdateFlag(model_id);
|
||||
|
||||
WriteNotice(entry, imported);
|
||||
SetState(model_id, DownloadState::Idle, 100, {});
|
||||
}
|
||||
|
||||
// 下载与导入的公共前置:目录内 id、已就绪不重做、单槽占位。占位成功返回 entry 并把该 id
|
||||
// 置为 state;请求已被满足(已就绪,或同 id 同类任务已在进行)时 satisfied 置 true 并返回
|
||||
// nullptr;其余拒绝返回 nullptr。
|
||||
const CatalogEntry *ClaimSlot(const std::string &model_id, DownloadState state, bool &satisfied)
|
||||
// nullptr;其余拒绝返回 nullptr。replace 为 true 时允许覆盖已就绪的 .gram(仅在有已确认
|
||||
// 更新时由 StartDownload 传入);已有 .gram.new 等待落位时一律不重复下载。replacing 是本次
|
||||
// 占位实际是否在覆盖旧模型:逻辑删除(.trash)后的重下不算,必须走原来的失败路径。
|
||||
const CatalogEntry *ClaimSlot(const std::string &model_id, DownloadState state, bool replace, bool &replacing,
|
||||
bool &satisfied)
|
||||
{
|
||||
replacing = false;
|
||||
satisfied = false;
|
||||
const CatalogEntry *entry = FindEntry(model_id);
|
||||
if (entry == nullptr)
|
||||
return nullptr;
|
||||
PurgeIfTrashed(model_id);
|
||||
std::error_code fs_error;
|
||||
// 已存在且通过格式校验的包不重做:落位只在格式校验之后发生,文件存在即曾通过校验,
|
||||
// 重做只会白白覆盖一份好包。逻辑已删除(.trash 在)的包不在此列:它们必须允许重新
|
||||
// 获取,哪怕旧 .gram 的物理删除还在等映射释放(小包可能在旧映射释放前就落位,
|
||||
// 落位失败进 Error 态,等映射释放后重试即可成功)。
|
||||
const bool trashed = std::filesystem::exists(ModelDirectory(model_id) / ".trash", fs_error) && !fs_error;
|
||||
if (!trashed && std::filesystem::exists(ModelFile(model_id), fs_error) && !fs_error)
|
||||
if (std::filesystem::exists(PendingModelFile(model_id), fs_error) && !fs_error)
|
||||
{
|
||||
satisfied = true;
|
||||
return nullptr;
|
||||
}
|
||||
// 已存在且通过格式校验的包不重做:落位只在格式校验之后发生,文件存在即曾通过校验,
|
||||
// 重做只会白白覆盖一份好包。逻辑已删除(.trash 在)的包不在此列:它们必须允许重新
|
||||
// 获取,哪怕旧 .gram 的物理删除还在等映射释放(小包可能在旧映射释放前就落位,
|
||||
// 落位失败进 Error 态,等映射释放后重试即可成功)。确认有更新时例外:replace 跳过
|
||||
// 这条判断,但下面的单槽守卫仍然生效。
|
||||
const bool trashed = std::filesystem::exists(ModelDirectory(model_id) / ".trash", fs_error) && !fs_error;
|
||||
const bool target_exists = std::filesystem::exists(ModelFile(model_id), fs_error) && !fs_error;
|
||||
if (!replace && !trashed && target_exists)
|
||||
{
|
||||
satisfied = true;
|
||||
return nullptr;
|
||||
}
|
||||
replacing = !trashed && replace && target_exists;
|
||||
std::lock_guard lock(g_mutex);
|
||||
// 单槽:同一时刻至多一个下载或导入,不做并发队列。同 id 同类请求幂等;其他 id
|
||||
// 忙碌时拒绝,页面在忙碌态禁用其余下载/导入按钮。
|
||||
@@ -406,6 +647,72 @@ std::string SourceUrl(const CatalogEntry &entry)
|
||||
return "https://" + Utf8FromWide(entry.host) + Utf8FromWide(entry.path);
|
||||
}
|
||||
|
||||
RemoteIdentity ReadRemoteIdentity(const std::filesystem::path &model_directory)
|
||||
{
|
||||
RemoteIdentity identity;
|
||||
std::ifstream input(model_directory / ".remote", std::ios::binary);
|
||||
if (!input)
|
||||
return identity;
|
||||
std::string line;
|
||||
while (std::getline(input, line))
|
||||
{
|
||||
// 容忍 CRLF:值不跨行,行尾的 \r 不属于值。
|
||||
if (!line.empty() && line.back() == '\r')
|
||||
line.pop_back();
|
||||
const size_t separator = line.find('=');
|
||||
if (separator == std::string::npos)
|
||||
continue;
|
||||
const std::string key = TrimSpaces(line.substr(0, separator));
|
||||
const std::string value = TrimSpaces(line.substr(separator + 1));
|
||||
if (key == "etag")
|
||||
{
|
||||
identity.etag = value;
|
||||
}
|
||||
else if (key == "last-modified")
|
||||
{
|
||||
identity.last_modified = value;
|
||||
}
|
||||
else if (key == "length")
|
||||
{
|
||||
char *end = nullptr;
|
||||
const long long parsed = std::strtoll(value.c_str(), &end, 10);
|
||||
if (end != value.c_str() && parsed > 0)
|
||||
identity.length = parsed;
|
||||
}
|
||||
// 未知键忽略:以后加字段时旧版本仍能读。
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
bool WriteRemoteIdentity(const std::filesystem::path &model_directory, const RemoteIdentity &identity)
|
||||
{
|
||||
return WriteIdentityFile(model_directory / ".remote", identity);
|
||||
}
|
||||
|
||||
UpdateVerdict CompareRemoteIdentity(const RemoteIdentity &installed, long long local_bytes,
|
||||
const RemoteIdentity &remote)
|
||||
{
|
||||
// 远端一个信号都没有:本次探测失败或头被中间层剥掉,不改结论、不误报更新。
|
||||
if (!HasIdentity(remote))
|
||||
return UpdateVerdict::Unknown;
|
||||
// 旧安装没有 .remote:只能比长度。相等即认为是最新,顺便把 etag/last-modified 补记下来;
|
||||
// 不相等才提示更新。字节数恰好相同的重传会漏掉一次,这是不下载 390 MB 的代价。
|
||||
if (!HasIdentity(installed))
|
||||
{
|
||||
if (remote.length <= 0 || local_bytes <= 0)
|
||||
return UpdateVerdict::Unknown;
|
||||
return remote.length == local_bytes ? UpdateVerdict::Current : UpdateVerdict::Update;
|
||||
}
|
||||
if (!installed.etag.empty() && !remote.etag.empty() && installed.etag != remote.etag)
|
||||
return UpdateVerdict::Update;
|
||||
if (!installed.last_modified.empty() && !remote.last_modified.empty() &&
|
||||
installed.last_modified != remote.last_modified)
|
||||
return UpdateVerdict::Update;
|
||||
if (installed.length > 0 && remote.length > 0 && installed.length != remote.length)
|
||||
return UpdateVerdict::Update;
|
||||
return UpdateVerdict::Current;
|
||||
}
|
||||
|
||||
std::map<std::string, ModelStatus> GetModelStatuses()
|
||||
{
|
||||
std::map<std::string, ModelStatus> statuses;
|
||||
@@ -413,14 +720,19 @@ std::map<std::string, ModelStatus> GetModelStatuses()
|
||||
{
|
||||
PurgeIfTrashed(entry.id);
|
||||
std::error_code fs_error;
|
||||
bool update_available = false;
|
||||
// runtime 下载与错误态优先于磁盘:刚失败的落位比「旧的已验证文件还在」更值得
|
||||
// 看见。逻辑已删除(.trash 标记在)一律按未下载呈现,哪怕 .gram 还没物理消失。
|
||||
{
|
||||
std::lock_guard lock(g_mutex);
|
||||
const auto update = g_updates.find(entry.id);
|
||||
update_available = update != g_updates.end() && update->second;
|
||||
const auto it = g_states.find(entry.id);
|
||||
if (it != g_states.end() && it->second.state == DownloadState::Downloading)
|
||||
{
|
||||
statuses[entry.id] = {"downloading", it->second.progress, {}};
|
||||
ModelStatus downloading{"downloading", it->second.progress, {}};
|
||||
downloading.update_available = update_available;
|
||||
statuses[entry.id] = downloading;
|
||||
continue;
|
||||
}
|
||||
if (it != g_states.end() && it->second.state == DownloadState::Importing)
|
||||
@@ -430,7 +742,9 @@ std::map<std::string, ModelStatus> GetModelStatuses()
|
||||
}
|
||||
if (it != g_states.end() && it->second.state == DownloadState::Error)
|
||||
{
|
||||
// 失败态保留 update_available,界面据此仍显示「更新」以便重试。
|
||||
statuses[entry.id] = {"error", 0, it->second.error};
|
||||
statuses[entry.id].update_available = update_available;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -443,7 +757,17 @@ std::map<std::string, ModelStatus> GetModelStatuses()
|
||||
// 字节数:钉死它就等于钉死上游的每次重训。
|
||||
if (std::filesystem::exists(ModelFile(entry.id), fs_error) && !fs_error)
|
||||
{
|
||||
statuses[entry.id] = {"ready", 100, {}};
|
||||
ModelStatus ready{"ready", 100, {}};
|
||||
if (std::filesystem::exists(PendingModelFile(entry.id), fs_error) && !fs_error)
|
||||
{
|
||||
// 新版本已校验、等重启落位;此时不再提示「可更新」,按钮是「待重启」。
|
||||
ready.update_pending = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
ready.update_available = update_available;
|
||||
}
|
||||
statuses[entry.id] = ready;
|
||||
continue;
|
||||
}
|
||||
statuses[entry.id] = {"absent", 0, {}};
|
||||
@@ -451,6 +775,104 @@ std::map<std::string, ModelStatus> GetModelStatuses()
|
||||
return statuses;
|
||||
}
|
||||
|
||||
void CheckForUpdates()
|
||||
{
|
||||
// 一个设置进程只探一轮:窗口反复显示/隐藏不重复联网;失败也不循环重试。
|
||||
if (g_update_check_started.exchange(true))
|
||||
return;
|
||||
for (const auto &entry : Catalog())
|
||||
{
|
||||
const std::string model_id = entry.id;
|
||||
if (!entry.track_updates)
|
||||
continue;
|
||||
std::error_code fs_error;
|
||||
if (!std::filesystem::exists(ModelFile(model_id), fs_error) || fs_error)
|
||||
continue;
|
||||
// 已逻辑删除的包不再探测:删除意愿优先,避免并发下载把已删除的包重新带回来。
|
||||
if (std::filesystem::exists(ModelDirectory(model_id) / ".trash", fs_error) && !fs_error)
|
||||
continue;
|
||||
// 已经有待落位的新版本:不再探测,避免把「待重启」覆盖成「可更新」。
|
||||
if (std::filesystem::exists(PendingModelFile(model_id), fs_error) && !fs_error)
|
||||
continue;
|
||||
{
|
||||
std::lock_guard lock(g_mutex);
|
||||
const auto runtime = g_states.find(model_id);
|
||||
if (runtime != g_states.end() && IsBusy(runtime->second.state))
|
||||
continue;
|
||||
}
|
||||
|
||||
const RemoteIdentity installed = ReadRemoteIdentity(ModelDirectory(model_id));
|
||||
RemoteIdentity remote;
|
||||
if (!ProbeRemoteIdentity(entry, remote))
|
||||
continue; // 离线 / 代理不可用 / 4xx:静默保留上一结论
|
||||
|
||||
std::error_code size_error;
|
||||
const auto file_bytes = std::filesystem::file_size(ModelFile(model_id), size_error);
|
||||
const long long local_bytes = size_error ? 0 : static_cast<long long>(file_bytes);
|
||||
const UpdateVerdict verdict = CompareRemoteIdentity(installed, local_bytes, remote);
|
||||
if (verdict == UpdateVerdict::Unknown)
|
||||
continue;
|
||||
// 探测要几百毫秒到几秒,期间用户可能已经删掉这个模型:删除意愿优先。这里再看一眼,
|
||||
// 既不写结论,也不能让下面的补记录把刚删掉的目录重新建出来(WriteIdentityFile
|
||||
// 会 create_directories)。
|
||||
if (!std::filesystem::exists(ModelFile(model_id), fs_error) || fs_error)
|
||||
continue;
|
||||
if (std::filesystem::exists(ModelDirectory(model_id) / ".trash", fs_error) && !fs_error)
|
||||
continue;
|
||||
|
||||
const bool write_legacy_record = !HasIdentity(installed) && verdict == UpdateVerdict::Current;
|
||||
// 网络 I/O 已结束,改内存结论很快;补记录放到锁外,避免磁盘操作拖住状态查询。
|
||||
{
|
||||
std::lock_guard lock(g_mutex);
|
||||
g_updates[model_id] = verdict == UpdateVerdict::Update;
|
||||
}
|
||||
if (write_legacy_record)
|
||||
WriteRemoteIdentity(ModelDirectory(model_id), remote);
|
||||
}
|
||||
}
|
||||
|
||||
void ApplyPendingUpdates()
|
||||
{
|
||||
for (const auto &entry : Catalog())
|
||||
{
|
||||
if (!entry.track_updates)
|
||||
continue;
|
||||
const std::string model_id = entry.id;
|
||||
std::error_code fs_error;
|
||||
const std::filesystem::path pending = PendingModelFile(model_id);
|
||||
if (!std::filesystem::exists(pending, fs_error) || fs_error)
|
||||
continue;
|
||||
// 用户已经删除了这个模型:删除意愿优先,待更新文件随目录一起清掉,不复活。
|
||||
if (std::filesystem::exists(ModelDirectory(model_id) / ".trash", fs_error) && !fs_error)
|
||||
continue;
|
||||
|
||||
// .gram.new 只在格式校验通过后才创建,这里不再重复校验(design 的「不重复校验」)。
|
||||
// 直接原子替换:旧模型还被映射时 MoveFileExW 会失败并保持原文件不动,不会出现
|
||||
// 「旧的已删、新的没换上」的窗口。
|
||||
const std::filesystem::path target = ModelFile(model_id);
|
||||
if (!MoveFileExW(pending.c_str(), target.c_str(), MOVEFILE_REPLACE_EXISTING))
|
||||
continue; // 旧模型仍被映射或其他落位失败:保留 .new 与 .remote.new,下次启动再试
|
||||
|
||||
const std::filesystem::path pending_remote = PendingRemoteFile(model_id);
|
||||
std::error_code remote_error;
|
||||
if (std::filesystem::exists(pending_remote, remote_error) && !remote_error)
|
||||
{
|
||||
if (!MoveFileExW(pending_remote.c_str(), RemoteFile(model_id).c_str(), MOVEFILE_REPLACE_EXISTING))
|
||||
std::filesystem::remove(RemoteFile(model_id), remote_error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 没有新身份(例如旧版本留下的 .gram.new):删掉旧记录,下一次探测按旧安装口径补。
|
||||
std::filesystem::remove(RemoteFile(model_id), remote_error);
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(g_mutex);
|
||||
g_updates.erase(model_id);
|
||||
}
|
||||
WriteNotice(entry, false);
|
||||
}
|
||||
}
|
||||
|
||||
// 手填的 id(自备模型、目录收录前就装好的包)不在目录里。八股是 octagram 这套格式的通称,
|
||||
// 不属于任何一家,用它兜底比挂一个猜出来的品牌名诚实。
|
||||
constexpr const char *kGenericBadge = "八股";
|
||||
@@ -464,10 +886,13 @@ const char *BadgeForModel(const std::string &model_id)
|
||||
bool StartDownload(const std::string &model_id)
|
||||
{
|
||||
bool satisfied = false;
|
||||
const CatalogEntry *entry = ClaimSlot(model_id, DownloadState::Downloading, satisfied);
|
||||
bool replacing = false;
|
||||
// 只有探测确认有更新时才允许覆盖已就绪的 .gram;否则保持原来的幂等语义。
|
||||
const CatalogEntry *entry =
|
||||
ClaimSlot(model_id, DownloadState::Downloading, HasUpdateFlag(model_id), replacing, satisfied);
|
||||
if (entry == nullptr)
|
||||
return satisfied;
|
||||
std::thread(DownloadThread, *entry).detach();
|
||||
std::thread(DownloadThread, *entry, replacing).detach();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -477,7 +902,8 @@ bool ImportModel(const std::string &model_id, const std::filesystem::path &sourc
|
||||
if (!std::filesystem::is_regular_file(source, fs_error) || fs_error)
|
||||
return false;
|
||||
bool satisfied = false;
|
||||
const CatalogEntry *entry = ClaimSlot(model_id, DownloadState::Importing, satisfied);
|
||||
bool replacing = false;
|
||||
const CatalogEntry *entry = ClaimSlot(model_id, DownloadState::Importing, false, replacing, satisfied);
|
||||
if (entry == nullptr)
|
||||
return satisfied;
|
||||
std::thread(ImportThread, *entry, source).detach();
|
||||
@@ -535,6 +961,12 @@ bool DeleteModel(const std::string &model_id)
|
||||
return false;
|
||||
}
|
||||
std::error_code error;
|
||||
// 待更新/远端身份是旁路文件,不受 .gram 映射影响;单独删掉,不能靠 remove_all 在
|
||||
// .gram 删除失败时顺带清理(它可能提前中止)。
|
||||
std::filesystem::remove(PendingModelFile(model_id), error);
|
||||
std::filesystem::remove(PendingRemoteFile(model_id), error);
|
||||
std::filesystem::remove(RemoteFile(model_id), error);
|
||||
error.clear();
|
||||
// .gram 与 NOTICE.md 都在这个目录里,整目录删除即卸载;目录不存在时 remove_all
|
||||
// 无错返回,删除因此是幂等的。
|
||||
std::filesystem::remove_all(ModelDirectory(model_id), error);
|
||||
@@ -546,6 +978,8 @@ bool DeleteModel(const std::string &model_id)
|
||||
// 配置时松手,状态查询的重试清扫就能真正删掉文件。
|
||||
std::ofstream trash(ModelDirectory(model_id) / ".trash", std::ios::binary | std::ios::trunc);
|
||||
}
|
||||
// 内存里的更新结论随删除一起清掉:重新下载后由下一次探测重新建立。
|
||||
ClearUpdateFlag(model_id);
|
||||
// 引擎缓存里这个路径的映射引用摘掉:词典在下一次应用配置(击键)松手后,
|
||||
// 上面的 .trash 清扫就能真正物理删除残留的 .gram(不用等重启)。
|
||||
gram::shared_gram_db_evict(ModelFile(model_id));
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
// 状态以磁盘为准(模型文件存在 = ready,落位发生在格式校验之后),下载态是各进程内存里的
|
||||
// runtime 状态:独立设置进程与 Server 各自能看到自己的下载进度。同一时刻至多一个下载在飞
|
||||
// (单网络槽),忙碌时对新 id 的下载请求直接拒绝,页面在下载态禁用其余下载按钮。
|
||||
//
|
||||
// 更新检测只对目录里 track_updates 的条目生效(本期仅万象):安装成功时把远端身份记进
|
||||
// models/<id>/.remote,设置进程打开时做一次 HEAD 探测比较。已就绪不重下只在「没有已确认
|
||||
// 更新」时成立;确认有更新后允许 StartDownload 覆盖 .gram。目标被输入法进程映射、无法原子
|
||||
// 替换时,校验过的新文件落成 <id>.gram.new(身份落 .remote.new),由下一次 Server 启动或
|
||||
// 设置进程启动的 ApplyPendingUpdates() 完成落位。
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
@@ -43,6 +49,9 @@ struct CatalogEntry
|
||||
const char *size_hint;
|
||||
const char *license;
|
||||
const char *license_note; // 可为空串:无补充说明
|
||||
// 是否参与「打开设置时探测远端更新」。只有上游会原地重传、且地址稳定的条目才置 true;
|
||||
// 八股文固定 tag、白霜 raw master 保持默认 false,后续启用只改这一行。
|
||||
bool track_updates = false;
|
||||
};
|
||||
|
||||
// 编译期内置目录,首条目是推荐包。
|
||||
@@ -58,14 +67,50 @@ const char *BadgeForModel(const std::string &model_id);
|
||||
|
||||
struct ModelStatus
|
||||
{
|
||||
std::string state; // "absent" | "downloading" | "importing" | "ready" | "error"
|
||||
int progress = 0; // 下载中 0-100;其余状态无意义
|
||||
std::string error; // state == "error" 时的人类可读原因
|
||||
std::string state; // "absent" | "downloading" | "importing" | "ready" | "error"
|
||||
int progress = 0; // 下载中 0-100;其余状态无意义
|
||||
std::string error; // state == "error" 时的人类可读原因
|
||||
bool update_available = false; // 远端身份与本地记录不同,可以更新
|
||||
bool update_pending = false; // 新版本已下载并通过校验,等输入法进程重启落位
|
||||
};
|
||||
|
||||
// 磁盘现状 + 本进程下载态,按目录条目逐 id 给出。轻量,可随配置快照频繁调用。
|
||||
// models/<id>/.remote 的内容:一行一个 key=value(etag / last-modified / length)。ETag 与
|
||||
// Last-Modified 可能为空,length 始终记录。写入先落 .tmp 再原子 rename。
|
||||
struct RemoteIdentity
|
||||
{
|
||||
std::string etag;
|
||||
std::string last_modified;
|
||||
long long length = 0; // 0 = 未知
|
||||
};
|
||||
|
||||
enum class UpdateVerdict
|
||||
{
|
||||
Current, // 与本地记录一致
|
||||
Update, // 远端重传过
|
||||
Unknown, // 远端没有可用信号,无法判断
|
||||
};
|
||||
|
||||
// 纯判定:installed 为空结构表示旧安装(没有 .remote),此时按 local_bytes 与远端长度比较。
|
||||
// 不联网、不读盘,供 CheckForUpdates 与测试共用。
|
||||
UpdateVerdict CompareRemoteIdentity(const RemoteIdentity &installed, long long local_bytes,
|
||||
const RemoteIdentity &remote);
|
||||
|
||||
// .remote 的读写。model_directory 是该模型的目录;文件不存在或解析失败都返回空结构,
|
||||
// 写入失败返回 false,不抛异常(下载线程不能因旁路文件失败而崩)。
|
||||
RemoteIdentity ReadRemoteIdentity(const std::filesystem::path &model_directory);
|
||||
bool WriteRemoteIdentity(const std::filesystem::path &model_directory, const RemoteIdentity &identity);
|
||||
|
||||
// 磁盘现状 + 本进程下载态,按目录条目逐 id 给出。轻量,可随配置快照频繁调用;绝不联网。
|
||||
std::map<std::string, ModelStatus> GetModelStatuses();
|
||||
|
||||
// 对 track_updates 且已就绪的条目做一次 HEAD 探测并把结论记进本进程内存。一个进程只探一轮,
|
||||
// 重复调用直接返回;失败静默放弃,不改变已有结论。
|
||||
void CheckForUpdates();
|
||||
|
||||
// 把校验通过、等待落位的 <id>.gram.new 换到 <id>.gram(及 .remote.new → .remote)。
|
||||
// Server 启动与设置进程初始 worker 各调一次,谁先拿到文件谁完成;旧文件仍被映射时保留待更新。
|
||||
void ApplyPendingUpdates();
|
||||
|
||||
// 幂等启动指定模型的下载:该 id 已在下载或已就绪时返回 true 不重复启动;同一时刻至多一个
|
||||
// 下载在飞,其他 id 忙碌时返回 false。目录里没有的 id、无法创建目录等同步失败也返回
|
||||
// false,原因记入该 id 的状态。完成/失败经状态查询可见。
|
||||
|
||||
@@ -1137,11 +1137,23 @@ bool IsBusy(const std::string &state)
|
||||
return state == "downloading" || state == "importing";
|
||||
}
|
||||
|
||||
bool UpdatePending(const Json *status)
|
||||
{
|
||||
return status && status->is_object() && status->value("updatePending", false);
|
||||
}
|
||||
|
||||
bool UpdateAvailable(const Json *status)
|
||||
{
|
||||
return status && status->is_object() && status->value("updateAvailable", false);
|
||||
}
|
||||
|
||||
std::wstring StatusText(const Json *status)
|
||||
{
|
||||
if (UpdatePending(status))
|
||||
return L"新版本已下载,点「重启输入法」生效";
|
||||
const std::string state = status && status->is_object() ? status->value("state", std::string("absent")) : "absent";
|
||||
if (state == "ready")
|
||||
return L"模型已就绪";
|
||||
return UpdateAvailable(status) ? L"有新版本可用" : L"模型已就绪";
|
||||
if (state == "downloading")
|
||||
{
|
||||
const int progress =
|
||||
@@ -1171,6 +1183,8 @@ struct ModelRow
|
||||
std::shared_ptr<Label> status;
|
||||
std::shared_ptr<BareRadio> radio;
|
||||
std::shared_ptr<Button> download;
|
||||
std::function<void()> downloadAction;
|
||||
std::function<void()> restartAction;
|
||||
std::shared_ptr<Hideable> importBox;
|
||||
std::shared_ptr<Button> import;
|
||||
std::shared_ptr<Hideable> removeBox;
|
||||
@@ -1220,7 +1234,10 @@ ModelRow MakeModelRow(PageContext &context, const std::string &id, const std::ws
|
||||
actions->Add(row.radio);
|
||||
}
|
||||
row.download = std::make_shared<Button>(L"下载", ButtonKind::Secondary);
|
||||
row.download->SetOnClick([model, downloadType, data]() { model->Post(downloadType, data); });
|
||||
row.downloadAction = [model, downloadType, data]() { model->Post(downloadType, data); };
|
||||
// 待重启时同一个按钮换成「重启」,直接走设置页已有的 restartServer 动作。
|
||||
row.restartAction = [model]() { model->Post("restartServer"); };
|
||||
row.download->SetOnClick(row.downloadAction);
|
||||
actions->Add(row.download);
|
||||
row.import = std::make_shared<Button>(L"导入", ButtonKind::Secondary);
|
||||
row.import->SetOnClick([model, importType, data]() { model->Post(importType, data); });
|
||||
@@ -2150,18 +2167,28 @@ VisualPtr BuildInputPage(PageContext &context)
|
||||
const Json *statuses = model->Find("association.sentence_collocation_model_status");
|
||||
const std::string active = model->GetString("association.sentence_collocation_model");
|
||||
bool anyBusy = false;
|
||||
bool anyPending = false;
|
||||
if (statuses && statuses->is_object())
|
||||
{
|
||||
for (const auto &item : statuses->items())
|
||||
{
|
||||
anyBusy = anyBusy || IsBusy(StateOf(&item.value()));
|
||||
anyPending = anyPending || UpdatePending(&item.value());
|
||||
}
|
||||
}
|
||||
for (ModelRow &row : s->collocationRows)
|
||||
{
|
||||
const Json *status =
|
||||
statuses && statuses->is_object() && statuses->contains(row.id) ? &(*statuses)[row.id] : nullptr;
|
||||
const std::string stateName = StateOf(status);
|
||||
const bool pending = UpdatePending(status);
|
||||
const bool update_available = UpdateAvailable(status);
|
||||
row.status->SetText(StatusText(status));
|
||||
row.download->SetDisabled(stateName == "ready" || anyBusy);
|
||||
// 每次快照都重设按钮文本和动作,避免上一次的「更新 / 重启」残留到别的状态。
|
||||
// 待重启时按钮直接变成可点的「重启」,点下去走设置页已有的 restartServer。
|
||||
row.download->SetText(pending ? L"重启输入法" : (update_available ? L"更新" : L"下载"));
|
||||
row.download->SetOnClick(pending ? row.restartAction : row.downloadAction);
|
||||
row.download->SetDisabled(pending ? false : ((stateName == "ready" && !update_available) || anyBusy));
|
||||
row.importBox->SetVisible(stateName != "ready");
|
||||
row.import->SetDisabled(anyBusy);
|
||||
row.removeBox->SetVisible(stateName == "ready");
|
||||
@@ -2170,10 +2197,11 @@ VisualPtr BuildInputPage(PageContext &context)
|
||||
const Json *activeStatus =
|
||||
statuses && statuses->is_object() && statuses->contains(active) ? &(*statuses)[active] : nullptr;
|
||||
s->collocationToggle->SetDisabled(StateOf(activeStatus) != "ready");
|
||||
if (anyBusy && s->collocationPoll == 0)
|
||||
// 待重启期间也轮询:点「重启」后 Server 落位完,下一份快照就能把按钮恢复成「下载」。
|
||||
if ((anyBusy || anyPending) && s->collocationPoll == 0)
|
||||
s->collocationPoll = PageTimers::Start(host->Hwnd(), 2000, true,
|
||||
[model]() { model->Post("collocationModelStatusRequest"); });
|
||||
else if (!anyBusy)
|
||||
else if (!anyBusy && !anyPending)
|
||||
PageTimers::Stop(s->collocationPoll);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include "skin/candidate_skin_catalog.h"
|
||||
#include "window/candidate_skin_layers.h"
|
||||
#include "utils/common_utils.h"
|
||||
@@ -59,9 +60,14 @@ constexpr UINT kScanSkinCatalog = WM_APP + 3;
|
||||
constexpr UINT kWorkerCompleted = WM_APP + 4;
|
||||
// Sent by settings_launcher.cpp (the Server exiting) and by the installer: close for real.
|
||||
constexpr UINT kQuitSettings = WM_APP + 5;
|
||||
// 模型更新探测线程完成:UI 线程重新取一份快照,探测线程不碰 g_worker。
|
||||
constexpr UINT kModelUpdateChecked = WM_APP + 6;
|
||||
// Timer 1 is msimeui's caret blink in the Direct2D window, so ours start elsewhere.
|
||||
constexpr UINT_PTR kConfigReloadTimer = 0x5301;
|
||||
constexpr UINT_PTR kLingerTimer = 0x5302;
|
||||
// Server 重启后主动刷新快照的短时定时器;不重启设置进程。
|
||||
constexpr UINT_PTR kRestartRefreshTimer = 0x5303;
|
||||
constexpr int kRestartRefreshTicks = 10; // 500ms × 10 = 5s,覆盖 Watchdog 拉起 Server 的时间
|
||||
|
||||
// The Direct2D settings window (msimeui). Null until wWinMain builds it.
|
||||
std::unique_ptr<native_settings::SettingsWindow> g_native;
|
||||
@@ -77,6 +83,10 @@ bool g_lingering = false;
|
||||
bool g_reload_pending = false;
|
||||
bool g_theme_refresh_pending = false;
|
||||
bool g_settings_light = false;
|
||||
// 第一份快照真正下发给页面之后才启动更新探测;一个设置进程只探一轮。
|
||||
bool g_model_update_check_started = false;
|
||||
// restartServer 之后主动刷新的剩余次数;Server 落位 .gram.new 后提前结束。
|
||||
int g_restart_refresh_remaining = 0;
|
||||
// appearance.settings_window_linger 的 UI 线程副本,由 ConfigCompletion 更新。
|
||||
std::string g_settings_window_linger = "off";
|
||||
std::wstring g_last_config_message;
|
||||
@@ -403,8 +413,11 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog)
|
||||
{"sentence_collocation_model_status", [] {
|
||||
nlohmann::json statuses = nlohmann::json::object();
|
||||
for (const auto &[model_id, status] : collocation::GetModelStatuses())
|
||||
statuses[model_id] = nlohmann::json{{"state", status.state}, {"progress", status.progress},
|
||||
{"error", status.error}};
|
||||
statuses[model_id] = nlohmann::json{{"state", status.state},
|
||||
{"progress", status.progress},
|
||||
{"error", status.error},
|
||||
{"updateAvailable", status.update_available},
|
||||
{"updatePending", status.update_pending}};
|
||||
return statuses;
|
||||
}()},
|
||||
{"sentence_collocation_model", GetConfiguredAssocSentenceCollocationModel()},
|
||||
@@ -1458,6 +1471,14 @@ void HandleClientMessage(HWND hwnd, const std::string &raw)
|
||||
MessageBoxW(g_settings_hwnd, L"未能重启输入法进程,请稍后重试。", L"水杉输入法",
|
||||
MB_OK | MB_ICONWARNING);
|
||||
}
|
||||
else if (g_worker && !g_closing)
|
||||
{
|
||||
// Watchdog 拉起新 Server 后才会落位 .gram.new;设置进程不会因为这次重启
|
||||
// 收到配置变化,所以在接下来几秒主动拉快照,待更新清掉或次数用完就停。
|
||||
// 不重启设置进程:避免单实例/重开时序问题和窗口闪现。
|
||||
g_restart_refresh_remaining = kRestartRefreshTicks;
|
||||
SetTimer(hwnd, kRestartRefreshTimer, 500, nullptr);
|
||||
}
|
||||
}
|
||||
else if (type == "openExternalUrl")
|
||||
{
|
||||
@@ -1693,6 +1714,7 @@ void CloseSettings(HWND hwnd)
|
||||
g_lingering = false;
|
||||
KillTimer(hwnd, kLingerTimer);
|
||||
KillTimer(hwnd, kConfigReloadTimer);
|
||||
KillTimer(hwnd, kRestartRefreshTimer);
|
||||
ReleaseMouseCapture(hwnd);
|
||||
ShowWindow(hwnd, SW_HIDE);
|
||||
if (g_worker)
|
||||
@@ -1785,6 +1807,20 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
|
||||
CloseSettings(hwnd);
|
||||
return 0;
|
||||
}
|
||||
if (w_param == kRestartRefreshTimer)
|
||||
{
|
||||
if (g_restart_refresh_remaining > 0)
|
||||
--g_restart_refresh_remaining;
|
||||
bool pending = false;
|
||||
for (const auto &entry : collocation::GetModelStatuses())
|
||||
pending = pending || entry.second.update_pending;
|
||||
// 待更新已经被新 Server 落位,或兜底次数用完,就停;仍刷新一次让页面恢复。
|
||||
if (!pending || g_restart_refresh_remaining <= 0)
|
||||
KillTimer(hwnd, kRestartRefreshTimer);
|
||||
if (g_worker && !g_closing)
|
||||
PostConfig(false);
|
||||
return 0;
|
||||
}
|
||||
if (w_param == kConfigReloadTimer && g_worker && !g_closing && !g_reload_pending)
|
||||
{
|
||||
g_reload_pending = true;
|
||||
@@ -1810,6 +1846,17 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
|
||||
if (g_worker)
|
||||
{
|
||||
g_worker->Drain(!g_closing);
|
||||
// 第一份快照真正下发之后才探测:此时页面已经有模型列表,探测结果回来只需刷新
|
||||
// 快照。线程不碰 g_worker,避免进程退出时的生命周期竞争;窗口先关掉时消息自然丢弃。
|
||||
if (!g_model_update_check_started && !g_closing && !g_last_config_message.empty())
|
||||
{
|
||||
g_model_update_check_started = true;
|
||||
const HWND target = hwnd;
|
||||
std::thread([target] {
|
||||
collocation::CheckForUpdates();
|
||||
PostMessageW(target, kModelUpdateChecked, 0, 0);
|
||||
}).detach();
|
||||
}
|
||||
if (g_closing && g_worker->Stopped())
|
||||
{
|
||||
// The settings window leaves without destroying its window: see the
|
||||
@@ -1818,6 +1865,10 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case kModelUpdateChecked:
|
||||
// 探测线程只回一条消息,刷新快照仍在 worker 队列上做,UI 线程不被网络阻塞。
|
||||
PostConfig(false);
|
||||
return 0;
|
||||
case WM_CLOSE:
|
||||
HideSettings(hwnd);
|
||||
return 0;
|
||||
@@ -1831,6 +1882,7 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
|
||||
g_worker->Stop();
|
||||
KillTimer(hwnd, kLingerTimer);
|
||||
KillTimer(hwnd, kConfigReloadTimer);
|
||||
KillTimer(hwnd, kRestartRefreshTimer);
|
||||
g_native.reset();
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
@@ -1913,6 +1965,8 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
|
||||
TraceStartup("worker: InitImeConfig begin");
|
||||
InitImeConfig();
|
||||
TraceStartup("worker: InitImeConfig end");
|
||||
// 上一次退出时没能落位的新版本在这里生效:此时还没有任何会话/映射。
|
||||
collocation::ApplyPendingUpdates();
|
||||
initial_config->set_value(ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light");
|
||||
return ConfigCompletion();
|
||||
});
|
||||
|
||||
@@ -121,6 +121,23 @@ void SeedModelFile(const std::filesystem::path &models_dir, const std::wstring &
|
||||
REQUIRE(static_cast<bool>(output));
|
||||
output << "gram";
|
||||
}
|
||||
|
||||
void WriteTextFile(const std::filesystem::path &file, const std::string &text)
|
||||
{
|
||||
std::ofstream output(file, std::ios::binary | std::ios::trunc);
|
||||
REQUIRE(static_cast<bool>(output));
|
||||
output << text;
|
||||
}
|
||||
|
||||
// 最小的合法 octagram 头:魔数 + 空双数组(44 字节,边界检查恰好落在文件内)。
|
||||
// 导入链路会真的跑 GramDb::open 的格式校验,随便几个字节过不了。
|
||||
void WriteMinimalGramFile(const std::filesystem::path &file)
|
||||
{
|
||||
std::string bytes(44, '\0');
|
||||
const std::string magic = "Rime::Grammar/";
|
||||
bytes.replace(0, magic.size(), magic);
|
||||
WriteTextFile(file, bytes);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE(collocation_catalog_lists_four_builtin_models)
|
||||
@@ -276,4 +293,206 @@ TEST_CASE(collocation_import_rejects_bad_ids_and_sources_and_validates_format)
|
||||
REQUIRE(collocation::ImportModel(collocation::kRecommendedModelId, source));
|
||||
REQUIRE_EQ(std::filesystem::file_size(env.models_dir() / L"wanxiang-lts-zh-hans" / L"wanxiang-lts-zh-hans.gram"),
|
||||
static_cast<std::uintmax_t>(4));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE(collocation_remote_identity_verdicts_follow_etag_modified_length)
|
||||
{
|
||||
const collocation::RemoteIdentity installed{"\"0x8DF2D93621D626A\"", "Mon, 19 Oct 2026 03:45:13 GMT", 398497836LL};
|
||||
collocation::RemoteIdentity remote = installed;
|
||||
REQUIRE(collocation::CompareRemoteIdentity(installed, 398497836LL, remote) == collocation::UpdateVerdict::Current);
|
||||
|
||||
// 三个判据任一变化都算上游重传过。
|
||||
remote.etag = "\"0xOTHER\"";
|
||||
REQUIRE(collocation::CompareRemoteIdentity(installed, 398497836LL, remote) == collocation::UpdateVerdict::Update);
|
||||
remote = installed;
|
||||
remote.last_modified = "Tue, 20 Oct 2026 03:45:13 GMT";
|
||||
REQUIRE(collocation::CompareRemoteIdentity(installed, 398497836LL, remote) == collocation::UpdateVerdict::Update);
|
||||
remote = installed;
|
||||
remote.length = 398497837LL;
|
||||
REQUIRE(collocation::CompareRemoteIdentity(installed, 398497836LL, remote) == collocation::UpdateVerdict::Update);
|
||||
|
||||
// 旧安装没有 .remote:长度相等按最新处理(同时补记录),不等才提示更新。
|
||||
const collocation::RemoteIdentity empty;
|
||||
REQUIRE(collocation::CompareRemoteIdentity(empty, 398497836LL, installed) == collocation::UpdateVerdict::Current);
|
||||
REQUIRE(collocation::CompareRemoteIdentity(empty, 398497835LL, installed) == collocation::UpdateVerdict::Update);
|
||||
// 远端一个信号都没有(离线 / 代理剥掉头):不误报。
|
||||
REQUIRE(collocation::CompareRemoteIdentity(installed, 398497836LL, empty) == collocation::UpdateVerdict::Unknown);
|
||||
REQUIRE(collocation::CompareRemoteIdentity(empty, 398497836LL, empty) == collocation::UpdateVerdict::Unknown);
|
||||
}
|
||||
|
||||
TEST_CASE(collocation_remote_identity_tolerates_missing_and_malformed_files)
|
||||
{
|
||||
ScopedCollocationEnvironment env;
|
||||
const std::filesystem::path directory = env.models_dir() / L"wanxiang-lts-zh-hans";
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(directory, ec);
|
||||
REQUIRE(!ec);
|
||||
|
||||
// 文件不存在:空结构,不抛异常。
|
||||
const collocation::RemoteIdentity missing = collocation::ReadRemoteIdentity(directory);
|
||||
REQUIRE(missing.etag.empty());
|
||||
REQUIRE(missing.last_modified.empty());
|
||||
REQUIRE_EQ(missing.length, static_cast<long long>(0));
|
||||
|
||||
const collocation::RemoteIdentity identity{"\"0x8DF2D93621D626A\"", "Mon, 19 Oct 2026 03:45:13 GMT", 398497836LL};
|
||||
REQUIRE(collocation::WriteRemoteIdentity(directory, identity));
|
||||
const collocation::RemoteIdentity read = collocation::ReadRemoteIdentity(directory);
|
||||
REQUIRE_EQ(read.etag, identity.etag);
|
||||
REQUIRE_EQ(read.last_modified, identity.last_modified);
|
||||
REQUIRE_EQ(read.length, identity.length);
|
||||
|
||||
// CRLF、空值、未知键、坏长度都容错:能读的读,坏的当未知。
|
||||
WriteTextFile(directory / L".remote", "etag=\r\nlast-modified=Tue, 20 Oct 2026 00:00:00 GMT\r\n"
|
||||
"length=not-a-number\r\nfuture-key=whatever\r\n\r\n");
|
||||
const collocation::RemoteIdentity tolerant = collocation::ReadRemoteIdentity(directory);
|
||||
REQUIRE(tolerant.etag.empty());
|
||||
REQUIRE_EQ(tolerant.last_modified, std::string("Tue, 20 Oct 2026 00:00:00 GMT"));
|
||||
REQUIRE_EQ(tolerant.length, static_cast<long long>(0));
|
||||
}
|
||||
|
||||
TEST_CASE(collocation_apply_pending_updates_swaps_model_and_remote_identity)
|
||||
{
|
||||
ScopedCollocationEnvironment env;
|
||||
const std::wstring id = L"wanxiang-lts-zh-hans";
|
||||
const std::filesystem::path directory = env.models_dir() / id;
|
||||
SeedModelFile(env.models_dir(), id); // 旧模型,4 字节
|
||||
WriteTextFile(directory / L"wanxiang-lts-zh-hans.gram.new", "new-gram-model"); // 14 字节
|
||||
REQUIRE(collocation::WriteRemoteIdentity(directory, {"\"old\"", "old-time", 4}));
|
||||
WriteTextFile(directory / L".remote.new", "etag=\"new\"\nlast-modified=new-time\nlength=14\n");
|
||||
|
||||
collocation::ApplyPendingUpdates();
|
||||
|
||||
REQUIRE(!std::filesystem::exists(directory / L"wanxiang-lts-zh-hans.gram.new"));
|
||||
REQUIRE(!std::filesystem::exists(directory / L".remote.new"));
|
||||
REQUIRE_EQ(std::filesystem::file_size(directory / L"wanxiang-lts-zh-hans.gram"), static_cast<std::uintmax_t>(14));
|
||||
const collocation::RemoteIdentity applied = collocation::ReadRemoteIdentity(directory);
|
||||
REQUIRE_EQ(applied.etag, std::string("\"new\""));
|
||||
REQUIRE_EQ(applied.last_modified, std::string("new-time"));
|
||||
REQUIRE_EQ(applied.length, static_cast<long long>(14));
|
||||
|
||||
// 只有 .gram.new、没有旧 .gram:直接落位;没有 .remote.new 时删掉旧记录,
|
||||
// 下一次探测按「旧安装」口径用长度补记录。
|
||||
std::filesystem::remove(directory / L"wanxiang-lts-zh-hans.gram");
|
||||
WriteTextFile(directory / L"wanxiang-lts-zh-hans.gram.new", "fresh");
|
||||
collocation::ApplyPendingUpdates();
|
||||
REQUIRE_EQ(std::filesystem::file_size(directory / L"wanxiang-lts-zh-hans.gram"), static_cast<std::uintmax_t>(5));
|
||||
REQUIRE(!std::filesystem::exists(directory / L".remote"));
|
||||
}
|
||||
|
||||
TEST_CASE(collocation_status_exposes_pending_update_without_reporting_available)
|
||||
{
|
||||
ScopedCollocationEnvironment env;
|
||||
const std::wstring id = L"wanxiang-lts-zh-hans";
|
||||
SeedModelFile(env.models_dir(), id);
|
||||
auto statuses = collocation::GetModelStatuses();
|
||||
REQUIRE_EQ(statuses["wanxiang-lts-zh-hans"].state, std::string("ready"));
|
||||
REQUIRE(!statuses["wanxiang-lts-zh-hans"].update_available);
|
||||
REQUIRE(!statuses["wanxiang-lts-zh-hans"].update_pending);
|
||||
|
||||
// .gram.new 存在即「已下载、等重启」,不再同时提示可更新。
|
||||
WriteTextFile(env.models_dir() / id / L"wanxiang-lts-zh-hans.gram.new", "pending");
|
||||
statuses = collocation::GetModelStatuses();
|
||||
REQUIRE_EQ(statuses["wanxiang-lts-zh-hans"].state, std::string("ready"));
|
||||
REQUIRE(!statuses["wanxiang-lts-zh-hans"].update_available);
|
||||
REQUIRE(statuses["wanxiang-lts-zh-hans"].update_pending);
|
||||
}
|
||||
|
||||
TEST_CASE(collocation_delete_removes_update_sidecars)
|
||||
{
|
||||
ScopedCollocationEnvironment env;
|
||||
const std::wstring id = L"wanxiang-lts-zh-hans";
|
||||
const std::filesystem::path directory = env.models_dir() / id;
|
||||
SeedModelFile(env.models_dir(), id);
|
||||
WriteTextFile(directory / L"wanxiang-lts-zh-hans.gram.new", "pending");
|
||||
REQUIRE(collocation::WriteRemoteIdentity(directory, {"\"etag\"", "time", 4}));
|
||||
WriteTextFile(directory / L".remote.new", "etag=\"next\"\n");
|
||||
|
||||
REQUIRE(collocation::DeleteModel(collocation::kRecommendedModelId));
|
||||
REQUIRE(!std::filesystem::exists(directory / L"wanxiang-lts-zh-hans.gram"));
|
||||
REQUIRE(!std::filesystem::exists(directory / L"wanxiang-lts-zh-hans.gram.new"));
|
||||
REQUIRE(!std::filesystem::exists(directory / L".remote"));
|
||||
REQUIRE(!std::filesystem::exists(directory / L".remote.new"));
|
||||
auto statuses = collocation::GetModelStatuses();
|
||||
REQUIRE_EQ(statuses["wanxiang-lts-zh-hans"].state, std::string("absent"));
|
||||
REQUIRE(!statuses["wanxiang-lts-zh-hans"].update_pending);
|
||||
}
|
||||
|
||||
TEST_CASE(collocation_apply_pending_updates_keeps_both_files_while_old_model_is_mapped)
|
||||
{
|
||||
ScopedCollocationEnvironment env;
|
||||
const std::wstring id = L"wanxiang-lts-zh-hans";
|
||||
const std::filesystem::path directory = env.models_dir() / id;
|
||||
const std::filesystem::path target = directory / L"wanxiang-lts-zh-hans.gram";
|
||||
const std::filesystem::path pending = directory / L"wanxiang-lts-zh-hans.gram.new";
|
||||
SeedModelFile(env.models_dir(), id); // 旧模型,4 字节
|
||||
WriteTextFile(pending, "new-gram-model"); // 新版本,14 字节
|
||||
|
||||
// 模拟输入法进程的映射:引擎只用 FILE_SHARE_READ 打开 .gram(没有 FILE_SHARE_DELETE),
|
||||
// 这正是「待重启」存在的理由。
|
||||
HANDLE mapped = CreateFileW(target.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
REQUIRE(mapped != INVALID_HANDLE_VALUE);
|
||||
collocation::ApplyPendingUpdates();
|
||||
// 先读结果再断言:断言抛异常时句柄必须已经关上,否则临时目录删不掉、后面的用例跟着挂。
|
||||
std::error_code size_error;
|
||||
const std::uintmax_t locked_bytes = std::filesystem::file_size(target, size_error);
|
||||
const bool locked_pending_exists = std::filesystem::exists(pending);
|
||||
CloseHandle(mapped);
|
||||
|
||||
// 落不了位不是错误:旧模型一个字节都没动,新文件留着等下次启动。不允许「先删旧的再改名」
|
||||
// 那样中间留下没有模型的窗口。
|
||||
REQUIRE(!size_error);
|
||||
REQUIRE_EQ(locked_bytes, static_cast<std::uintmax_t>(4));
|
||||
REQUIRE(locked_pending_exists);
|
||||
|
||||
// 映射释放后,下一次启动的 ApplyPendingUpdates 收敛到新版本。
|
||||
collocation::ApplyPendingUpdates();
|
||||
REQUIRE_EQ(std::filesystem::file_size(target), static_cast<std::uintmax_t>(14));
|
||||
REQUIRE(!std::filesystem::exists(pending));
|
||||
auto statuses = collocation::GetModelStatuses();
|
||||
REQUIRE_EQ(statuses["wanxiang-lts-zh-hans"].state, std::string("ready"));
|
||||
REQUIRE(!statuses["wanxiang-lts-zh-hans"].update_pending);
|
||||
}
|
||||
|
||||
TEST_CASE(collocation_apply_pending_updates_skips_logically_deleted_model)
|
||||
{
|
||||
ScopedCollocationEnvironment env;
|
||||
const std::wstring id = L"wanxiang-lts-zh-hans";
|
||||
const std::filesystem::path directory = env.models_dir() / id;
|
||||
SeedModelFile(env.models_dir(), id);
|
||||
WriteTextFile(directory / L"wanxiang-lts-zh-hans.gram.new", "new-gram-model");
|
||||
// .trash 在、旧 .gram 还没能物理删除(映射未释放):删除意愿优先,待更新不得抢先落位。
|
||||
WriteTextFile(directory / L".trash", "");
|
||||
|
||||
collocation::ApplyPendingUpdates();
|
||||
|
||||
REQUIRE_EQ(std::filesystem::file_size(directory / L"wanxiang-lts-zh-hans.gram"), static_cast<std::uintmax_t>(4));
|
||||
REQUIRE(std::filesystem::exists(directory / L"wanxiang-lts-zh-hans.gram.new"));
|
||||
}
|
||||
|
||||
TEST_CASE(collocation_import_does_not_fabricate_remote_identity)
|
||||
{
|
||||
ScopedCollocationEnvironment env;
|
||||
const std::wstring id = L"wanxiang-lts-zh-hans";
|
||||
const std::filesystem::path directory = env.models_dir() / id;
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(directory, ec);
|
||||
REQUIRE(!ec);
|
||||
// 目录里残留的旧身份(例如上次安装的记录)不能被当成导入文件的身份。
|
||||
REQUIRE(collocation::WriteRemoteIdentity(directory, {"\"stale\"", "time", 4}));
|
||||
|
||||
const std::filesystem::path source = env.models_dir().parent_path() / L"imported.gram";
|
||||
WriteMinimalGramFile(source);
|
||||
REQUIRE(collocation::ImportModel(collocation::kRecommendedModelId, source));
|
||||
std::string state;
|
||||
for (int attempt = 0; attempt < 200; ++attempt)
|
||||
{
|
||||
state = collocation::GetModelStatuses()["wanxiang-lts-zh-hans"].state;
|
||||
if (state != "importing")
|
||||
break;
|
||||
Sleep(25);
|
||||
}
|
||||
REQUIRE_EQ(state, std::string("ready"));
|
||||
// 导入没有响应头:落位后删掉旧记录,下一次探测按「旧安装」口径用长度补记录或提示更新。
|
||||
REQUIRE(!std::filesystem::exists(directory / L".remote"));
|
||||
}
|
||||
Reference in new issue
Block a user