feat: unified restart notification with reason-specific messaging

Replace statusInfo.updated (bool) with serverInfo.restart (nullable enum)
to unify all restart-needed scenarios under a single PatchDB field.

Backend sets the restart reason in RPC handlers for hostname change (mdns),
language change, kiosk toggle, and OS update download. Init clears it on
boot. The update flow checks this field to prevent updates when a restart
is already pending.

Frontend shows a persistent action bar with reason-specific i18n messages
instead of per-feature restart dialogs. For .local hostname changes, the
existing "open new address" dialog is preserved — the restart toast
appears after the user logs in on the new address.

Also includes migration in v0_4_0_alpha_23 to remove statusInfo.updated
and initialize serverInfo.restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Hill
2026-03-28 22:35:34 -06:00
parent d6b81f3c9b
commit 591e3bec1a
21 changed files with 160 additions and 166 deletions

View File

@@ -125,7 +125,6 @@ impl Public {
},
status_info: ServerStatus {
backup_progress: None,
updated: false,
update_progress: None,
shutting_down: false,
restarting: false,
@@ -152,6 +151,7 @@ impl Public {
kiosk: Some(kiosk).filter(|_| &*PLATFORM != "raspberrypi"),
language,
keyboard,
restart: None,
},
package_data: AllPackageData::default(),
ui: serde_json::from_str(*DB_UI_SEED_CELL.get().unwrap_or(&"null"))
@@ -218,6 +218,18 @@ pub struct ServerInfo {
pub kiosk: Option<bool>,
pub language: Option<InternedString>,
pub keyboard: Option<KeyboardOptions>,
#[serde(default)]
pub restart: Option<RestartReason>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum RestartReason {
Mdns,
Language,
Kiosk,
Update,
}
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
@@ -364,7 +376,6 @@ pub struct BackupProgress {
#[ts(export)]
pub struct ServerStatus {
pub backup_progress: Option<BTreeMap<PackageId, BackupProgress>>,
pub updated: bool,
pub update_progress: Option<FullProgress>,
#[serde(default)]
pub shutting_down: bool,

View File

@@ -7,7 +7,7 @@ use tracing::instrument;
use ts_rs::TS;
use crate::context::RpcContext;
use crate::db::model::public::ServerInfo;
use crate::db::model::public::{RestartReason, ServerInfo};
use crate::prelude::*;
use crate::util::Invoke;
@@ -272,6 +272,7 @@ pub async fn set_hostname_rpc(
}
if let Some(hostname) = &hostname {
hostname.save(server_info)?;
server_info.as_restart_mut().ser(&Some(RestartReason::Mdns))?;
}
ServerHostnameInfo::load(server_info)
})

View File

@@ -16,7 +16,7 @@ use crate::account::AccountInfo;
use crate::context::config::ServerConfig;
use crate::context::{CliContext, InitContext, RpcContext};
use crate::db::model::Database;
use crate::db::model::public::ServerStatus;
use crate::db::model::public::{RestartReason, ServerStatus};
use crate::developer::OS_DEVELOPER_KEY_PATH;
use crate::hostname::ServerHostname;
use crate::middleware::auth::local::LocalAuthContext;
@@ -371,7 +371,6 @@ pub async fn init(
let ram = get_mem_info().await?.total.0 as u64 * 1024 * 1024;
let devices = lshw().await?;
let status_info = ServerStatus {
updated: false,
update_progress: None,
backup_progress: None,
shutting_down: false,
@@ -379,6 +378,7 @@ pub async fn init(
};
db.mutate(|v| {
let server_info = v.as_public_mut().as_server_info_mut();
server_info.as_restart_mut().ser(&None::<RestartReason>)?;
server_info.as_ntp_synced_mut().ser(&ntp_synced)?;
server_info.as_ram_mut().ser(&ram)?;
server_info.as_devices_mut().ser(&devices)?;

View File

@@ -16,6 +16,7 @@ use ts_rs::TS;
use crate::bins::set_locale;
use crate::context::{CliContext, RpcContext};
use crate::db::model::public::RestartReason;
use crate::disk::util::{get_available, get_used};
use crate::logs::{LogSource, LogsParams, SYSTEM_UNIT};
use crate::prelude::*;
@@ -351,10 +352,9 @@ pub fn kiosk<C: Context>() -> ParentHandler<C> {
from_fn_async(|ctx: RpcContext| async move {
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_server_info_mut()
.as_kiosk_mut()
.ser(&Some(true))
let server_info = db.as_public_mut().as_server_info_mut();
server_info.as_kiosk_mut().ser(&Some(true))?;
server_info.as_restart_mut().ser(&Some(RestartReason::Kiosk))
})
.await
.result?;
@@ -369,10 +369,9 @@ pub fn kiosk<C: Context>() -> ParentHandler<C> {
from_fn_async(|ctx: RpcContext| async move {
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_server_info_mut()
.as_kiosk_mut()
.ser(&Some(false))
let server_info = db.as_public_mut().as_server_info_mut();
server_info.as_kiosk_mut().ser(&Some(false))?;
server_info.as_restart_mut().ser(&Some(RestartReason::Kiosk))
})
.await
.result?;
@@ -1367,10 +1366,11 @@ pub async fn set_language(
save_language(&*language).await?;
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_server_info_mut()
let server_info = db.as_public_mut().as_server_info_mut();
server_info
.as_language_mut()
.ser(&Some(language.clone()))
.ser(&Some(language.clone()))?;
server_info.as_restart_mut().ser(&Some(RestartReason::Language))
})
.await
.result?;

View File

@@ -19,6 +19,7 @@ use ts_rs::TS;
use crate::PLATFORM;
use crate::context::{CliContext, RpcContext};
use crate::db::model::public::RestartReason;
use crate::notifications::{NotificationLevel, notify};
use crate::prelude::*;
use crate::progress::{
@@ -80,9 +81,9 @@ pub async fn update_system(
.await
.into_public()
.into_server_info()
.into_status_info()
.into_updated()
.into_restart()
.de()?
.is_some()
{
return Err(Error::new(
eyre!("{}", t!("update.already-updated-restart-required")),
@@ -281,10 +282,18 @@ async fn maybe_do_update(
let start_progress = progress.snapshot();
let status = ctx
.db
ctx.db
.mutate(|db| {
let mut status = peeked.as_public().as_server_info().as_status_info().de()?;
let server_info = db.as_public_mut().as_server_info_mut();
if server_info.as_restart().de()?.is_some() {
return Err(Error::new(
eyre!("{}", t!("update.already-updated-restart-required")),
crate::ErrorKind::InvalidRequest,
));
}
let mut status = server_info.as_status_info().de()?;
if status.update_progress.is_some() {
return Err(Error::new(
eyre!("{}", t!("update.already-updating")),
@@ -293,22 +302,12 @@ async fn maybe_do_update(
}
status.update_progress = Some(start_progress);
db.as_public_mut()
.as_server_info_mut()
.as_status_info_mut()
.ser(&status)?;
Ok(status)
server_info.as_status_info_mut().ser(&status)?;
Ok(())
})
.await
.result?;
if status.updated {
return Err(Error::new(
eyre!("{}", t!("update.already-updated-restart-required")),
crate::ErrorKind::InvalidRequest,
));
}
let progress_task = NonDetachingJoinHandle::from(tokio::spawn(progress.clone().sync_to_db(
ctx.db.clone(),
|db| {
@@ -338,10 +337,12 @@ async fn maybe_do_update(
Ok(()) => {
ctx.db
.mutate(|db| {
let status_info =
db.as_public_mut().as_server_info_mut().as_status_info_mut();
status_info.as_update_progress_mut().ser(&None)?;
status_info.as_updated_mut().ser(&true)
let server_info = db.as_public_mut().as_server_info_mut();
server_info
.as_status_info_mut()
.as_update_progress_mut()
.ser(&None)?;
server_info.as_restart_mut().ser(&Some(RestartReason::Update))
})
.await
.result?;

View File

@@ -28,7 +28,13 @@ impl VersionT for Version {
&V0_3_0_COMPAT
}
#[instrument(skip_all)]
fn up(self, _db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
db["public"]["serverInfo"]["statusInfo"]
.as_object_mut()
.map(|m| m.remove("updated"));
db["public"]["serverInfo"]["restart"] = Value::Null;
Ok(Value::Null)
}
fn down(self, _db: &mut Value) -> Result<(), Error> {