feat(linux): reinstall an existing Service when upgrading the package

The deb postinst and rpm %post now reinstall the Service in place when an
upgrade finds its systemd unit, reusing the unit's group so package-manager
runs without a sudo/pkexec user keep the same IPC access. First installs and
disabled, masked, or group-less units are left alone; failures warn and exit
0 because neither dpkg nor rpm can restore the previous Service from a failed
postinst.

The in-app updater stops a Service-run core before installing on Linux and
restarts it if the install fails, so the post-update restart does not fail on
the session lost when the Service restarts.

Refs #8515

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Tunglies
2026-11-09 18:02:22 +08:00
co-authored by Copilot
parent c3531bbbf2
commit cdd704f980
5 changed files with 122 additions and 8 deletions
+4
View File
@@ -52,4 +52,8 @@
- 优化 macOS TUN 模式的系统 DNS,增加阿里和 Cloudflare 地址
**🐧 Linux**
- 优化 Linux 升级安装包后自动重装已安装的服务,无需在应用内手动重装
</details>
@@ -5,6 +5,44 @@ chmod +x /usr/bin/clash-verge-service-install
chmod +x /usr/bin/clash-verge-service-uninstall
chmod +x /usr/bin/clash-verge-service
# Failures only warn: dpkg and rpm cannot restore the previous Service from a failed postinst,
# and a failing script would leave the upgraded package unconfigured or report the update as failed.
reinstall_existing_service() {
local unit="/etc/systemd/system/clash-verge-service.service"
# Only a unit kept by an upgrade-aware removal script shows the Service existed before this upgrade.
if [ ! -f "$unit" ] || [ -L "$unit" ]; then
return 0
fi
if [ ! -d /run/systemd/system ]; then
echo "Warning: systemd is not running, so Clash Verge Service was not reinstalled; reinstall it from Clash Verge." >&2
return 0
fi
if ! systemctl is-enabled --quiet clash-verge-service.service; then
echo "Clash Verge Service is disabled; leaving it unchanged." >&2
return 0
fi
# Package managers run without the invoking user's identity, so keep the group that may use the Service.
local group gid
group="$(sed -n 's/^Group=//p' "$unit" | head -n 1)"
gid=""
if [ -n "$group" ]; then
gid="$(getent group "$group" | cut -d: -f3)"
fi
if [ -z "$gid" ]; then
echo "Warning: the Clash Verge Service group '$group' could not be resolved, so the Service was not reinstalled; reinstall it from Clash Verge." >&2
return 0
fi
if ! CLASH_VERGE_SERVICE_GID="$gid" /usr/bin/clash-verge-service-install --install-service; then
echo "Warning: reinstalling Clash Verge Service failed; reinstall it from Clash Verge." >&2
fi
}
# Upgrades keep an existing Service current; first installs never add one. No success is reported:
# a previous package whose %preun uninstalls unconditionally still removes the Service afterwards.
if [ "$1" -ge 2 ]; then
reinstall_existing_service
fi
# The enforcement file exists in both Enforcing and Permissive mode.
if [ ! -e /sys/fs/selinux/enforce ]; then
exit 0
+37
View File
@@ -3,6 +3,43 @@ chmod +x /usr/bin/clash-verge-service-install
chmod +x /usr/bin/clash-verge-service-uninstall
chmod +x /usr/bin/clash-verge-service
# Failures only warn: dpkg and rpm cannot restore the previous Service from a failed postinst,
# and a failing script would leave the upgraded package unconfigured or report the update as failed.
reinstall_existing_service() {
local unit="/etc/systemd/system/clash-verge-service.service"
# Only a unit kept by an upgrade-aware removal script shows the Service existed before this upgrade.
if [ ! -f "$unit" ] || [ -L "$unit" ]; then
return 0
fi
if [ ! -d /run/systemd/system ]; then
echo "Warning: systemd is not running, so Clash Verge Service was not reinstalled; reinstall it from Clash Verge." >&2
return 0
fi
if ! systemctl is-enabled --quiet clash-verge-service.service; then
echo "Clash Verge Service is disabled; leaving it unchanged." >&2
return 0
fi
# Package managers run without the invoking user's identity, so keep the group that may use the Service.
local group gid
group="$(sed -n 's/^Group=//p' "$unit" | head -n 1)"
gid=""
if [ -n "$group" ]; then
gid="$(getent group "$group" | cut -d: -f3)"
fi
if [ -z "$gid" ]; then
echo "Warning: the Clash Verge Service group '$group' could not be resolved, so the Service was not reinstalled; reinstall it from Clash Verge." >&2
return 0
fi
if ! CLASH_VERGE_SERVICE_GID="$gid" /usr/bin/clash-verge-service-install --install-service; then
echo "Warning: reinstalling Clash Verge Service failed; reinstall it from Clash Verge." >&2
fi
}
# Upgrades keep an existing Service current; first installs never add one.
if [ "$1" = "configure" ] && [ -n "$2" ]; then
reinstall_existing_service
fi
. /etc/os-release
if [ "$ID" = "deepin" ]; then
+11
View File
@@ -750,6 +750,17 @@ impl CoreManager {
self.controlled_stop_core_inner().await
}
/// Stops the core only when the Service runs it, returning whether it did. Waits for an in-flight start.
#[cfg(target_os = "linux")]
pub(crate) async fn stop_service_core(&self) -> Result<bool> {
let _life = self.lifecycle_lock.lock().await;
if !matches!(*self.get_running_mode(), RunningMode::Service) {
return Ok(false);
}
self.controlled_stop_core_inner().await?;
Ok(true)
}
pub(crate) async fn controlled_stop_core_inner(&self) -> Result<()> {
self.controlled_stop_core_with_intent(ProxyStopIntent::Clear).await
}
+32 -8
View File
@@ -292,8 +292,36 @@ impl SilentUpdater {
Ok(updater_builder.build()?.check().await?)
}
async fn install(update: Update, bytes: Vec<u8>) -> Result<()> {
Ok(tokio::task::spawn_blocking(move || update.install(&bytes)).await??)
async fn install(update: Update, bytes: Vec<u8>, timeout: Option<Duration>) -> Result<()> {
// The package scripts reinstall an existing Service, killing the core it runs and its session;
// stopping it first keeps the post-update restart from failing on that lost session.
#[cfg(target_os = "linux")]
let stopped_service_core = crate::core::CoreManager::global()
.stop_service_core()
.await
.context("failed to stop the Service core before installing the update")?;
let install = tokio::task::spawn_blocking(move || update.install(&bytes));
let joined = match timeout {
Some(limit) => tokio::time::timeout(limit, install)
.await
.map_err(|_| anyhow::anyhow!("install timed out ({}s)", limit.as_secs())),
None => Ok(install.await),
};
let result = joined.and_then(|joined| Ok(joined??));
#[cfg(target_os = "linux")]
if result.is_err()
&& stopped_service_core
&& let Err(error) = crate::core::CoreManager::global().start_core().await
{
logging!(
error,
Type::System,
"Failed to restart the core after the update install failed: {error:#}"
);
}
result
}
/// Installs a newer cached update before normal startup, if the user confirms.
@@ -369,11 +397,7 @@ impl SilentUpdater {
Self::show_update_splash(app_handle, &version);
// `install()` may hang (#2558); on Windows NSIS can take over without returning.
let install = tokio::time::timeout(std::time::Duration::from_secs(30), Self::install(update, bytes));
match install
.await
.unwrap_or_else(|_| Err(anyhow::anyhow!("install timed out (30s)")))
{
match Self::install(update, bytes, Some(Duration::from_secs(30))).await {
Ok(()) => {
logging!(info, Type::System, "Update v{version} install triggered at startup");
Self::delete_cache();
@@ -417,7 +441,7 @@ impl SilentUpdater {
};
logging!(info, Type::System, "Installing update v{version}...");
Self::install(update, bytes).await?;
Self::install(update, bytes, None).await?;
Self::delete_cache();
Ok(InstallUpdateOutcome::Installed)
}