feat: preserve volumes on failed install + migrate ext4 to btrfs

- COW snapshot (cp --reflink=always) of package volumes before
  install/update; restore on failure, remove on success
- Automatic ext4→btrfs conversion via btrfs-convert during disk attach
  with e2fsck pre-check and post-conversion defrag
- Probe package-data filesystem during setup.disk.list (on both disk
  and partition level) so the UI can warn about ext4 conversion
- Setup wizard preserve-overwrite dialog shows ext4 warning with
  backup acknowledgment checkbox before allowing preserve
This commit is contained in:
Aiden McClelland
2026-03-17 15:10:03 -06:00
parent c1a328e5ca
commit 900d86ab83
15 changed files with 386 additions and 42 deletions

View File

@@ -25,20 +25,28 @@ pub enum RepairStrategy {
Preen,
Aggressive,
}
/// Detects the filesystem type of a block device using `grub-probe`.
/// Returns e.g. `"ext2"` (for ext4), `"btrfs"`, etc.
pub async fn detect_filesystem(
logicalname: impl AsRef<Path> + std::fmt::Debug,
) -> Result<String, Error> {
Ok(String::from_utf8(
Command::new("grub-probe")
.arg("-d")
.arg(logicalname.as_ref())
.invoke(crate::ErrorKind::DiskManagement)
.await?,
)?
.trim()
.to_owned())
}
impl RepairStrategy {
pub async fn fsck(
&self,
logicalname: impl AsRef<Path> + std::fmt::Debug,
) -> Result<RequiresReboot, Error> {
match &*String::from_utf8(
Command::new("grub-probe")
.arg("-d")
.arg(logicalname.as_ref())
.invoke(crate::ErrorKind::DiskManagement)
.await?,
)?
.trim()
{
match &*detect_filesystem(&logicalname).await? {
"ext2" => self.e2fsck(logicalname).await,
"btrfs" => self.btrfs_check(logicalname).await,
fs => {

View File

@@ -7,7 +7,7 @@ use rust_i18n::t;
use tokio::process::Command;
use tracing::instrument;
use super::fsck::{RepairStrategy, RequiresReboot};
use super::fsck::{RepairStrategy, RequiresReboot, detect_filesystem};
use super::util::pvscan;
use crate::disk::mount::filesystem::block_dev::BlockDev;
use crate::disk::mount::filesystem::{FileSystem, ReadWrite};
@@ -301,6 +301,37 @@ pub async fn mount_fs<P: AsRef<Path>>(
.with_ctx(|_| (crate::ErrorKind::Filesystem, PASSWORD_PATH))?;
blockdev_path = Path::new("/dev/mapper").join(&full_name);
}
// Convert ext4 → btrfs on the package-data partition if needed
let fs_type = detect_filesystem(&blockdev_path).await?;
if fs_type == "ext2" {
tracing::info!("Running e2fsck before converting {name} from ext4 to btrfs");
Command::new("e2fsck")
.arg("-fy")
.arg(&blockdev_path)
.invoke(ErrorKind::DiskManagement)
.await?;
tracing::info!("Converting {name} from ext4 to btrfs");
Command::new("btrfs-convert")
.arg("--no-progress")
.arg(&blockdev_path)
.invoke(ErrorKind::DiskManagement)
.await?;
// Defragment after conversion for optimal performance
let tmp_mount = datadir.as_ref().join(format!("{name}.convert-tmp"));
tokio::fs::create_dir_all(&tmp_mount).await?;
BlockDev::new(&blockdev_path)
.mount(&tmp_mount, ReadWrite)
.await?;
Command::new("btrfs")
.args(["filesystem", "defragment", "-r"])
.arg(&tmp_mount)
.invoke(ErrorKind::DiskManagement)
.await?;
unmount(&tmp_mount, false).await?;
tokio::fs::remove_dir(&tmp_mount).await?;
}
let reboot = repair.fsck(&blockdev_path).await?;
if !guid.ends_with("_UNENC") {
@@ -342,3 +373,99 @@ pub async fn mount_all_fs<P: AsRef<Path>>(
reboot |= mount_fs(guid, &datadir, "package-data", repair, password).await?;
Ok(reboot)
}
/// Temporarily activates a VG and opens LUKS to probe the `package-data`
/// filesystem type. Returns `None` if probing fails (e.g. LV doesn't exist).
#[instrument(skip_all)]
pub async fn probe_package_data_fs(guid: &str) -> Result<Option<String>, Error> {
// Import and activate the VG
match Command::new("vgimport")
.arg(guid)
.invoke(ErrorKind::DiskManagement)
.await
{
Ok(_) => {}
Err(e)
if format!("{}", e.source)
.lines()
.any(|l| l.trim() == format!("Volume group \"{}\" is not exported", guid)) =>
{
// Already imported, that's fine
}
Err(e) => {
tracing::warn!("Could not import VG {guid} for filesystem probe: {e}");
return Ok(None);
}
}
if let Err(e) = Command::new("vgchange")
.arg("-ay")
.arg(guid)
.invoke(ErrorKind::DiskManagement)
.await
{
tracing::warn!("Could not activate VG {guid} for filesystem probe: {e}");
return Ok(None);
}
let mut opened_luks = false;
let result = async {
let lv_path = Path::new("/dev").join(guid).join("package-data");
if tokio::fs::metadata(&lv_path).await.is_err() {
return Ok(None);
}
let blockdev_path = if !guid.ends_with("_UNENC") {
let full_name = format!("{guid}_package-data");
let password = DEFAULT_PASSWORD;
if let Some(parent) = Path::new(PASSWORD_PATH).parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(PASSWORD_PATH, password)
.await
.with_ctx(|_| (ErrorKind::Filesystem, PASSWORD_PATH))?;
Command::new("cryptsetup")
.arg("-q")
.arg("luksOpen")
.arg("--allow-discards")
.arg(format!("--key-file={PASSWORD_PATH}"))
.arg(format!("--keyfile-size={}", password.len()))
.arg(&lv_path)
.arg(&full_name)
.invoke(ErrorKind::DiskManagement)
.await?;
let _ = tokio::fs::remove_file(PASSWORD_PATH).await;
opened_luks = true;
PathBuf::from(format!("/dev/mapper/{full_name}"))
} else {
lv_path.clone()
};
detect_filesystem(&blockdev_path).await.map(Some)
}
.await;
// Always clean up: close LUKS, deactivate VG, export VG
if opened_luks {
let full_name = format!("{guid}_package-data");
Command::new("cryptsetup")
.arg("-q")
.arg("luksClose")
.arg(&full_name)
.invoke(ErrorKind::DiskManagement)
.await
.log_err();
}
Command::new("vgchange")
.arg("-an")
.arg(guid)
.invoke(ErrorKind::DiskManagement)
.await
.log_err();
Command::new("vgexport")
.arg(guid)
.invoke(ErrorKind::DiskManagement)
.await
.log_err();
result
}

View File

@@ -41,6 +41,7 @@ pub struct DiskInfo {
pub partitions: Vec<PartitionInfo>,
pub capacity: u64,
pub guid: Option<InternedString>,
pub filesystem: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, ts_rs::TS)]
@@ -55,6 +56,7 @@ pub struct PartitionInfo {
pub used: Option<u64>,
pub start_os: BTreeMap<String, StartOsRecoveryInfo>,
pub guid: Option<InternedString>,
pub filesystem: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, ts_rs::TS)]
@@ -374,6 +376,15 @@ pub async fn list(os: &OsPartitionInfo) -> Result<Vec<DiskInfo>, Error> {
disk_info.capacity = part_info.capacity;
if let Some(g) = disk_guids.get(&disk_info.logicalname) {
disk_info.guid = g.clone();
if let Some(guid) = g {
disk_info.filesystem =
crate::disk::main::probe_package_data_fs(guid)
.await
.unwrap_or_else(|e| {
tracing::warn!("Failed to probe filesystem for {guid}: {e}");
None
});
}
} else {
disk_info.partitions = vec![part_info];
}
@@ -384,11 +395,31 @@ pub async fn list(os: &OsPartitionInfo) -> Result<Vec<DiskInfo>, Error> {
disk_info.partitions = Vec::with_capacity(index.parts.len());
if let Some(g) = disk_guids.get(&disk_info.logicalname) {
disk_info.guid = g.clone();
if let Some(guid) = g {
disk_info.filesystem =
crate::disk::main::probe_package_data_fs(guid)
.await
.unwrap_or_else(|e| {
tracing::warn!("Failed to probe filesystem for {guid}: {e}");
None
});
}
} else {
for part in index.parts {
let mut part_info = part_info(part).await;
if let Some(g) = disk_guids.get(&part_info.logicalname) {
part_info.guid = g.clone();
if let Some(guid) = g {
part_info.filesystem =
crate::disk::main::probe_package_data_fs(guid)
.await
.unwrap_or_else(|e| {
tracing::warn!(
"Failed to probe filesystem for {guid}: {e}"
);
None
});
}
}
disk_info.partitions.push(part_info);
}
@@ -461,6 +492,7 @@ async fn disk_info(disk: PathBuf) -> DiskInfo {
partitions: Vec::new(),
capacity,
guid: None,
filesystem: None,
}
}
@@ -544,6 +576,7 @@ async fn part_info(part: PathBuf) -> PartitionInfo {
used,
start_os,
guid: None,
filesystem: None,
}
}

View File

@@ -422,11 +422,15 @@ impl Service {
tracing::error!("Error installing service: {e}");
tracing::debug!("{e:?}")
}) {
crate::volume::remove_install_backup(id).await.log_err();
return Ok(Some(service));
}
}
}
cleanup(ctx, id, false).await.log_err();
crate::volume::restore_volumes_from_install_backup(id)
.await
.log_err();
ctx.db
.mutate(|v| v.as_public_mut().as_package_data_mut().remove(id))
.await
@@ -461,37 +465,60 @@ impl Service {
tracing::error!("Error installing service: {e}");
tracing::debug!("{e:?}")
}) {
crate::volume::remove_install_backup(id).await.log_err();
return Ok(Some(service));
}
}
}
let s9pk = S9pk::open(s9pk_path, Some(id)).await?;
ctx.db
.mutate({
|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(id)
.or_not_found(id)?
.as_state_info_mut()
.map_mutate(|s| {
if let PackageState::Updating(UpdatingState {
manifest, ..
}) = s
{
Ok(PackageState::Installed(InstalledState { manifest }))
} else {
Err(Error::new(
eyre!("{}", t!("service.mod.race-condition-detected")),
ErrorKind::Database,
))
}
})
}
})
.await
.result?;
handle_installed(s9pk).await
match async {
let s9pk = S9pk::open(s9pk_path, Some(id)).await?;
ctx.db
.mutate({
|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(id)
.or_not_found(id)?
.as_state_info_mut()
.map_mutate(|s| {
if let PackageState::Updating(UpdatingState {
manifest,
..
}) = s
{
Ok(PackageState::Installed(InstalledState { manifest }))
} else {
Err(Error::new(
eyre!(
"{}",
t!("service.mod.race-condition-detected")
),
ErrorKind::Database,
))
}
})
}
})
.await
.result?;
handle_installed(s9pk).await
}
.await
{
Ok(service) => {
crate::volume::remove_install_backup(id).await.log_err();
Ok(service)
}
Err(e) => {
tracing::error!(
"Update rollback failed for {id}, restoring volume snapshot: {e}"
);
crate::volume::restore_volumes_from_install_backup(id)
.await
.log_err();
Err(e)
}
}
}
PackageStateMatchModelRef::Removing(_) | PackageStateMatchModelRef::Restoring(_) => {
if let Ok(s9pk) = S9pk::open(s9pk_path, Some(id)).await.map_err(|e| {

View File

@@ -307,6 +307,8 @@ impl ServiceMap {
finalization_progress.start();
let s9pk = S9pk::open(&installed_path, Some(&id)).await?;
let data_version = get_data_version(&id).await?;
// Snapshot existing volumes before install/update modifies them
crate::volume::snapshot_volumes_for_install(&id).await?;
let prev = if let Some(service) = service.take() {
ensure_code!(
recovery_source.is_none(),
@@ -382,6 +384,8 @@ impl ServiceMap {
cleanup.await?;
}
crate::volume::remove_install_backup(&id).await.log_err();
drop(service);
sync_progress_task.await.map_err(|_| {

View File

@@ -1,13 +1,19 @@
use std::path::{Path, PathBuf};
use tokio::process::Command;
use crate::PackageId;
pub use crate::VolumeId;
use crate::prelude::*;
use crate::util::Invoke;
use crate::util::VersionString;
use crate::DATA_DIR;
pub const PKG_VOLUME_DIR: &str = "package-data/volumes";
pub const BACKUP_DIR: &str = "/media/startos/backups";
const INSTALL_BACKUP_SUFFIX: &str = ".install-backup";
pub fn data_dir<P: AsRef<Path>>(datadir: P, pkg_id: &PackageId, volume_id: &VolumeId) -> PathBuf {
datadir
.as_ref()
@@ -33,3 +39,70 @@ pub fn asset_dir<P: AsRef<Path>>(
pub fn backup_dir(pkg_id: &PackageId) -> PathBuf {
Path::new(BACKUP_DIR).join(pkg_id).join("data")
}
fn pkg_volume_dir(pkg_id: &PackageId) -> PathBuf {
Path::new(DATA_DIR).join(PKG_VOLUME_DIR).join(pkg_id)
}
fn install_backup_path(pkg_id: &PackageId) -> PathBuf {
Path::new(DATA_DIR)
.join(PKG_VOLUME_DIR)
.join(format!("{pkg_id}{INSTALL_BACKUP_SUFFIX}"))
}
/// Creates a COW snapshot of the package volume directory before install.
/// Uses `cp --reflink=always` so it's instant on btrfs and fails gracefully
/// on ext4 (no backup, current behavior preserved).
/// Returns `true` if a backup was created, `false` if no data existed or
/// the filesystem doesn't support reflinks.
pub async fn snapshot_volumes_for_install(pkg_id: &PackageId) -> Result<bool, Error> {
let src = pkg_volume_dir(pkg_id);
if tokio::fs::metadata(&src).await.is_err() {
return Ok(false);
}
let dst = install_backup_path(pkg_id);
// Remove any stale backup from a previous failed attempt
crate::util::io::delete_dir(&dst).await?;
match Command::new("cp")
.arg("-a")
.arg("--reflink=always")
.arg(&src)
.arg(&dst)
.invoke(ErrorKind::Filesystem)
.await
{
Ok(_) => {
tracing::info!("Created install backup for {pkg_id} at {dst:?}");
Ok(true)
}
Err(e) => {
tracing::warn!(
"Could not create install backup for {pkg_id} \
(filesystem may not support reflinks): {e}"
);
// Clean up partial copy if any
crate::util::io::delete_dir(&dst).await?;
Ok(false)
}
}
}
/// Restores the package volume directory from a COW snapshot after a failed
/// install. The current (possibly corrupted) volume dir is deleted first.
/// No-op if no backup exists.
pub async fn restore_volumes_from_install_backup(pkg_id: &PackageId) -> Result<(), Error> {
let backup = install_backup_path(pkg_id);
if tokio::fs::metadata(&backup).await.is_err() {
return Ok(());
}
let dst = pkg_volume_dir(pkg_id);
crate::util::io::delete_dir(&dst).await?;
crate::util::io::rename(&backup, &dst).await?;
tracing::info!("Restored volumes from install backup for {pkg_id}");
Ok(())
}
/// Removes the install backup after a successful install.
pub async fn remove_install_backup(pkg_id: &PackageId) -> Result<(), Error> {
crate::util::io::delete_dir(&install_backup_path(pkg_id)).await
}

View File

@@ -1,12 +1,30 @@
import { Component } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiDialogContext } from '@taiga-ui/core'
import {
TuiButton,
TuiCheckbox,
TuiDialogContext,
TuiNotification,
TuiTitle,
} from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
export interface PreserveOverwriteData {
isExt4: boolean
}
@Component({
imports: [TuiButton, TuiHeader, TuiTitle, i18nPipe],
imports: [
FormsModule,
TuiButton,
TuiCheckbox,
TuiHeader,
TuiNotification,
TuiTitle,
i18nPipe,
],
template: `
<header tuiHeader>
<hgroup tuiTitle>
@@ -24,6 +42,18 @@ import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
{{ 'to discard' | i18n }}
</li>
</ul>
@if (context.data.isExt4) {
<p tuiNotification appearance="warning" size="m">
{{
'This drive uses ext4 and will be automatically converted to btrfs. A backup is strongly recommended before proceeding.'
| i18n
}}
</p>
<label>
<input tuiCheckbox type="checkbox" [(ngModel)]="backupAck" />
{{ 'I have a backup of my data' | i18n }}
</label>
}
<footer>
<button
tuiButton
@@ -36,6 +66,7 @@ import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
tuiButton
appearance=""
[style.background]="'var(--tui-status-positive)'"
[disabled]="context.data.isExt4 && !backupAck"
(click)="context.completeWith(true)"
>
{{ 'Preserve' | i18n }}
@@ -44,7 +75,9 @@ import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
`,
})
export class PreserveOverwriteDialog {
protected readonly context = injectContext<TuiDialogContext<boolean>>()
protected readonly context =
injectContext<TuiDialogContext<boolean, PreserveOverwriteData>>()
protected backupAck = false
}
export const PRESERVE_OVERWRITE = new PolymorpheusComponent(

View File

@@ -292,8 +292,18 @@ export default class DrivesPage {
private showPreserveOverwriteDialog() {
let selectionMade = false
const drive = this.selectedDataDrive
const filesystem =
drive?.filesystem ||
drive?.partitions.find(p => p.guid)?.filesystem ||
null
const isExt4 = filesystem === 'ext2'
this.dialogs.openComponent<boolean>(PRESERVE_OVERWRITE).subscribe({
this.dialogs
.openComponent<boolean>(PRESERVE_OVERWRITE, {
data: { isExt4 },
})
.subscribe({
next: preserve => {
selectionMade = true
this.preserveData = preserve

View File

@@ -203,6 +203,7 @@ const MOCK_DISKS: DiskInfo[] = [
partitions: [],
capacity: 0,
guid: null,
filesystem: null,
},
// 10 GiB - too small for OS and data; also tests both vendor+model null
{
@@ -217,10 +218,12 @@ const MOCK_DISKS: DiskInfo[] = [
used: null,
startOs: {},
guid: null,
filesystem: null,
},
],
capacity: 10 * GiB,
guid: null,
filesystem: null,
},
// 18 GiB - exact OS boundary; tests vendor null with model present
{
@@ -235,10 +238,12 @@ const MOCK_DISKS: DiskInfo[] = [
used: null,
startOs: {},
guid: null,
filesystem: null,
},
],
capacity: 18 * GiB,
guid: null,
filesystem: null,
},
// 20 GiB - exact data boundary; tests vendor present with model null
{
@@ -253,10 +258,12 @@ const MOCK_DISKS: DiskInfo[] = [
used: null,
startOs: {},
guid: null,
filesystem: null,
},
],
capacity: 20 * GiB,
guid: null,
filesystem: null,
},
// 30 GiB - OK for OS or data alone, too small for both (< 38 GiB)
{
@@ -271,10 +278,12 @@ const MOCK_DISKS: DiskInfo[] = [
used: null,
startOs: {},
guid: null,
filesystem: null,
},
],
capacity: 30 * GiB,
guid: null,
filesystem: null,
},
// 30 GiB with existing StartOS data - tests preserve/overwrite + capacity constraint
{
@@ -298,10 +307,12 @@ const MOCK_DISKS: DiskInfo[] = [
},
},
guid: 'small-existing-guid',
filesystem: 'ext2',
},
],
capacity: 30 * GiB,
guid: 'small-existing-guid',
filesystem: 'ext2',
},
// 500 GB - large, always OK
{
@@ -316,10 +327,12 @@ const MOCK_DISKS: DiskInfo[] = [
used: null,
startOs: {},
guid: null,
filesystem: null,
},
],
capacity: 500000000000,
guid: null,
filesystem: null,
},
// 1 TB with existing StartOS data
{
@@ -343,10 +356,12 @@ const MOCK_DISKS: DiskInfo[] = [
},
},
guid: 'existing-guid',
filesystem: 'btrfs',
},
],
capacity: 1000000000000,
guid: 'existing-guid',
filesystem: 'btrfs',
},
// 2 TB
{
@@ -370,10 +385,12 @@ const MOCK_DISKS: DiskInfo[] = [
},
},
guid: null,
filesystem: null,
},
],
capacity: 2000000000000,
guid: null,
filesystem: null,
},
]

View File

@@ -723,4 +723,6 @@ export default {
800: 'Geben Sie bei Aufforderung Ihr StartOS-Passwort ein',
801: 'Ihr System hat Secure Boot aktiviert, was erfordert, dass alle Kernel-Module mit einem vertrauenswürdigen Schlüssel signiert sind. Einige Hardware-Treiber \u2014 wie die für NVIDIA-GPUs \u2014 sind nicht mit dem Standard-Distributionsschlüssel signiert. Die Registrierung des StartOS-Signaturschlüssels ermöglicht es Ihrer Firmware, diesen Modulen zu vertrauen, damit Ihre Hardware vollständig genutzt werden kann.',
802: 'Die Übersetzungen auf Betriebssystemebene sind bereits aktiv. Ein Neustart ist erforderlich, damit die Übersetzungen auf Dienstebene wirksam werden.',
803: 'Dieses Laufwerk verwendet ext4 und wird automatisch in btrfs konvertiert. Ein Backup wird dringend empfohlen, bevor Sie fortfahren.',
804: 'Ich habe ein Backup meiner Daten',
} satisfies i18n

View File

@@ -724,4 +724,6 @@ export const ENGLISH: Record<string, number> = {
'When prompted, enter your StartOS password': 800,
'Your system has Secure Boot enabled, which requires all kernel modules to be signed with a trusted key. Some hardware drivers \u2014 such as those for NVIDIA GPUs \u2014 are not signed by the default distribution key. Enrolling the StartOS signing key allows your firmware to trust these modules so your hardware can be fully utilized.': 801,
'OS-level translations are already in effect. A restart is required for service-level translations to take effect.': 802,
'This drive uses ext4 and will be automatically converted to btrfs. A backup is strongly recommended before proceeding.': 803,
'I have a backup of my data': 804,
}

View File

@@ -723,4 +723,6 @@ export default {
800: 'Cuando se le solicite, ingrese su contraseña de StartOS',
801: 'Su sistema tiene Secure Boot habilitado, lo que requiere que todos los módulos del kernel estén firmados con una clave de confianza. Algunos controladores de hardware \u2014 como los de las GPU NVIDIA \u2014 no están firmados con la clave de distribución predeterminada. Registrar la clave de firma de StartOS permite que su firmware confíe en estos módulos para que su hardware pueda utilizarse completamente.',
802: 'Las traducciones a nivel del sistema operativo ya están en vigor. Se requiere un reinicio para que las traducciones a nivel de servicio surtan efecto.',
803: 'Esta unidad usa ext4 y se convertirá automáticamente a btrfs. Se recomienda encarecidamente hacer una copia de seguridad antes de continuar.',
804: 'Tengo una copia de seguridad de mis datos',
} satisfies i18n

View File

@@ -723,4 +723,6 @@ export default {
800: 'Lorsque vous y êtes invité, entrez votre mot de passe StartOS',
801: "Votre système a Secure Boot activé, ce qui exige que tous les modules du noyau soient signés avec une clé de confiance. Certains pilotes matériels \u2014 comme ceux des GPU NVIDIA \u2014 ne sont pas signés par la clé de distribution par défaut. L'enregistrement de la clé de signature StartOS permet à votre firmware de faire confiance à ces modules afin que votre matériel puisse être pleinement utilisé.",
802: "Les traductions au niveau du système d'exploitation sont déjà en vigueur. Un redémarrage est nécessaire pour que les traductions au niveau des services prennent effet.",
803: "Ce disque utilise ext4 et sera automatiquement converti en btrfs. Il est fortement recommandé de faire une sauvegarde avant de continuer.",
804: "J'ai une sauvegarde de mes données",
} satisfies i18n

View File

@@ -723,4 +723,6 @@ export default {
800: 'Po wyświetleniu monitu wprowadź swoje hasło StartOS',
801: 'Twój system ma włączony Secure Boot, co wymaga, aby wszystkie moduły jądra były podpisane zaufanym kluczem. Niektóre sterowniki sprzętowe \u2014 takie jak te dla GPU NVIDIA \u2014 nie są podpisane domyślnym kluczem dystrybucji. Zarejestrowanie klucza podpisu StartOS pozwala firmware ufać tym modułom, aby sprzęt mógł być w pełni wykorzystany.',
802: 'Tłumaczenia na poziomie systemu operacyjnego są już aktywne. Wymagane jest ponowne uruchomienie, aby tłumaczenia na poziomie usług zaczęły obowiązywać.',
803: 'Ten dysk używa ext4 i zostanie automatycznie skonwertowany na btrfs. Zdecydowanie zaleca się wykonanie kopii zapasowej przed kontynuowaniem.',
804: 'Mam kopię zapasową moich danych',
} satisfies i18n

View File

@@ -7,6 +7,7 @@ export interface DiskInfo {
partitions: PartitionInfo[]
capacity: number
guid: string | null
filesystem: string | null
}
export interface PartitionInfo {
@@ -16,6 +17,7 @@ export interface PartitionInfo {
used: number | null
startOs: Record<string, StartOSDiskInfo>
guid: string | null
filesystem: string | null
}
export type StartOSDiskInfo = {