[feat] add in-app updates for desktop installations

This commit is contained in:
dijunkun
2026-10-20 20:11:12 +08:00
parent 62ba3693af
commit a625890b28
34 changed files with 1732 additions and 25 deletions
@@ -86,6 +86,9 @@
#include "ui/release_notes_text.h"
#include "ui/ui_localization.h"
#include "update_checker.h"
#if CROSSDESK_HAS_UPDATER
#include "updater/update_controller.h"
#endif
#include "window_geometry.h"
namespace crossdesk {
@@ -1134,6 +1137,9 @@ int GuiApplication::Run() {
#endif
InitializeModules();
update_checker_ = std::make_unique<UpdateChecker>(CROSSDESK_VERSION);
#if CROSSDESK_HAS_UPDATER
updater_ = std::make_unique<updater::Controller>(path_manager_->GetCachePath());
#endif
InitializeUi();
#ifndef __OHOS__
ui_->activation_receiver = std::move(activation_receiver);
@@ -1735,8 +1741,24 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
? UiText(std::to_string(signal_port))
: slint::SharedString{});
});
main->on_cancel_update([this] {
#if CROSSDESK_HAS_UPDATER
if (updater_ && updater_->busy()) updater_->Cancel();
#endif
});
main->on_open_download([this] {
const auto& latest = update_checker_->latest();
#if CROSSDESK_HAS_UPDATER
if (updater_ && updater_->busy()) return;
if (updater_ && latest && updater_->Available(latest->update_envelope)) {
const int language = localization::detail::ClampLanguageIndex(localization_language_index_);
if (tinyfd_messageBox("CrossDesk", localization::install_update_confirm[language].c_str(),
"okcancel", "question", 0)) {
updater_->Start(latest->update_envelope);
}
return;
}
#endif
OpenUrl(latest && !latest->download_url.empty() ? latest->download_url
: "https://crossdesk.cn");
});
@@ -2326,6 +2348,36 @@ void GuiApplication::Tick() {
}
void GuiApplication::PollUpdateCheck() {
#if CROSSDESK_HAS_UPDATER
if (updater_) {
// The event loop is running and initialization has completed.
static bool startup_confirmed = false;
if (!startup_confirmed) {
updater_->ConfirmStartup();
startup_confirmed = true;
}
if (updater_->Poll()) {
exit_ = true;
return;
}
const auto& latest = update_checker_->latest();
const bool can_install = latest && updater_->Available(latest->update_envelope);
const int language = localization::detail::ClampLanguageIndex(localization_language_index_);
std::string status;
if (updater_->busy()) {
status = localization::update_preparing[language];
if (updater_->state() == updater::Controller::State::Downloading)
status += " " + std::to_string(updater_->progress()) + "%";
} else if (updater_->state() == updater::Controller::State::Failed) {
status = localization::update_install_failed[language] + "\n" + updater_->error();
}
for (const auto& main : {ui_->main, ui_->wall}) {
main->set_update_install_available(can_install);
main->set_update_install_busy(updater_->busy());
main->set_update_install_status(UiText(status));
}
}
#endif
// The first tick runs after show()/event-loop startup. Poll never waits for
// networking and all application/model changes remain on this UI thread.
if (!update_checker_->Poll() ||
@@ -6163,6 +6215,9 @@ void GuiApplication::Cleanup() {
if (update_checker_) {
update_checker_->Stop();
}
#if CROSSDESK_HAS_UPDATER
updater_.reset();
#endif
SDL_Quit();
}
@@ -22,6 +22,7 @@
namespace crossdesk {
class UpdateChecker;
namespace updater { class Controller; }
// Slint application shell. GuiRuntime remains the owner of transport, media,
// device, clipboard, transfer and settings behavior.
@@ -126,6 +127,9 @@ private:
std::unique_ptr<SlintUi> ui_;
std::unique_ptr<UpdateChecker> update_checker_;
#if CROSSDESK_HAS_UPDATER
std::unique_ptr<updater::Controller> updater_;
#endif
CursorStateProvider cursor_state_provider_;
std::unordered_map<std::string, CursorDeliveryState>
cursor_delivery_states_;
@@ -317,6 +317,13 @@ struct TranslationRow {
X(access_website, u8"访问官网: ", \
"Access Website: ", u8"Официальный сайт: ") \
X(update, u8"更新", "Update", u8"Обновить") \
X(install_update, u8"安装更新", "Install update", u8"Установить обновление") \
X(manual_download, u8"手动下载", "Download", u8"Скачать") \
X(install_update_confirm, u8"下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?", \
"CrossDesk will close after downloading the update. Connections and file transfers will be interrupted. Installation requires system authorization, then CrossDesk will restart. Continue?", \
u8"После загрузки CrossDesk закроется для обновления. Соединения и передача файлов будут прерваны. Потребуется разрешение системы, затем приложение перезапустится. Продолжить?") \
X(update_preparing, u8"正在准备更新…", "Preparing update…", u8"Подготовка обновления…") \
X(update_install_failed, u8"更新未完成,可重试或手动安装。", "Update did not complete. Retry or install manually.", u8"Обновление не завершено. Повторите или установите вручную.") \
X(connection_alias, u8"修改名称", "Edit Alias", \
u8"Изменить имя подключения") \
X(delete_connection, u8"删除连接", "Delete Connection", \
+49 -15
View File
@@ -99,6 +99,7 @@ export global UiStrings {
in-out property <string> view-release-notes: "Release notes";
in-out property <string> later: "Later";
in-out property <string> download: "Download";
in-out property <string> install-update: "Install update";
in-out property <string> input-password: "Input password";
in-out property <string> reinput-password: "Incorrect password, please try again";
in-out property <string> remember-password: "Remember password";
@@ -408,6 +409,10 @@ export component MainWindow inherits Window {
callback read-all-announcements;
callback load-more-announcements;
in property <bool> update-available: false;
in property <bool> update-install-available: false;
in property <bool> update-install-busy: false;
in property <string> update-install-status: "";
private property <string> update-action-text: root.update-install-available ? UiStrings.install-update : UiStrings.download;
in property <UpdateCheckStatus> update-check-status: idle;
private property <string> update-status-text:
root.update-check-status == UpdateCheckStatus.checking ? UiStrings.checking-for-updates :
@@ -460,7 +465,7 @@ export component MainWindow inherits Window {
root.autostart-status == AutostartStatus.unsupported ? GalleryStrings.tr("设备不支持", "Unsupported", "Не поддерживается") :
GalleryStrings.tr("读取失败", "Read failed", "Ошибка чтения");
in-out property <bool> autostart-guide-open: false;
changed autostart-guide-open => { if root.autostart-guide-open { focus-sink.focus(); } }
changed autostart-guide-open => { if root.autostart-guide-open { root.dialog-focus-pending = true; } }
in-out property <bool> daemon-enabled: false;
in-out property <bool> save-remote-previews: true;
// Show the preference on every platform; only adapted hosts can change it.
@@ -492,6 +497,7 @@ export component MainWindow inherits Window {
callback cancel-self-hosted-settings;
callback browse-save-path;
callback open-download;
callback cancel-update;
callback open-website;
callback open-afdian-sponsor;
callback open-github-sponsor;
@@ -558,6 +564,23 @@ export component MainWindow inherits Window {
private property <bool> recent-tooltip-online: false;
private property <length> recent-tooltip-pointer-x: 0px;
private property <length> recent-tooltip-pointer-y: 0px;
private property <bool> dialog-focus-pending: false;
// A changed handler can run lazily while winit's accessibility adapter
// borrows its tree. Switching focus there reenters that adapter and panics.
// Coalesce requests and move focus on the next event-loop turn instead.
Timer {
interval: 1ms;
running: root.dialog-focus-pending;
triggered => {
root.dialog-focus-pending = false;
if root.settings-open || root.autostart-guide-open
|| root.wayland-notice-open || root.messages-open || root.about-open
|| (root.sponsorship-open && root.sponsorship-unobstructed) {
focus-sink.focus();
}
}
}
changed menu-open => {
if root.menu-open { root.copy-feedback = CopyFeedback.idle; root.gallery-action("release", ""); }
@@ -575,26 +598,26 @@ export component MainWindow inherits Window {
root.gallery-bandwidth-draft = root.gallery-bandwidth-index;
root.gallery-action("settings-open", "");
if root.autostart-system-managed { root.refresh-autostart-status(); }
focus-sink.focus();
root.dialog-focus-pending = true;
} else {
root.autostart-guide-open = false;
}
}
changed wayland-notice-open => {
if root.wayland-notice-open { focus-sink.focus(); }
if root.wayland-notice-open { root.dialog-focus-pending = true; }
}
changed messages-open => {
if root.messages-open { focus-sink.focus(); }
if root.messages-open { root.dialog-focus-pending = true; }
}
changed about-open => {
if root.about-open { focus-sink.focus(); }
if root.about-open { root.dialog-focus-pending = true; }
}
changed sponsorship-open => {
if root.sponsorship-open { focus-sink.focus(); }
if root.sponsorship-open { root.dialog-focus-pending = true; }
}
reset-remote-id => {
@@ -2452,9 +2475,10 @@ export component MainWindow inherits Window {
height: 32px;
primary: true;
text-size: 12px;
text: UiStrings.download;
clicked => { root.open-website(); }
about-update-button-metrics := Text { visible: false; text: UiStrings.download; font-size: 12px; }
text: root.update-action-text;
enabled: !root.update-install-busy;
clicked => { root.open-download(); root.about-open = false; root.update-open = true; }
about-update-button-metrics := Text { visible: false; text: root.update-action-text; font-size: 12px; }
}
SettingsButton {
width: max(116px, check-button-metrics.preferred-width + 24px);
@@ -2514,14 +2538,14 @@ export component MainWindow inherits Window {
width: update-website-label.preferred-width;
accessible-role: button;
accessible-label: update-website-label.text;
accessible-action-default => { root.open-download(); }
accessible-action-default => { root.open-website(); }
update-website-label := Text {
text: UiStrings.access-website + "https://crossdesk.cn";
color: website-touch.has-hover ? #254cc0 : WorkbenchStyle.accent;
font-size: 13px;
vertical-alignment: center;
}
website-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-download(); } }
website-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-website(); } }
}
}
}
@@ -2588,6 +2612,14 @@ export component MainWindow inherits Window {
vertical-stretch: 0;
spacing: 16px;
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
if root.update-install-status != "": Text {
text: root.update-install-status;
font-size: 12px;
color: WorkbenchStyle.muted;
wrap: word-wrap;
max-height: 64px;
overflow: elide;
}
HorizontalLayout {
alignment: end;
spacing: 8px;
@@ -2595,14 +2627,16 @@ export component MainWindow inherits Window {
width: 88px;
height: 34px;
text: UiStrings.later;
clicked => { root.update-open = false; }
clicked => { root.cancel-update(); root.update-open = false; }
}
WorkbenchButton {
width: 88px;
width: max(110px, update-action-metrics.preferred-width + 24px);
height: 34px;
primary: true;
text: UiStrings.download;
clicked => { root.open-download(); root.update-open = false; }
enabled: !root.update-install-busy;
text: root.update-action-text;
clicked => { root.open-download(); }
update-action-metrics := Text { visible: false; text: root.update-action-text; font-size: 12px; }
}
}
}
+2 -1
View File
@@ -131,7 +131,8 @@ inline int ApplyMainWindowStrings(
Text(localization::update_check_failed[language]));
strings.set_release_notes(Text(localization::release_notes[language]));
strings.set_view_release_notes(Text(localization::view_release_notes[language]));
strings.set_download(Text(localization::update[language]));
strings.set_download(Text(localization::manual_download[language]));
strings.set_install_update(Text(localization::install_update[language]));
strings.set_input_password(Text(localization::input_password[language]));
strings.set_reinput_password(Text(localization::reinput_password[language]));
strings.set_remember_password(