Merge pull request #9982 from masa-masa-0314/feat/realms-backups

🤖 Agent: Realms backup list and backup info screens on main's Realms code (only the backups commit kept; the carried settings/invite copies already landed); restore asks the service and re-reads the list; realms tests pass.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-07 09:13:43 +01:00
commit 92b2dbfbc7
2 files changed
+370 -2

No files matched your search

+299 -2
View File
@@ -12,7 +12,7 @@ pub mod client;
use crate::hud::{HudAtlas, HudBatch, rgba};
use crate::text::Text;
use client::{
InviteCode, PendingInviteDto, PlayerInfo, RealmAddress, RealmServer, RealmSlot,
Backup, InviteCode, PendingInviteDto, PlayerInfo, RealmAddress, RealmServer, RealmSlot,
RealmTierConfiguration, RealmTierRange, RealmWorldOptions, RealmsClient, RealmsError,
RegionDataDto, RegionSelectionPreferenceDto, Subscription,
};
@@ -540,6 +540,22 @@ pub enum SubScreen {
job: Option<Job<Result<(), RealmsError>>>,
error: Option<String>,
},
/// `RealmsBackupScreen`: the Realm's automatic backups.
Backups {
server: RealmServer,
slot_id: i32,
backups: Option<Result<Vec<Backup>, RealmsError>>,
list_job: Option<Job<Result<Vec<Backup>, RealmsError>>>,
/// `RealmsClient.restoreWorld` on a worker.
restore_job: Option<Job<Result<(), RealmsError>>>,
error: Option<String>,
},
/// `RealmsBackupInfoScreen`: one backup's metadata.
BackupInfo {
server: RealmServer,
slot_id: i32,
backup: Backup,
},
/// `RealmsInviteScreen`: a name to invite to the Realm.
Invite {
server: RealmServer,
@@ -724,6 +740,7 @@ impl Screen {
let mut open_region_screen = false;
let mut saved_code = false;
let mut refetch_codes = false;
let mut refetch_backups = false;
match &mut self.subscreen {
SubScreen::JoinCode { error, job, .. } => {
if let Some(result) = job.as_ref().and_then(take) {
@@ -906,6 +923,26 @@ impl Screen {
}
}
}
SubScreen::Backups {
backups,
list_job,
restore_job,
error,
..
} => {
if let Some(result) = list_job.as_ref().and_then(take) {
*list_job = None;
*backups = Some(result);
}
if let Some(result) = restore_job.as_ref().and_then(take) {
*restore_job = None;
match result {
// The restore is done: the list reads again.
Ok(()) => refetch_backups = true,
Err(e) => *error = Some(e.text()),
}
}
}
SubScreen::InviteCodes {
codes,
list_job,
@@ -942,6 +979,9 @@ impl Screen {
if refetch_codes {
self.start_invite_codes();
}
if refetch_backups {
self.start_backups();
}
if saved_code {
self.back_to_codes();
}
@@ -1441,6 +1481,53 @@ impl Screen {
self.start_invite_codes();
}
/// `RealmsBackupScreen.refresh`: the backup list reads again.
fn start_backups(&mut self) {
let SubScreen::Backups {
server, list_job, ..
} = &mut self.subscreen
else {
return;
};
if list_job.is_some() {
return;
}
if let Some(client) = client() {
let id = server.id;
*list_job = Some(spawn(move || client.backups_for(id)));
}
}
/// Back to the worlds tab from the backup screens.
fn back_to_worlds_tab(&mut self) {
let server = match &self.subscreen {
SubScreen::Backups { server, .. } | SubScreen::BackupInfo { server, .. } => {
server.clone()
}
_ => return,
};
self.subscreen = SubScreen::Configure {
server,
tab: ConfigureTab::Worlds,
subscription: None,
subscription_job: None,
detail: None,
detail_job: None,
deleting: None,
member_job: None,
settings: None,
tier: None,
tier_job: None,
regions: None,
regions_job: None,
saving: None,
error: None,
job: None,
};
self.start_configure_subscription();
self.start_configure_detail();
}
/// Back to the players tab from the codes screens.
fn back_to_players_tab(&mut self) {
let server = match &self.subscreen {
@@ -1649,6 +1736,70 @@ impl Screen {
}
Outcome::None
}
// RealmsWorldsTab's Backup button: the backup list.
Some(93) => {
if let SubScreen::Configure {
server,
detail: Some(Ok(detail)),
..
} = &self.subscreen
&& let Some(client) = client()
{
let (server, slot_id, id) = (server.clone(), detail.active_slot, server.id);
self.subscreen = SubScreen::Backups {
server,
slot_id,
backups: None,
list_job: Some(spawn(move || client.backups_for(id))),
restore_job: None,
error: None,
};
}
Outcome::None
}
// RealmsBackupScreen: a row's Restore (170+row) and Back (190).
Some(btn @ 170..=189) => {
if let SubScreen::Backups {
server,
backups,
restore_job,
..
} = &mut self.subscreen
{
let row = usize::from(btn - 170);
if restore_job.is_none()
&& let Some(Ok(list)) = backups
&& let Some(backup) = list.get(row)
&& let Some(client) = client()
{
let (id, backup_id) = (server.id, backup.backup_id.clone());
*restore_job = Some(spawn(move || client.restore_world(id, &backup_id)));
}
}
Outcome::None
}
Some(190) => {
self.back_to_worlds_tab();
Outcome::None
}
Some(191) => {
if let SubScreen::BackupInfo {
server, slot_id, ..
} = &self.subscreen
{
let server = server.clone();
self.subscreen = SubScreen::Backups {
server,
slot_id: *slot_id,
backups: None,
list_job: None,
restore_job: None,
error: None,
};
self.start_backups();
}
Outcome::None
}
// RealmsSlotOptionsScreen: the active slot's options.
Some(94) => {
if let SubScreen::Configure {
@@ -2062,6 +2213,33 @@ impl Screen {
}
return Outcome::None;
}
// RealmsBackupScreen: a row (not its Restore button) opens the backup's
// metadata.
if let SubScreen::Backups {
server,
slot_id,
backups: Some(Ok(list)),
..
} = &self.subscreen
{
let rx = ((w - ROW_WIDTH) / 2.0).floor();
let row_y = |i: usize| 36.0 + 22.0 + 2.0 + i as f32 * ROW_HEIGHT;
if (rx..rx + ROW_WIDTH).contains(&x) {
for (i, backup) in list.iter().enumerate() {
let y0 = row_y(i);
if (y0..y0 + ROW_HEIGHT).contains(&y) {
let (server, slot_id, backup) =
(server.clone(), *slot_id, backup.clone());
self.subscreen = SubScreen::BackupInfo {
server,
slot_id,
backup,
};
return Outcome::None;
}
}
}
}
// RealmsSettingsTab's two fields: the click moves the caret between them.
// The two distance sliders: a click on the track sets the value, as the
// spawn-protection slider does (no drag event on this screen yet).
@@ -2506,7 +2684,7 @@ impl Screen {
&& !slot.options.empty
{
buttons.push((
cx - 76.0,
cx - 154.0,
h - 96.0,
152.0,
t("mco.configure.world.buttons.options"),
@@ -2514,6 +2692,15 @@ impl Screen {
94,
None,
));
buttons.push((
cx + 2.0,
h - 96.0,
152.0,
t("mco.configure.world.backup"),
!server.expired,
93,
None,
));
}
// RealmsPlayersTab: a row's Op/Deop/Remove, then Invite Player.
if *tab == ConfigureTab::Players
@@ -2639,6 +2826,34 @@ impl Screen {
));
buttons.push((cx + 2.0, h - 36.0, 100.0, t("gui.cancel"), true, 99, None));
}
SubScreen::Backups {
backups,
restore_job,
..
} => {
if let Some(Ok(list)) = backups {
let rx = ((w - ROW_WIDTH) / 2.0).floor();
for (i, _) in list.iter().enumerate().take(40) {
let row_y = 36.0 + 22.0 + 2.0 + i as f32 * ROW_HEIGHT;
if row_y + ROW_HEIGHT > h - 48.0 {
break;
}
buttons.push((
rx + ROW_WIDTH - 88.0,
row_y + 8.0,
84.0,
t("mco.backup.button.restore"),
restore_job.is_none(),
170 + i as u8,
None,
));
}
}
buttons.push((cx - 100.0, h - 36.0, 200.0, t("gui.back"), true, 190, None));
}
SubScreen::BackupInfo { .. } => {
buttons.push((cx - 100.0, h - 36.0, 200.0, t("gui.back"), true, 191, None));
}
SubScreen::InviteCodes {
codes,
list_job,
@@ -3486,6 +3701,88 @@ impl Screen {
}
}
}
SubScreen::Backups { backups, error, .. } => {
// `RealmsBackupScreen`: a row per backup with its date.
centered(
batch,
atlas,
&t("mco.configure.world.backup"),
cx,
14.0,
u32::MAX,
);
match backups {
Some(Ok(list)) if list.is_empty() => {
centered(batch, atlas, &t("mco.backup.nobackups"), cx, 80.0, GREY);
}
Some(Ok(list)) => {
let rx = ((w - ROW_WIDTH) / 2.0).floor();
for (i, backup) in list.iter().enumerate() {
let row_y = 36.0 + 22.0 + 2.0 + i as f32 * ROW_HEIGHT;
if row_y + ROW_HEIGHT > h - 48.0 {
break;
}
let label = crate::lang::tr(
"mco.backup.entry",
&[&start_date_text(backup.last_modified)
as &dyn std::fmt::Display],
);
batch.text(atlas, &label, rx + 4.0, row_y + 10.0, u32::MAX, true);
}
}
Some(Err(e)) => {
for (i, line) in wrap_lines(&e.text(), atlas, w - 40.0).iter().enumerate() {
centered(batch, atlas, line, cx, 60.0 + i as f32 * 10.0, RED);
}
}
None => centered(
batch,
atlas,
&t("mco.configure.world.loading"),
cx,
60.0,
u32::MAX,
),
}
if let Some(e) = error {
for (i, line) in wrap_lines(e, atlas, w - 40.0).iter().enumerate() {
centered(batch, atlas, line, cx, h - 56.0 + i as f32 * 10.0, RED);
}
}
}
SubScreen::BackupInfo { backup, .. } => {
// `RealmsBackupInfoScreen`: the metadata's key/value rows.
centered(
batch,
atlas,
&t("mco.backup.info.title"),
cx,
14.0,
u32::MAX,
);
let left = cx - 150.0;
let mut y = 40.0;
for (key, value) in &backup.metadata {
if y > h - 50.0 {
break;
}
let value = match value {
serde_json::Value::String(text) => text.clone(),
other => other.to_string(),
};
let label = {
let candidate = format!("mco.backup.entry.{key}");
if crate::lang::has(&candidate) {
crate::lang::get(&candidate)
} else {
key.clone()
}
};
batch.text(atlas, &label, left, y, GREY, true);
batch.text(atlas, &value, left + 110.0, y, u32::MAX, true);
y += 12.0;
}
}
SubScreen::InviteCodes { codes, error, .. } => {
// `RealmsInviteCodesScreen`: the count in the title, the subtitle, a row
// per code with its expiration, then Create and Back.
@@ -386,6 +386,29 @@ struct InviteCodeListResponse {
invite_codes: Vec<InviteCode>,
}
/// `Backup`: one of the Realm's automatic backups.
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Backup {
#[serde(default, deserialize_with = "nullable")]
pub backup_id: String,
/// `Backup.lastModified`: epoch milliseconds.
#[serde(default, deserialize_with = "nullable")]
pub last_modified: i64,
#[serde(default, deserialize_with = "nullable")]
pub size: i64,
#[serde(default, deserialize_with = "nullable")]
pub metadata: std::collections::HashMap<String, serde_json::Value>,
#[serde(default, deserialize_with = "nullable")]
pub change_list: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Clone, Debug, Deserialize, Default)]
struct BackupListResponse {
#[serde(default, deserialize_with = "nullable")]
backups: Vec<Backup>,
}
/// `RealmsWorldOptions`: a slot's options. Fields the JSON lacks keep the values of its
/// constructor (`Default`); `empty` is never in the JSON (`RealmsSlot.defaults` sets it).
#[derive(Clone, Debug, Deserialize, PartialEq)]
@@ -996,6 +1019,28 @@ impl RealmsClient {
)
.map(drop)
}
/// `RealmsClient.backupsFor`: `GET worlds/{id}/backups`.
pub fn backups_for(&self, realm_id: i64) -> Result<Vec<Backup>, RealmsError> {
let url = self.url(&format!("worlds/{realm_id}/backups"));
let json = Self::execute(self.request("GET", &url, READ_TIMEOUT), None)?;
serde_json::from_str::<BackupListResponse>(&json)
.map(|r| r.backups)
.map_err(|_| RealmsError::Parse)
}
/// `RealmsClient.restoreWorld`: `PUT worlds/{id}/backups?backupId={backupId}`, with
/// vanilla's long timeouts (`Request.put(url, "", 40000, 600000)`).
pub fn restore_world(&self, realm_id: i64, backup_id: &str) -> Result<(), RealmsError> {
let url = self.url(&format!("worlds/{realm_id}/backups"));
let query = format!("backupId={}", urlencoded(backup_id)?);
let full = format!("{url}?{query}");
Self::execute(
self.request("PUT", &full, Duration::from_secs(600)),
Some(""),
)
.map(drop)
}
}
/// An id or code that goes into a URL path: vanilla builds those URLs from the server's own
@@ -1016,6 +1061,23 @@ fn path_segment(s: &str) -> Result<&str, RealmsError> {
}
}
/// A query value: percent-encoded the way `URI` encodes it, so a backup id cannot add a
/// parameter or a fragment.
fn urlencoded(value: &str) -> Result<String, RealmsError> {
let mut out = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
if out.is_empty() {
return Err(RealmsError::Parse);
}
Ok(out)
}
/// `UndashedUuid.toString`: a player's UUID as 32 hex digits, the op/deop/uninvite path
/// segment. Anything else (a name with a `/`, a shortened id) would reach another path.
fn undashed(uuid: &str) -> Result<String, RealmsError> {
@@ -1036,6 +1098,15 @@ mod tests {
const TOKEN: &str = "eyJsecret.token.value";
const UUID: u128 = 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef;
/// `RealmsClient.restoreWorld` puts the backup id in the query: a `&` or `/` in it
/// must not add parameters or paths.
#[test]
fn a_backup_id_is_a_safe_query_value() {
assert_eq!(urlencoded("abc-123_x.y~z").unwrap(), "abc-123_x.y~z");
assert_eq!(urlencoded("a b&c=d/e").unwrap(), "a%20b%26c%3Dd%2Fe");
assert!(urlencoded("").is_err());
}
/// One HTTP exchange on a local port: the request as the server read it.
fn serve_once(answer: &'static str) -> (String, std::thread::JoinHandle<String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();