mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-01 21:13:09 +00:00
working setup flow + manifest localization
This commit is contained in:
@@ -23,8 +23,8 @@ use crate::progress::ProgressUnits;
|
||||
use crate::s9pk::S9pk;
|
||||
use crate::service::service_map::DownloadInstallFuture;
|
||||
use crate::setup::SetupExecuteProgress;
|
||||
use crate::system::sync_kiosk;
|
||||
use crate::util::serde::IoFormat;
|
||||
use crate::system::{save_language, sync_kiosk};
|
||||
use crate::util::serde::{IoFormat, Pem};
|
||||
use crate::{PLATFORM, PackageId};
|
||||
|
||||
#[derive(Deserialize, Serialize, Parser, TS)]
|
||||
@@ -66,7 +66,10 @@ pub async fn restore_packages_rpc(
|
||||
match async { res.await?.await }.await {
|
||||
Ok(_) => (),
|
||||
Err(err) => {
|
||||
tracing::error!("{}", t!("backup.restore.package-error", id = id, error = err));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!("backup.restore.package-error", id = id, error = err)
|
||||
);
|
||||
tracing::debug!("{:?}", err);
|
||||
}
|
||||
}
|
||||
@@ -81,7 +84,7 @@ pub async fn restore_packages_rpc(
|
||||
pub async fn recover_full_server(
|
||||
ctx: &SetupContext,
|
||||
disk_guid: InternedString,
|
||||
start_os_password: String,
|
||||
password: String,
|
||||
recovery_source: TmpMountGuard,
|
||||
server_id: &str,
|
||||
recovery_password: &str,
|
||||
@@ -105,7 +108,7 @@ pub async fn recover_full_server(
|
||||
)?;
|
||||
|
||||
os_backup.account.password = argon2::hash_encoded(
|
||||
start_os_password.as_bytes(),
|
||||
password.as_bytes(),
|
||||
&rand::random::<[u8; 16]>()[..],
|
||||
&argon2::Config::rfc9106_low_mem(),
|
||||
)
|
||||
@@ -114,9 +117,23 @@ pub async fn recover_full_server(
|
||||
let kiosk = Some(kiosk.unwrap_or(true)).filter(|_| &*PLATFORM != "raspberrypi");
|
||||
sync_kiosk(kiosk).await?;
|
||||
|
||||
let language = ctx.language.peek(|a| a.clone());
|
||||
let keyboard = ctx.keyboard.peek(|a| a.clone());
|
||||
|
||||
if let Some(language) = &language {
|
||||
save_language(&**language).await?;
|
||||
}
|
||||
|
||||
if let Some(keyboard) = &keyboard {
|
||||
keyboard.save().await?;
|
||||
}
|
||||
|
||||
let db = ctx.db().await?;
|
||||
db.put(&ROOT, &Database::init(&os_backup.account, kiosk)?)
|
||||
.await?;
|
||||
db.put(
|
||||
&ROOT,
|
||||
&Database::init(&os_backup.account, kiosk, language, keyboard)?,
|
||||
)
|
||||
.await?;
|
||||
drop(db);
|
||||
|
||||
let config = ctx.config.peek(|c| c.clone());
|
||||
@@ -150,7 +167,10 @@ pub async fn recover_full_server(
|
||||
match async { res.await?.await }.await {
|
||||
Ok(_) => (),
|
||||
Err(err) => {
|
||||
tracing::error!("{}", t!("backup.restore.package-error", id = id, error = err));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!("backup.restore.package-error", id = id, error = err)
|
||||
);
|
||||
tracing::debug!("{:?}", err);
|
||||
}
|
||||
}
|
||||
@@ -160,7 +180,14 @@ pub async fn recover_full_server(
|
||||
.await;
|
||||
restore_phase.lock().await.complete();
|
||||
|
||||
Ok(((&os_backup.account).try_into()?, rpc_ctx))
|
||||
Ok((
|
||||
SetupResult {
|
||||
hostname: os_backup.account.hostname,
|
||||
root_ca: Pem(os_backup.account.root_ca_cert),
|
||||
needs_restart: ctx.install_rootfs.peek(|a| a.is_some()),
|
||||
},
|
||||
rpc_ctx,
|
||||
))
|
||||
}
|
||||
|
||||
#[instrument(skip(ctx, backup_guard))]
|
||||
|
||||
@@ -12,22 +12,27 @@ pub mod start_init;
|
||||
pub mod startd;
|
||||
pub mod tunnel;
|
||||
|
||||
pub fn set_locale() {
|
||||
pub fn set_locale_from_env() {
|
||||
let lang = std::env::var("LANG").ok();
|
||||
let lang = lang
|
||||
.as_deref()
|
||||
.map_or("C", |l| l.strip_suffix(".UTF-8").unwrap_or(l));
|
||||
let mut set_lang = lang;
|
||||
set_locale(lang)
|
||||
}
|
||||
|
||||
pub fn set_locale(lang: &str) {
|
||||
let mut best = None;
|
||||
let prefix = lang.split_inclusive("_").next().unwrap();
|
||||
for l in rust_i18n::available_locales!() {
|
||||
if l == lang {
|
||||
set_lang = l;
|
||||
best = Some(l);
|
||||
break;
|
||||
}
|
||||
if l.split("_").next().unwrap() == lang.split("_").next().unwrap() {
|
||||
set_lang = l;
|
||||
if best.is_none() && l.starts_with(prefix) {
|
||||
best = Some(l);
|
||||
}
|
||||
}
|
||||
rust_i18n::set_locale(set_lang);
|
||||
rust_i18n::set_locale(best.unwrap_or(lang));
|
||||
}
|
||||
|
||||
pub fn translate_cli(mut cmd: clap::Command) -> clap::Command {
|
||||
@@ -144,7 +149,7 @@ impl MultiExecutable {
|
||||
}
|
||||
|
||||
pub fn execute(&self) {
|
||||
set_locale();
|
||||
set_locale_from_env();
|
||||
|
||||
let mut popped = Vec::with_capacity(2);
|
||||
let mut args = std::env::args_os().collect::<VecDeque<_>>();
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::time::Duration;
|
||||
use futures::{Future, StreamExt};
|
||||
use imbl_value::InternedString;
|
||||
use josekit::jwk::Jwk;
|
||||
use openssl::x509::X509;
|
||||
use patch_db::PatchDb;
|
||||
use rpc_toolkit::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -15,10 +16,9 @@ use tracing::instrument;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::MAIN_DATA;
|
||||
use crate::account::AccountInfo;
|
||||
use crate::context::RpcContext;
|
||||
use crate::context::config::ServerConfig;
|
||||
use crate::disk::mount::guard::TmpMountGuard;
|
||||
use crate::disk::mount::guard::{MountGuard, TmpMountGuard};
|
||||
use crate::hostname::Hostname;
|
||||
use crate::net::gateway::UpgradableListener;
|
||||
use crate::net::web_server::{WebServer, WebServerAcceptorSetter};
|
||||
@@ -27,7 +27,9 @@ use crate::progress::FullProgressTracker;
|
||||
use crate::rpc_continuations::{Guid, RpcContinuation, RpcContinuations};
|
||||
use crate::setup::SetupProgress;
|
||||
use crate::shutdown::Shutdown;
|
||||
use crate::system::KeyboardOptions;
|
||||
use crate::util::future::NonDetachingJoinHandle;
|
||||
use crate::util::serde::Pem;
|
||||
use crate::util::sync::SyncMutex;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -42,27 +44,10 @@ lazy_static::lazy_static! {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SetupResult {
|
||||
pub tor_addresses: Vec<String>,
|
||||
#[ts(type = "string")]
|
||||
pub hostname: Hostname,
|
||||
#[ts(type = "string")]
|
||||
pub lan_address: InternedString,
|
||||
pub root_ca: String,
|
||||
}
|
||||
impl TryFrom<&AccountInfo> for SetupResult {
|
||||
type Error = Error;
|
||||
fn try_from(value: &AccountInfo) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
tor_addresses: value
|
||||
.tor_keys
|
||||
.iter()
|
||||
.map(|tor_key| format!("https://{}", tor_key.onion_address()))
|
||||
.collect(),
|
||||
hostname: value.hostname.clone(),
|
||||
lan_address: value.hostname.lan_address(),
|
||||
root_ca: String::from_utf8(value.root_ca_cert.to_pem()?)?,
|
||||
})
|
||||
}
|
||||
pub root_ca: Pem<X509>,
|
||||
pub needs_restart: bool,
|
||||
}
|
||||
|
||||
pub struct SetupContextSeed {
|
||||
@@ -75,7 +60,9 @@ pub struct SetupContextSeed {
|
||||
pub disk_guid: OnceCell<InternedString>,
|
||||
pub shutdown: Sender<Option<Shutdown>>,
|
||||
pub rpc_continuations: RpcContinuations,
|
||||
pub install_rootfs: SyncMutex<Option<TmpMountGuard>>,
|
||||
pub install_rootfs: SyncMutex<Option<(TmpMountGuard, MountGuard)>>,
|
||||
pub keyboard: SyncMutex<Option<KeyboardOptions>>,
|
||||
pub language: SyncMutex<Option<InternedString>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -100,6 +87,8 @@ impl SetupContext {
|
||||
shutdown,
|
||||
rpc_continuations: RpcContinuations::new(),
|
||||
install_rootfs: SyncMutex::new(None),
|
||||
language: SyncMutex::new(None),
|
||||
keyboard: SyncMutex::new(None),
|
||||
})))
|
||||
}
|
||||
#[instrument(skip_all)]
|
||||
@@ -129,7 +118,10 @@ impl SetupContext {
|
||||
Ok(res)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("{}", t!("context.setup.setup-failed", error = e));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!("context.setup.setup-failed", error = e)
|
||||
);
|
||||
tracing::debug!("{e:?}");
|
||||
Err(e)
|
||||
}
|
||||
@@ -142,7 +134,10 @@ impl SetupContext {
|
||||
)
|
||||
.map_err(|_| {
|
||||
if self.result.initialized() {
|
||||
Error::new(eyre!("{}", t!("context.setup.setup-already-complete")), ErrorKind::InvalidRequest)
|
||||
Error::new(
|
||||
eyre!("{}", t!("context.setup.setup-already-complete")),
|
||||
ErrorKind::InvalidRequest,
|
||||
)
|
||||
} else {
|
||||
Error::new(
|
||||
eyre!("{}", t!("context.setup.setup-already-in-progress")),
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::notifications::Notifications;
|
||||
use crate::prelude::*;
|
||||
use crate::sign::AnyVerifyingKey;
|
||||
use crate::ssh::SshKeys;
|
||||
use crate::system::KeyboardOptions;
|
||||
use crate::util::serde::Pem;
|
||||
|
||||
pub mod package;
|
||||
@@ -28,9 +29,14 @@ pub struct Database {
|
||||
pub private: Private,
|
||||
}
|
||||
impl Database {
|
||||
pub fn init(account: &AccountInfo, kiosk: Option<bool>) -> Result<Self, Error> {
|
||||
pub fn init(
|
||||
account: &AccountInfo,
|
||||
kiosk: Option<bool>,
|
||||
language: Option<InternedString>,
|
||||
keyboard: Option<KeyboardOptions>,
|
||||
) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
public: Public::init(account, kiosk)?,
|
||||
public: Public::init(account, kiosk, language, keyboard)?,
|
||||
private: Private {
|
||||
key_store: KeyStore::new(account)?,
|
||||
password: account.password.clone(),
|
||||
|
||||
@@ -417,8 +417,7 @@ impl Map for CurrentDependencies {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[model = "Model<Self>"]
|
||||
pub struct CurrentDependencyInfo {
|
||||
#[ts(type = "string | null")]
|
||||
pub title: Option<InternedString>,
|
||||
pub title: Option<String>,
|
||||
pub icon: Option<DataUrl<'static>>,
|
||||
#[serde(flatten)]
|
||||
pub kind: CurrentDependencyKind,
|
||||
|
||||
@@ -45,7 +45,12 @@ pub struct Public {
|
||||
pub ui: Value,
|
||||
}
|
||||
impl Public {
|
||||
pub fn init(account: &AccountInfo, kiosk: Option<bool>) -> Result<Self, Error> {
|
||||
pub fn init(
|
||||
account: &AccountInfo,
|
||||
kiosk: Option<bool>,
|
||||
language: Option<InternedString>,
|
||||
keyboard: Option<KeyboardOptions>,
|
||||
) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
server_info: ServerInfo {
|
||||
arch: get_arch(),
|
||||
@@ -139,8 +144,8 @@ impl Public {
|
||||
ram: 0,
|
||||
devices: Vec::new(),
|
||||
kiosk,
|
||||
language: None,
|
||||
keyboard: None,
|
||||
language,
|
||||
keyboard,
|
||||
},
|
||||
package_data: AllPackageData::default(),
|
||||
ui: serde_json::from_str(*DB_UI_SEED_CELL.get().unwrap_or(&"null"))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use imbl_value::InternedString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::prelude::*;
|
||||
use crate::s9pk::manifest::LocaleString;
|
||||
use crate::util::PathOrUrl;
|
||||
use crate::{Error, PackageId};
|
||||
|
||||
@@ -28,7 +28,7 @@ impl Map for Dependencies {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[model = "Model<Self>"]
|
||||
pub struct DepInfo {
|
||||
pub description: Option<String>,
|
||||
pub description: Option<LocaleString>,
|
||||
pub optional: bool,
|
||||
#[serde(flatten)]
|
||||
pub metadata: Option<MetadataSrc>,
|
||||
@@ -73,7 +73,7 @@ pub enum MetadataSrc {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct Metadata {
|
||||
pub title: InternedString,
|
||||
pub title: LocaleString,
|
||||
pub icon: PathOrUrl,
|
||||
}
|
||||
|
||||
@@ -82,5 +82,5 @@ pub struct Metadata {
|
||||
#[model = "Model<Self>"]
|
||||
pub struct DependencyMetadata {
|
||||
#[ts(type = "string")]
|
||||
pub title: InternedString,
|
||||
pub title: LocaleString,
|
||||
}
|
||||
|
||||
@@ -377,6 +377,20 @@ pub fn server<C: Context>() -> ParentHandler<C> {
|
||||
"host",
|
||||
net::host::server_host_api::<C>().with_about("about.commands-host-system-ui"),
|
||||
)
|
||||
.subcommand(
|
||||
"set-keyboard",
|
||||
from_fn_async(system::set_keyboard)
|
||||
.no_display()
|
||||
.with_about("about.set-keyboard")
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
.subcommand(
|
||||
"set-language",
|
||||
from_fn_async(system::set_language)
|
||||
.no_display()
|
||||
.with_about("about.set-language")
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn package<C: Context>() -> ParentHandler<C> {
|
||||
|
||||
@@ -78,37 +78,6 @@ pub async fn partition(
|
||||
|
||||
gpt.update_partitions(Default::default())?;
|
||||
|
||||
// Calculate where the OS partitions will end
|
||||
// EFI/BIOS: 100MB or 8MB, Boot: 1GB, Root: 15GB
|
||||
let efi_size_sectors = if use_efi {
|
||||
100 * 1024 * 1024 / 512
|
||||
} else {
|
||||
8 * 1024 * 1024 / 512
|
||||
};
|
||||
let boot_size_sectors = 1024 * 1024 * 1024 / 512;
|
||||
let root_size_sectors = 15 * 1024 * 1024 * 1024 / 512;
|
||||
// GPT typically starts partitions at sector 2048
|
||||
let os_partitions_end_sector =
|
||||
2048 + efi_size_sectors + boot_size_sectors + root_size_sectors;
|
||||
|
||||
// Check if protected partition would be overwritten
|
||||
if let Some((first_lba, _, ref path)) = protected_partition_info {
|
||||
if first_lba < os_partitions_end_sector {
|
||||
return Err(Error::new(
|
||||
eyre!(
|
||||
concat!(
|
||||
"Protected partition {} starts at sector {}",
|
||||
" which would be overwritten by OS partitions ending at sector {}"
|
||||
),
|
||||
path.display(),
|
||||
first_lba,
|
||||
os_partitions_end_sector
|
||||
),
|
||||
crate::ErrorKind::DiskManagement,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let efi = if use_efi {
|
||||
gpt.add_partition("efi", 100 * 1024 * 1024, gpt::partition_types::EFI, 0, None)?;
|
||||
true
|
||||
@@ -141,6 +110,30 @@ pub async fn partition(
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Check if protected partition would be overwritten by OS partitions
|
||||
if let Some((first_lba, _, ref path)) = protected_partition_info {
|
||||
// Get the actual end sector of the last OS partition (root = partition 3)
|
||||
let os_partitions_end_sector = gpt
|
||||
.partitions()
|
||||
.get(&3)
|
||||
.map(|p| p.last_lba)
|
||||
.unwrap_or(0);
|
||||
if first_lba <= os_partitions_end_sector {
|
||||
return Err(Error::new(
|
||||
eyre!(
|
||||
concat!(
|
||||
"Protected partition {} starts at sector {}",
|
||||
" which would be overwritten by OS partitions ending at sector {}"
|
||||
),
|
||||
path.display(),
|
||||
first_lba,
|
||||
os_partitions_end_sector
|
||||
),
|
||||
crate::ErrorKind::DiskManagement,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let data_part = if let Some((first_lba, last_lba, path)) = protected_partition_info {
|
||||
// Re-create the data partition entry at the same location
|
||||
let length_lba = last_lba - first_lba + 1;
|
||||
|
||||
@@ -454,20 +454,25 @@ pub async fn install_os(
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if let Some(guid) = disks.iter().find_map(|d| {
|
||||
d.guid
|
||||
.as_ref()
|
||||
.filter(|_| &d.logicalname == logicalname)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
d.partitions.iter().find_map(|p| {
|
||||
p.guid
|
||||
.as_ref()
|
||||
.filter(|_| &p.logicalname == logicalname)
|
||||
.cloned()
|
||||
if let Some(guid) = (!data_drive.wipe)
|
||||
.then(|| disks.iter())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|d| {
|
||||
d.guid
|
||||
.as_ref()
|
||||
.filter(|_| &d.logicalname == logicalname)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
d.partitions.iter().find_map(|p| {
|
||||
p.guid
|
||||
.as_ref()
|
||||
.filter(|_| &p.logicalname == logicalname)
|
||||
.cloned()
|
||||
})
|
||||
})
|
||||
})
|
||||
}) {
|
||||
})
|
||||
{
|
||||
setup_info.guid = Some(guid);
|
||||
setup_info.attach = true;
|
||||
} else {
|
||||
@@ -476,13 +481,20 @@ pub async fn install_os(
|
||||
}
|
||||
}
|
||||
|
||||
let config = MountGuard::mount(
|
||||
&Bind::new(rootfs.path().join("config")),
|
||||
"/media/startos/config",
|
||||
ReadWrite,
|
||||
)
|
||||
.await?;
|
||||
|
||||
write_file_atomic(
|
||||
rootfs.path().join("config/setup.json"),
|
||||
"/media/startos/config/setup.json",
|
||||
IoFormat::JsonPretty.to_vec(&setup_info)?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
ctx.install_rootfs.replace(Some(rootfs));
|
||||
ctx.install_rootfs.replace(Some((rootfs, config)));
|
||||
|
||||
Ok(setup_info)
|
||||
}
|
||||
|
||||
@@ -44,28 +44,44 @@ impl DeviceInfo {
|
||||
impl DeviceInfo {
|
||||
pub fn to_header_value(&self) -> HeaderValue {
|
||||
let mut url: Url = "http://localhost".parse().unwrap();
|
||||
url.query_pairs_mut()
|
||||
.append_pair("os.version", &self.os.version.to_string())
|
||||
let mut qp = url.query_pairs_mut();
|
||||
qp.append_pair("os.version", &self.os.version.to_string())
|
||||
.append_pair("os.compat", &self.os.compat.to_string())
|
||||
.append_pair("os.platform", &*self.os.platform);
|
||||
if let Some(lang) = self.os.language.as_deref() {
|
||||
qp.append_pair("os.language", lang);
|
||||
}
|
||||
drop(qp);
|
||||
|
||||
HeaderValue::from_str(url.query().unwrap_or_default()).unwrap()
|
||||
}
|
||||
pub fn from_header_value(header: &HeaderValue) -> Result<Self, Error> {
|
||||
let query: BTreeMap<_, _> = form_urlencoded::parse(header.as_bytes()).collect();
|
||||
let has_hw_info = query.keys().any(|k| k.starts_with("hardware."));
|
||||
let version = query
|
||||
.get("os.version")
|
||||
.or_not_found("os.version")?
|
||||
.parse()?;
|
||||
Ok(Self {
|
||||
os: OsInfo {
|
||||
version: query
|
||||
.get("os.version")
|
||||
.or_not_found("os.version")?
|
||||
.parse()?,
|
||||
compat: query.get("os.compat").or_not_found("os.compat")?.parse()?,
|
||||
platform: query
|
||||
.get("os.platform")
|
||||
.or_not_found("os.platform")?
|
||||
.deref()
|
||||
.into(),
|
||||
language: query
|
||||
.get("os.language")
|
||||
.map(|v| v.deref())
|
||||
.map(InternedString::intern)
|
||||
.or_else(|| {
|
||||
if version < "0.4.0-alpha.18".parse().ok()? {
|
||||
Some(rust_i18n::locale().deref().into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
version,
|
||||
},
|
||||
hardware: has_hw_info
|
||||
.then(|| {
|
||||
@@ -190,8 +206,8 @@ pub struct OsInfo {
|
||||
pub version: Version,
|
||||
#[ts(type = "string")]
|
||||
pub compat: VersionRange,
|
||||
#[ts(type = "string")]
|
||||
pub platform: InternedString,
|
||||
pub language: Option<InternedString>,
|
||||
}
|
||||
impl From<&RpcContext> for OsInfo {
|
||||
fn from(_: &RpcContext) -> Self {
|
||||
@@ -199,6 +215,7 @@ impl From<&RpcContext> for OsInfo {
|
||||
version: crate::version::Current::default().semver(),
|
||||
compat: crate::version::Current::default().compat().clone(),
|
||||
platform: InternedString::intern(&*crate::PLATFORM),
|
||||
language: Some(InternedString::intern(&*rust_i18n::locale())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::context::CliContext;
|
||||
use crate::prelude::*;
|
||||
use crate::registry::context::RegistryContext;
|
||||
use crate::registry::package::index::Category;
|
||||
use crate::s9pk::manifest::LocaleString;
|
||||
use crate::util::serde::{HandlerExtSerde, WithIoFormat, display_serializable};
|
||||
|
||||
pub fn category_api<C: Context>() -> ParentHandler<C> {
|
||||
@@ -66,7 +67,7 @@ pub fn category_api<C: Context>() -> ParentHandler<C> {
|
||||
pub struct AddCategoryParams {
|
||||
#[ts(type = "string")]
|
||||
pub id: InternedString,
|
||||
pub name: String,
|
||||
pub name: LocaleString,
|
||||
}
|
||||
|
||||
pub async fn add_category(
|
||||
@@ -196,7 +197,7 @@ pub fn display_categories<T>(
|
||||
"NAME",
|
||||
]);
|
||||
for (id, info) in categories {
|
||||
table.add_row(row![&*id, &info.name]);
|
||||
table.add_row(row![&*id, &info.name.localized()]);
|
||||
}
|
||||
table.print_tty(false)?;
|
||||
Ok(())
|
||||
|
||||
@@ -79,20 +79,20 @@ pub struct GetPackageResponse {
|
||||
pub other_versions: Option<BTreeMap<VersionString, PackageInfoShort>>,
|
||||
}
|
||||
impl GetPackageResponse {
|
||||
pub fn tables(&self) -> Vec<prettytable::Table> {
|
||||
pub fn tables(self) -> Vec<prettytable::Table> {
|
||||
use prettytable::*;
|
||||
|
||||
let mut res = Vec::with_capacity(self.best.len());
|
||||
|
||||
for (version, info) in &self.best {
|
||||
let mut table = info.table(version);
|
||||
for (version, info) in self.best {
|
||||
let mut table = info.table(&version);
|
||||
|
||||
let lesser_versions: BTreeMap<_, _> = self
|
||||
.other_versions
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(v, _)| ***v < **version)
|
||||
.filter(|(v, _)| ***v < *version)
|
||||
.collect();
|
||||
|
||||
if !lesser_versions.is_empty() {
|
||||
@@ -121,13 +121,17 @@ pub struct GetPackageResponseFull {
|
||||
pub other_versions: BTreeMap<VersionString, PackageVersionInfo>,
|
||||
}
|
||||
impl GetPackageResponseFull {
|
||||
pub fn tables(&self) -> Vec<prettytable::Table> {
|
||||
pub fn tables(self) -> Vec<prettytable::Table> {
|
||||
let mut res = Vec::with_capacity(self.best.len());
|
||||
|
||||
let all: BTreeMap<_, _> = self.best.iter().chain(self.other_versions.iter()).collect();
|
||||
let all: BTreeMap<_, _> = self
|
||||
.best
|
||||
.into_iter()
|
||||
.chain(self.other_versions.into_iter())
|
||||
.collect();
|
||||
|
||||
for (version, info) in all {
|
||||
res.push(info.table(version));
|
||||
res.push(info.table(&version));
|
||||
}
|
||||
|
||||
res
|
||||
@@ -444,7 +448,11 @@ pub async fn cli_download(
|
||||
return Err(Error::new(
|
||||
eyre!(
|
||||
"{}",
|
||||
t!("registry.package.get.version-not-found", id = id, version = target_version.unwrap_or(VersionRange::Any))
|
||||
t!(
|
||||
"registry.package.get.version-not-found",
|
||||
id = id,
|
||||
version = target_version.unwrap_or(VersionRange::Any)
|
||||
)
|
||||
),
|
||||
ErrorKind::NotFound,
|
||||
));
|
||||
@@ -465,7 +473,11 @@ pub async fn cli_download(
|
||||
return Err(Error::new(
|
||||
eyre!(
|
||||
"{}",
|
||||
t!("registry.package.get.version-not-found", id = id, version = target_version.unwrap_or(VersionRange::Any))
|
||||
t!(
|
||||
"registry.package.get.version-not-found",
|
||||
id = id,
|
||||
version = target_version.unwrap_or(VersionRange::Any)
|
||||
)
|
||||
),
|
||||
ErrorKind::NotFound,
|
||||
));
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::registry::device_info::DeviceInfo;
|
||||
use crate::rpc_continuations::Guid;
|
||||
use crate::s9pk::S9pk;
|
||||
use crate::s9pk::git_hash::GitHash;
|
||||
use crate::s9pk::manifest::{Alerts, Description, HardwareRequirements};
|
||||
use crate::s9pk::manifest::{Alerts, Description, HardwareRequirements, LocaleString};
|
||||
use crate::s9pk::merkle_archive::source::FileSource;
|
||||
use crate::sign::commitment::merkle_archive::MerkleArchiveCommitment;
|
||||
use crate::sign::{AnySignature, AnyVerifyingKey};
|
||||
@@ -49,22 +49,27 @@ pub struct PackageInfo {
|
||||
#[model = "Model<Self>"]
|
||||
#[ts(export)]
|
||||
pub struct Category {
|
||||
pub name: String,
|
||||
pub name: LocaleString,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, HasModel, TS, PartialEq, Eq)]
|
||||
#[derive(Debug, Deserialize, Serialize, HasModel, TS, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[model = "Model<Self>"]
|
||||
#[ts(export)]
|
||||
pub struct DependencyMetadata {
|
||||
#[ts(type = "string | null")]
|
||||
pub title: Option<InternedString>,
|
||||
pub title: Option<LocaleString>,
|
||||
pub icon: Option<DataUrl<'static>>,
|
||||
pub description: Option<String>,
|
||||
pub description: Option<LocaleString>,
|
||||
pub optional: bool,
|
||||
}
|
||||
impl DependencyMetadata {
|
||||
pub fn localize_for(&mut self, locale: &str) {
|
||||
self.title.as_mut().map(|t| t.localize_for(locale));
|
||||
self.description.as_mut().map(|d| d.localize_for(locale));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, HasModel, TS, PartialEq, Eq)]
|
||||
#[derive(Debug, Deserialize, Serialize, HasModel, TS, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[model = "Model<Self>"]
|
||||
pub struct PackageMetadata {
|
||||
@@ -72,7 +77,7 @@ pub struct PackageMetadata {
|
||||
pub title: InternedString,
|
||||
pub icon: DataUrl<'static>,
|
||||
pub description: Description,
|
||||
pub release_notes: String,
|
||||
pub release_notes: LocaleString,
|
||||
pub git_hash: Option<GitHash>,
|
||||
#[ts(type = "string")]
|
||||
pub license: InternedString,
|
||||
@@ -199,20 +204,20 @@ impl PackageVersionInfo {
|
||||
self.s9pks.sort_by_key(|(h, _)| h.specificity_desc());
|
||||
Ok(())
|
||||
}
|
||||
pub fn table(&self, version: &VersionString) -> prettytable::Table {
|
||||
pub fn table(self, version: &VersionString) -> prettytable::Table {
|
||||
use prettytable::*;
|
||||
|
||||
let mut table = Table::new();
|
||||
|
||||
table.add_row(row![bc => &self.metadata.title]);
|
||||
table.add_row(row![br -> "VERSION", AsRef::<str>::as_ref(version)]);
|
||||
table.add_row(row![br -> "RELEASE NOTES", &self.metadata.release_notes]);
|
||||
table.add_row(row![br -> "RELEASE NOTES", &self.metadata.release_notes.localized()]);
|
||||
table.add_row(
|
||||
row![br -> "ABOUT", &textwrap::wrap(&self.metadata.description.short, 80).join("\n")],
|
||||
row![br -> "ABOUT", &textwrap::wrap(&self.metadata.description.short.localized(), 80).join("\n")],
|
||||
);
|
||||
table.add_row(row![
|
||||
br -> "DESCRIPTION",
|
||||
&textwrap::wrap(&self.metadata.description.long, 80).join("\n")
|
||||
&textwrap::wrap(&self.metadata.description.long.localized(), 80).join("\n")
|
||||
]);
|
||||
table.add_row(row![br -> "GIT HASH", self.metadata.git_hash.as_deref().unwrap_or("N/A")]);
|
||||
table.add_row(row![br -> "LICENSE", &self.metadata.license]);
|
||||
@@ -280,6 +285,24 @@ impl Model<PackageVersionInfo> {
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(locale) = device_info.os.language.as_deref() {
|
||||
let metadata = self.as_metadata_mut();
|
||||
metadata
|
||||
.as_alerts_mut()
|
||||
.mutate(|a| Ok(a.localize_for(locale)));
|
||||
metadata
|
||||
.as_dependency_metadata_mut()
|
||||
.as_entries_mut()?
|
||||
.into_iter()
|
||||
.try_for_each(|(_, d)| d.mutate(|d| Ok(d.localize_for(locale))));
|
||||
metadata
|
||||
.as_description_mut()
|
||||
.mutate(|d| Ok(d.localize_for(locale)))?;
|
||||
metadata
|
||||
.as_release_notes_mut()
|
||||
.mutate(|r| Ok(r.localize_for(locale)))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
|
||||
@@ -9,7 +9,7 @@ use tokio::process::Command;
|
||||
|
||||
use crate::dependencies::{DepInfo, Dependencies};
|
||||
use crate::prelude::*;
|
||||
use crate::s9pk::manifest::{DeviceFilter, Manifest};
|
||||
use crate::s9pk::manifest::{DeviceFilter, LocaleString, Manifest};
|
||||
use crate::s9pk::merkle_archive::directory_contents::DirectoryContents;
|
||||
use crate::s9pk::merkle_archive::source::TmpSource;
|
||||
use crate::s9pk::merkle_archive::{Entry, MerkleArchive};
|
||||
@@ -198,7 +198,7 @@ impl TryFrom<ManifestV1> for Manifest {
|
||||
title: format!("{} (Legacy)", value.title).into(),
|
||||
version: version.into(),
|
||||
satisfies: BTreeSet::new(),
|
||||
release_notes: value.release_notes,
|
||||
release_notes: LocaleString::Translated(value.release_notes),
|
||||
can_migrate_from: VersionRange::any(),
|
||||
can_migrate_to: VersionRange::none(),
|
||||
license: value.license.into(),
|
||||
@@ -226,7 +226,7 @@ impl TryFrom<ManifestV1> for Manifest {
|
||||
(
|
||||
id,
|
||||
DepInfo {
|
||||
description: value.description,
|
||||
description: value.description.map(LocaleString::Translated),
|
||||
optional: !value.requirement.required(),
|
||||
metadata: None,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
|
||||
use clap::builder::ValueParserFactory;
|
||||
use color_eyre::eyre::eyre;
|
||||
use exver::{Version, VersionRange};
|
||||
use imbl_value::InternedString;
|
||||
use imbl_value::{InOMap, InternedString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use url::Url;
|
||||
@@ -17,7 +18,7 @@ use crate::s9pk::merkle_archive::expected::{Expected, Filter};
|
||||
use crate::s9pk::v2::pack::ImageConfig;
|
||||
use crate::util::lshw::{LshwDevice, LshwDisplay, LshwProcessor};
|
||||
use crate::util::serde::Regex;
|
||||
use crate::util::{VersionString, mime};
|
||||
use crate::util::{FromStrParser, VersionString, mime};
|
||||
use crate::version::{Current, VersionT};
|
||||
use crate::{ImageId, VolumeId};
|
||||
|
||||
@@ -35,7 +36,7 @@ pub struct Manifest {
|
||||
pub title: InternedString,
|
||||
pub version: VersionString,
|
||||
pub satisfies: BTreeSet<VersionString>,
|
||||
pub release_notes: String,
|
||||
pub release_notes: LocaleString,
|
||||
#[ts(type = "string")]
|
||||
pub can_migrate_to: VersionRange,
|
||||
#[ts(type = "string")]
|
||||
@@ -190,6 +191,118 @@ impl HardwareRequirements {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, TS)]
|
||||
#[ts(type = "string | Record<string, string>")]
|
||||
pub enum LocaleString {
|
||||
Translated(String),
|
||||
LanguageMap(InOMap<InternedString, String>),
|
||||
}
|
||||
impl std::str::FromStr for LocaleString {
|
||||
type Err = std::convert::Infallible;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
// Try JSON parse first (for maps or quoted strings)
|
||||
if let Ok(parsed) = serde_json::from_str::<LocaleString>(s) {
|
||||
return Ok(parsed);
|
||||
}
|
||||
// Fall back to plain string
|
||||
Ok(LocaleString::Translated(s.to_owned()))
|
||||
}
|
||||
}
|
||||
impl LocaleString {
|
||||
pub fn localize_for(&mut self, locale: &str) {
|
||||
if let Self::LanguageMap(map) = self {
|
||||
if let Some(translated) = map.remove(locale) {
|
||||
*self = Self::Translated(translated);
|
||||
return;
|
||||
}
|
||||
let prefix = locale.split_inclusive("_").next().unwrap();
|
||||
let mut first = None;
|
||||
for (lang, translated) in std::mem::take(map) {
|
||||
if lang.starts_with(prefix) {
|
||||
*self = Self::Translated(translated);
|
||||
return;
|
||||
}
|
||||
if first.is_none() {
|
||||
first = Some(translated);
|
||||
}
|
||||
}
|
||||
*self = Self::Translated(first.unwrap_or_default())
|
||||
}
|
||||
}
|
||||
pub fn localized_for(mut self, locale: &str) -> String {
|
||||
self.localize_for(locale);
|
||||
if let Self::Translated(s) = self {
|
||||
s
|
||||
} else {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
pub fn localize(&mut self) {
|
||||
self.localize_for(&*rust_i18n::locale());
|
||||
}
|
||||
pub fn localized(mut self) -> String {
|
||||
self.localized_for(&*rust_i18n::locale())
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for LocaleString {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct LocaleStringVisitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for LocaleStringVisitor {
|
||||
type Value = LocaleString;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a string or a map of language codes to strings")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(LocaleString::Translated(value.to_owned()))
|
||||
}
|
||||
|
||||
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(LocaleString::Translated(value))
|
||||
}
|
||||
|
||||
fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
|
||||
where
|
||||
M: serde::de::MapAccess<'de>,
|
||||
{
|
||||
let language_map =
|
||||
InOMap::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
|
||||
Ok(LocaleString::LanguageMap(language_map))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(LocaleStringVisitor)
|
||||
}
|
||||
}
|
||||
impl Serialize for LocaleString {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
LocaleString::Translated(s) => serializer.serialize_str(s),
|
||||
LocaleString::LanguageMap(map) => map.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ValueParserFactory for LocaleString {
|
||||
type Parser = FromStrParser<Self>;
|
||||
fn value_parser() -> Self::Parser {
|
||||
FromStrParser::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
@@ -294,21 +407,32 @@ impl DeviceFilter {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS, PartialEq)]
|
||||
#[ts(export)]
|
||||
pub struct Description {
|
||||
pub short: String,
|
||||
pub long: String,
|
||||
pub short: LocaleString,
|
||||
pub long: LocaleString,
|
||||
}
|
||||
impl Description {
|
||||
pub fn localize_for(&mut self, locale: &str) {
|
||||
self.short.localize_for(locale);
|
||||
self.long.localize_for(locale);
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
if self.short.chars().skip(160).next().is_some() {
|
||||
if match &self.short {
|
||||
LocaleString::Translated(s) => s.len() > 160,
|
||||
LocaleString::LanguageMap(map) => map.values().any(|s| s.len() > 160),
|
||||
} {
|
||||
return Err(Error::new(
|
||||
eyre!("Short description must be 160 characters or less."),
|
||||
crate::ErrorKind::ValidateS9pk,
|
||||
));
|
||||
}
|
||||
if self.long.chars().skip(5000).next().is_some() {
|
||||
if match &self.short {
|
||||
LocaleString::Translated(s) => s.len() > 5000,
|
||||
LocaleString::LanguageMap(map) => map.values().any(|s| s.len() > 5000),
|
||||
} {
|
||||
return Err(Error::new(
|
||||
eyre!("Long description must be 5000 characters or less."),
|
||||
crate::ErrorKind::ValidateS9pk,
|
||||
@@ -318,13 +442,22 @@ impl Description {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, TS, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, TS, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct Alerts {
|
||||
pub install: Option<String>,
|
||||
pub uninstall: Option<String>,
|
||||
pub restore: Option<String>,
|
||||
pub start: Option<String>,
|
||||
pub stop: Option<String>,
|
||||
pub install: Option<LocaleString>,
|
||||
pub uninstall: Option<LocaleString>,
|
||||
pub restore: Option<LocaleString>,
|
||||
pub start: Option<LocaleString>,
|
||||
pub stop: Option<LocaleString>,
|
||||
}
|
||||
impl Alerts {
|
||||
pub fn localize_for(&mut self, locale: &str) {
|
||||
self.install.as_mut().map(|s| s.localize_for(locale));
|
||||
self.uninstall.as_mut().map(|s| s.localize_for(locale));
|
||||
self.restore.as_mut().map(|s| s.localize_for(locale));
|
||||
self.start.as_mut().map(|s| s.localize_for(locale));
|
||||
self.stop.as_mut().map(|s| s.localize_for(locale));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::prelude::*;
|
||||
use crate::rpc_continuations::Guid;
|
||||
use crate::s9pk::S9pk;
|
||||
use crate::s9pk::git_hash::GitHash;
|
||||
use crate::s9pk::manifest::Manifest;
|
||||
use crate::s9pk::manifest::{LocaleString, Manifest};
|
||||
use crate::s9pk::merkle_archive::directory_contents::DirectoryContents;
|
||||
use crate::s9pk::merkle_archive::source::http::HttpSource;
|
||||
use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile;
|
||||
@@ -756,7 +756,7 @@ pub async fn pack(ctx: CliContext, params: PackParams) -> Result<(), Error> {
|
||||
}
|
||||
};
|
||||
Some((
|
||||
s9pk.as_manifest().title.clone(),
|
||||
LocaleString::Translated(s9pk.as_manifest().title.to_string()),
|
||||
s9pk.icon_data_url().await?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::str::FromStr;
|
||||
|
||||
use clap::builder::ValueParserFactory;
|
||||
use exver::VersionRange;
|
||||
use imbl_value::InternedString;
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::db::model::package::{
|
||||
@@ -149,13 +148,25 @@ impl FromStr for DependencyRequirement {
|
||||
.map(|id| id.parse().map_err(Error::from))
|
||||
.collect(),
|
||||
Some((kind, _)) => Err(Error::new(
|
||||
eyre!("{}", t!("service.effects.dependency.unknown-dependency-kind", kind = kind)),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!(
|
||||
"service.effects.dependency.unknown-dependency-kind",
|
||||
kind = kind
|
||||
)
|
||||
),
|
||||
ErrorKind::InvalidRequest,
|
||||
)),
|
||||
None => match rest {
|
||||
"r" | "running" => Ok(BTreeSet::new()),
|
||||
kind => Err(Error::new(
|
||||
eyre!("{}", t!("service.effects.dependency.unknown-dependency-kind", kind = kind)),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!(
|
||||
"service.effects.dependency.unknown-dependency-kind",
|
||||
kind = kind
|
||||
)
|
||||
),
|
||||
ErrorKind::InvalidRequest,
|
||||
)),
|
||||
},
|
||||
@@ -217,7 +228,7 @@ pub async fn set_dependencies(
|
||||
.s9pk
|
||||
.dependency_metadata(&dep_id)
|
||||
.await?
|
||||
.map(|m| m.title),
|
||||
.map(|m| m.title.localized()),
|
||||
icon: context
|
||||
.seed
|
||||
.persistent_container
|
||||
@@ -294,8 +305,7 @@ pub struct CheckDependenciesParam {
|
||||
#[ts(export)]
|
||||
pub struct CheckDependenciesResult {
|
||||
package_id: PackageId,
|
||||
#[ts(type = "string | null")]
|
||||
title: Option<InternedString>,
|
||||
title: Option<String>,
|
||||
installed_version: Option<VersionString>,
|
||||
satisfies: BTreeSet<VersionString>,
|
||||
is_running: bool,
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::account::AccountInfo;
|
||||
use crate::auth::write_shadow;
|
||||
use crate::backup::restore::recover_full_server;
|
||||
use crate::backup::target::BackupTargetFS;
|
||||
use crate::bins::set_locale;
|
||||
use crate::context::rpc::InitRpcContextPhases;
|
||||
use crate::context::setup::SetupResult;
|
||||
use crate::context::{RpcContext, SetupContext};
|
||||
@@ -36,11 +37,11 @@ use crate::prelude::*;
|
||||
use crate::progress::{FullProgress, PhaseProgressTrackerHandle, ProgressUnits};
|
||||
use crate::rpc_continuations::Guid;
|
||||
use crate::shutdown::Shutdown;
|
||||
use crate::system::sync_kiosk;
|
||||
use crate::system::{KeyboardOptions, SetLanguageParams, save_language, sync_kiosk};
|
||||
use crate::util::Invoke;
|
||||
use crate::util::crypto::EncryptedWire;
|
||||
use crate::util::io::{Counter, create_file, dir_copy, dir_size, read_file_to_string};
|
||||
use crate::util::serde::IoFormat;
|
||||
use crate::util::serde::{IoFormat, Pem};
|
||||
use crate::{DATA_DIR, Error, ErrorKind, MAIN_DATA, PACKAGE_DATA, PLATFORM, ResultExt};
|
||||
|
||||
pub fn setup<C: Context>() -> ParentHandler<C> {
|
||||
@@ -75,6 +76,9 @@ pub fn setup<C: Context>() -> ParentHandler<C> {
|
||||
.with_about("about.display-os-logs"),
|
||||
)
|
||||
.subcommand("restart", from_fn_async(restart).no_cli())
|
||||
.subcommand("shutdown", from_fn_async(shutdown).no_cli())
|
||||
.subcommand("set-language", from_fn_async(set_language).no_cli())
|
||||
.subcommand("set-keyboard", from_fn_async(set_keyboard).no_cli())
|
||||
}
|
||||
|
||||
pub fn disk<C: Context>() -> ParentHandler<C> {
|
||||
@@ -103,6 +107,8 @@ async fn setup_init(
|
||||
init_phases: InitPhases,
|
||||
) -> Result<(AccountInfo, InitResult), Error> {
|
||||
let init_result = init(&ctx.webserver, &ctx.config.peek(|c| c.clone()), init_phases).await?;
|
||||
let language = ctx.language.peek(|a| a.clone());
|
||||
let keyboard = ctx.keyboard.peek(|a| a.clone());
|
||||
|
||||
let account = init_result
|
||||
.net_ctrl
|
||||
@@ -118,6 +124,12 @@ async fn setup_init(
|
||||
if let Some(kiosk) = kiosk {
|
||||
info.as_kiosk_mut().ser(&Some(kiosk))?;
|
||||
}
|
||||
if let Some(language) = language.clone() {
|
||||
info.as_language_mut().ser(&Some(language))?;
|
||||
}
|
||||
if let Some(keyboard) = keyboard.clone() {
|
||||
info.as_keyboard_mut().ser(&Some(keyboard))?;
|
||||
}
|
||||
|
||||
Ok(account)
|
||||
})
|
||||
@@ -126,6 +138,13 @@ async fn setup_init(
|
||||
|
||||
sync_kiosk(kiosk).await?;
|
||||
|
||||
if let Some(language) = language {
|
||||
save_language(&*language).await?;
|
||||
}
|
||||
if let Some(keyboard) = keyboard {
|
||||
keyboard.save().await?;
|
||||
}
|
||||
|
||||
if let Some(password) = &password {
|
||||
write_shadow(&password).await?;
|
||||
}
|
||||
@@ -137,7 +156,6 @@ async fn setup_init(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct AttachParams {
|
||||
#[serde(rename = "startOsPassword")]
|
||||
pub password: Option<EncryptedWire>,
|
||||
pub guid: InternedString,
|
||||
#[ts(optional)]
|
||||
@@ -155,63 +173,81 @@ pub async fn attach(
|
||||
) -> Result<SetupProgress, Error> {
|
||||
let setup_ctx = ctx.clone();
|
||||
ctx.run_setup(move || async move {
|
||||
let progress = &setup_ctx.progress;
|
||||
let mut disk_phase = progress.add_phase(t!("setup.opening-data-drive").into(), Some(10));
|
||||
let init_phases = InitPhases::new(&progress);
|
||||
let rpc_ctx_phases = InitRpcContextPhases::new(&progress);
|
||||
let progress = &setup_ctx.progress;
|
||||
let mut disk_phase = progress.add_phase(t!("setup.opening-data-drive").into(), Some(10));
|
||||
let init_phases = InitPhases::new(&progress);
|
||||
let rpc_ctx_phases = InitRpcContextPhases::new(&progress);
|
||||
|
||||
let password: Option<String> = match password {
|
||||
Some(a) => match a.decrypt(&setup_ctx) {
|
||||
a @ Some(_) => a,
|
||||
None => {
|
||||
return Err(Error::new(
|
||||
color_eyre::eyre::eyre!("{}", t!("setup.couldnt-decode-password")),
|
||||
crate::ErrorKind::Unknown,
|
||||
));
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let password: Option<String> = match password {
|
||||
Some(a) => match a.decrypt(&setup_ctx) {
|
||||
a @ Some(_) => a,
|
||||
None => {
|
||||
return Err(Error::new(
|
||||
color_eyre::eyre::eyre!("{}", t!("setup.couldnt-decode-password")),
|
||||
crate::ErrorKind::Unknown,
|
||||
));
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
disk_phase.start();
|
||||
let requires_reboot = crate::disk::main::import(
|
||||
&*disk_guid,
|
||||
DATA_DIR,
|
||||
if tokio::fs::metadata(REPAIR_DISK_PATH).await.is_ok() {
|
||||
RepairStrategy::Aggressive
|
||||
} else {
|
||||
RepairStrategy::Preen
|
||||
},
|
||||
if disk_guid.ends_with("_UNENC") { None } else { Some(DEFAULT_PASSWORD) },
|
||||
)
|
||||
.await?;
|
||||
let _ = setup_ctx.disk_guid.set(disk_guid.clone());
|
||||
disk_phase.start();
|
||||
let requires_reboot = crate::disk::main::import(
|
||||
&*disk_guid,
|
||||
DATA_DIR,
|
||||
if tokio::fs::metadata(REPAIR_DISK_PATH).await.is_ok() {
|
||||
tokio::fs::remove_file(REPAIR_DISK_PATH)
|
||||
.await
|
||||
.with_ctx(|_| (ErrorKind::Filesystem, REPAIR_DISK_PATH))?;
|
||||
}
|
||||
if requires_reboot.0 {
|
||||
crate::disk::main::export(&*disk_guid, DATA_DIR).await?;
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("setup.disk-errors-corrected-restart-required")),
|
||||
ErrorKind::DiskManagement,
|
||||
));
|
||||
}
|
||||
disk_phase.complete();
|
||||
RepairStrategy::Aggressive
|
||||
} else {
|
||||
RepairStrategy::Preen
|
||||
},
|
||||
if disk_guid.ends_with("_UNENC") {
|
||||
None
|
||||
} else {
|
||||
Some(DEFAULT_PASSWORD)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let _ = setup_ctx.disk_guid.set(disk_guid.clone());
|
||||
if tokio::fs::metadata(REPAIR_DISK_PATH).await.is_ok() {
|
||||
tokio::fs::remove_file(REPAIR_DISK_PATH)
|
||||
.await
|
||||
.with_ctx(|_| (ErrorKind::Filesystem, REPAIR_DISK_PATH))?;
|
||||
}
|
||||
if requires_reboot.0 {
|
||||
crate::disk::main::export(&*disk_guid, DATA_DIR).await?;
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("setup.disk-errors-corrected-restart-required")),
|
||||
ErrorKind::DiskManagement,
|
||||
));
|
||||
}
|
||||
disk_phase.complete();
|
||||
|
||||
let (account, net_ctrl) = setup_init(&setup_ctx, password, kiosk, init_phases).await?;
|
||||
let (account, net_ctrl) = setup_init(&setup_ctx, password, kiosk, init_phases).await?;
|
||||
|
||||
let rpc_ctx = RpcContext::init(&setup_ctx.webserver, &setup_ctx.config.peek(|c| c.clone()), disk_guid, Some(net_ctrl), rpc_ctx_phases).await?;
|
||||
let rpc_ctx = RpcContext::init(
|
||||
&setup_ctx.webserver,
|
||||
&setup_ctx.config.peek(|c| c.clone()),
|
||||
disk_guid,
|
||||
Some(net_ctrl),
|
||||
rpc_ctx_phases,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(((&account).try_into()?, rpc_ctx))
|
||||
})?;
|
||||
Ok((
|
||||
SetupResult {
|
||||
hostname: account.hostname,
|
||||
root_ca: Pem(account.root_ca_cert),
|
||||
needs_restart: setup_ctx.install_rootfs.peek(|a| a.is_some()),
|
||||
},
|
||||
rpc_ctx,
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(ctx.progress().await)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[ts(export)]
|
||||
#[serde(tag = "status")]
|
||||
pub enum SetupStatusRes {
|
||||
@@ -246,17 +282,10 @@ pub async fn status(ctx: SetupContext) -> Result<SetupStatusRes, Error> {
|
||||
if ctx.task.initialized() {
|
||||
Ok(SetupStatusRes::Running(ctx.progress().await))
|
||||
} else {
|
||||
let path = if tokio::fs::metadata("/run/live/medium").await.is_ok() {
|
||||
let Some(path) = ctx
|
||||
.install_rootfs
|
||||
.peek(|fs| fs.as_ref().map(|fs| fs.path().join("config/setup.json")))
|
||||
else {
|
||||
return Ok(SetupStatusRes::NeedsInstall);
|
||||
};
|
||||
path
|
||||
} else {
|
||||
Path::new("/media/startos/config/setup.json").to_path_buf()
|
||||
};
|
||||
let path = Path::new("/media/startos/config/setup.json");
|
||||
if tokio::fs::metadata(path).await.is_err() {
|
||||
return Ok(SetupStatusRes::NeedsInstall);
|
||||
}
|
||||
IoFormat::Json
|
||||
.from_slice(read_file_to_string(path).await?.as_bytes())
|
||||
.map(SetupStatusRes::Incomplete)
|
||||
@@ -361,8 +390,8 @@ pub async fn setup_data_drive(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct SetupExecuteParams {
|
||||
start_os_logicalname: PathBuf,
|
||||
start_os_password: EncryptedWire,
|
||||
guid: InternedString,
|
||||
password: EncryptedWire,
|
||||
recovery_source: Option<RecoverySource<EncryptedWire>>,
|
||||
#[ts(optional)]
|
||||
kiosk: Option<bool>,
|
||||
@@ -372,13 +401,13 @@ pub struct SetupExecuteParams {
|
||||
pub async fn execute(
|
||||
ctx: SetupContext,
|
||||
SetupExecuteParams {
|
||||
start_os_logicalname,
|
||||
start_os_password,
|
||||
guid,
|
||||
password,
|
||||
recovery_source,
|
||||
kiosk,
|
||||
}: SetupExecuteParams,
|
||||
) -> Result<SetupProgress, Error> {
|
||||
let start_os_password = match start_os_password.decrypt(&ctx) {
|
||||
let password = match password.decrypt(&ctx) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
return Err(Error::new(
|
||||
@@ -407,15 +436,7 @@ pub async fn execute(
|
||||
};
|
||||
|
||||
let setup_ctx = ctx.clone();
|
||||
ctx.run_setup(move || {
|
||||
execute_inner(
|
||||
setup_ctx,
|
||||
start_os_logicalname,
|
||||
start_os_password,
|
||||
recovery,
|
||||
kiosk,
|
||||
)
|
||||
})?;
|
||||
ctx.run_setup(move || execute_inner(setup_ctx, guid, password, recovery, kiosk))?;
|
||||
|
||||
Ok(ctx.progress().await)
|
||||
}
|
||||
@@ -447,12 +468,27 @@ pub async fn complete(ctx: SetupContext) -> Result<SetupResult, Error> {
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn exit(ctx: SetupContext) -> Result<(), Error> {
|
||||
ctx.shutdown.send(None).expect("failed to shutdown");
|
||||
let shutdown = if let Some((rootfs, config)) = ctx.install_rootfs.replace(None) {
|
||||
config.unmount(false).await?;
|
||||
rootfs.unmount().await?;
|
||||
Some(Shutdown {
|
||||
disk_guid: ctx.disk_guid.get().cloned(),
|
||||
restart: true,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ctx.shutdown.send(shutdown).expect("failed to shutdown");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn restart(ctx: SetupContext) -> Result<(), Error> {
|
||||
if let Some((rootfs, config)) = ctx.install_rootfs.replace(None) {
|
||||
config.unmount(false).await?;
|
||||
rootfs.unmount().await?;
|
||||
}
|
||||
ctx.shutdown
|
||||
.send(Some(Shutdown {
|
||||
disk_guid: ctx.disk_guid.get().cloned(),
|
||||
@@ -462,16 +498,30 @@ pub async fn restart(ctx: SetupContext) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn shutdown(ctx: SetupContext) -> Result<(), Error> {
|
||||
if let Some((rootfs, config)) = ctx.install_rootfs.replace(None) {
|
||||
config.unmount(false).await?;
|
||||
rootfs.unmount().await?;
|
||||
}
|
||||
ctx.shutdown
|
||||
.send(Some(Shutdown {
|
||||
disk_guid: ctx.disk_guid.get().cloned(),
|
||||
restart: false,
|
||||
}))
|
||||
.expect("failed to shutdown");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn execute_inner(
|
||||
ctx: SetupContext,
|
||||
start_os_logicalname: PathBuf,
|
||||
start_os_password: String,
|
||||
guid: InternedString,
|
||||
password: String,
|
||||
recovery_source: Option<RecoverySource<String>>,
|
||||
kiosk: Option<bool>,
|
||||
) -> Result<(SetupResult, RpcContext), Error> {
|
||||
let progress = &ctx.progress;
|
||||
let mut disk_phase = progress.add_phase(t!("setup.formatting-data-drive").into(), Some(10));
|
||||
let restore_phase = match recovery_source.as_ref() {
|
||||
Some(RecoverySource::Backup { .. }) => {
|
||||
Some(progress.add_phase(t!("setup.restoring-backup").into(), Some(100)))
|
||||
@@ -484,10 +534,6 @@ pub async fn execute_inner(
|
||||
let init_phases = InitPhases::new(&progress);
|
||||
let rpc_ctx_phases = InitRpcContextPhases::new(&progress);
|
||||
|
||||
disk_phase.start();
|
||||
let guid = setup_data_drive(&ctx, &start_os_logicalname).await?;
|
||||
disk_phase.complete();
|
||||
|
||||
let progress = SetupExecuteProgress {
|
||||
init_phases,
|
||||
restore_phase,
|
||||
@@ -497,25 +543,25 @@ pub async fn execute_inner(
|
||||
match recovery_source {
|
||||
Some(RecoverySource::Backup {
|
||||
target,
|
||||
password,
|
||||
password: recovery_password,
|
||||
server_id,
|
||||
}) => {
|
||||
recover(
|
||||
&ctx,
|
||||
guid,
|
||||
start_os_password,
|
||||
password,
|
||||
target,
|
||||
server_id,
|
||||
password,
|
||||
recovery_password,
|
||||
kiosk,
|
||||
progress,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some(RecoverySource::Migrate { guid: old_guid }) => {
|
||||
migrate(&ctx, guid, &old_guid, start_os_password, kiosk, progress).await
|
||||
migrate(&ctx, guid, &old_guid, password, kiosk, progress).await
|
||||
}
|
||||
None => fresh_setup(&ctx, guid, &start_os_password, kiosk, progress).await,
|
||||
None => fresh_setup(&ctx, guid, &password, kiosk, progress).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,7 +574,7 @@ pub struct SetupExecuteProgress {
|
||||
async fn fresh_setup(
|
||||
ctx: &SetupContext,
|
||||
guid: InternedString,
|
||||
start_os_password: &str,
|
||||
password: &str,
|
||||
kiosk: Option<bool>,
|
||||
SetupExecuteProgress {
|
||||
init_phases,
|
||||
@@ -536,11 +582,24 @@ async fn fresh_setup(
|
||||
..
|
||||
}: SetupExecuteProgress,
|
||||
) -> Result<(SetupResult, RpcContext), Error> {
|
||||
let account = AccountInfo::new(start_os_password, root_ca_start_time().await)?;
|
||||
let account = AccountInfo::new(password, root_ca_start_time().await)?;
|
||||
let db = ctx.db().await?;
|
||||
let kiosk = Some(kiosk.unwrap_or(true)).filter(|_| &*PLATFORM != "raspberrypi");
|
||||
sync_kiosk(kiosk).await?;
|
||||
db.put(&ROOT, &Database::init(&account, kiosk)?).await?;
|
||||
|
||||
let language = ctx.language.peek(|a| a.clone());
|
||||
let keyboard = ctx.keyboard.peek(|a| a.clone());
|
||||
|
||||
if let Some(language) = &language {
|
||||
save_language(&**language).await?;
|
||||
}
|
||||
|
||||
if let Some(keyboard) = &keyboard {
|
||||
keyboard.save().await?;
|
||||
}
|
||||
|
||||
db.put(&ROOT, &Database::init(&account, kiosk, language, keyboard)?)
|
||||
.await?;
|
||||
drop(db);
|
||||
|
||||
let config = ctx.config.peek(|c| c.clone());
|
||||
@@ -556,16 +615,23 @@ async fn fresh_setup(
|
||||
)
|
||||
.await?;
|
||||
|
||||
write_shadow(start_os_password).await?;
|
||||
write_shadow(password).await?;
|
||||
|
||||
Ok(((&account).try_into()?, rpc_ctx))
|
||||
Ok((
|
||||
SetupResult {
|
||||
hostname: account.hostname,
|
||||
root_ca: Pem(account.root_ca_cert),
|
||||
needs_restart: ctx.install_rootfs.peek(|a| a.is_some()),
|
||||
},
|
||||
rpc_ctx,
|
||||
))
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn recover(
|
||||
ctx: &SetupContext,
|
||||
guid: InternedString,
|
||||
start_os_password: String,
|
||||
password: String,
|
||||
recovery_source: BackupTargetFS,
|
||||
server_id: String,
|
||||
recovery_password: String,
|
||||
@@ -576,7 +642,7 @@ async fn recover(
|
||||
recover_full_server(
|
||||
ctx,
|
||||
guid.clone(),
|
||||
start_os_password,
|
||||
password,
|
||||
recovery_source,
|
||||
&server_id,
|
||||
&recovery_password,
|
||||
@@ -591,7 +657,7 @@ async fn migrate(
|
||||
ctx: &SetupContext,
|
||||
guid: InternedString,
|
||||
old_guid: &str,
|
||||
start_os_password: String,
|
||||
password: String,
|
||||
kiosk: Option<bool>,
|
||||
SetupExecuteProgress {
|
||||
init_phases,
|
||||
@@ -671,7 +737,7 @@ async fn migrate(
|
||||
crate::disk::main::export(&old_guid, "/media/startos/migrate").await?;
|
||||
restore_phase.complete();
|
||||
|
||||
let (account, net_ctrl) = setup_init(&ctx, Some(start_os_password), kiosk, init_phases).await?;
|
||||
let (account, net_ctrl) = setup_init(&ctx, Some(password), kiosk, init_phases).await?;
|
||||
|
||||
let rpc_ctx = RpcContext::init(
|
||||
&ctx.webserver,
|
||||
@@ -682,5 +748,27 @@ async fn migrate(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(((&account).try_into()?, rpc_ctx))
|
||||
Ok((
|
||||
SetupResult {
|
||||
hostname: account.hostname,
|
||||
root_ca: Pem(account.root_ca_cert),
|
||||
needs_restart: ctx.install_rootfs.peek(|a| a.is_some()),
|
||||
},
|
||||
rpc_ctx,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn set_language(
|
||||
ctx: SetupContext,
|
||||
SetLanguageParams { language }: SetLanguageParams,
|
||||
) -> Result<(), Error> {
|
||||
set_locale(&*language);
|
||||
ctx.language.replace(Some(language));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_keyboard(ctx: SetupContext, options: KeyboardOptions) -> Result<(), Error> {
|
||||
options.apply_to_session().await?;
|
||||
ctx.keyboard.replace(Some(options));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use tokio::sync::broadcast::Receiver;
|
||||
use tracing::instrument;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::bins::set_locale;
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
use crate::disk::util::{get_available, get_used};
|
||||
use crate::logs::{LogSource, LogsParams, SYSTEM_UNIT};
|
||||
@@ -1140,9 +1141,11 @@ pub async fn test_smtp(
|
||||
pub struct KeyboardOptions {
|
||||
#[arg(help = "help.arg.keyboard-layout")]
|
||||
pub layout: InternedString,
|
||||
#[arg(long, help = "help.arg.keyboard-model")]
|
||||
#[arg(short, long, help = "help.arg.keyboard-keymap")]
|
||||
pub keymap: Option<InternedString>,
|
||||
#[arg(short, long, help = "help.arg.keyboard-model")]
|
||||
pub model: Option<InternedString>,
|
||||
#[arg(long, help = "help.arg.keyboard-variant")]
|
||||
#[arg(short, long, help = "help.arg.keyboard-variant")]
|
||||
pub variant: Option<InternedString>,
|
||||
#[arg(short, long = "option", help = "help.arg.keyboard-option")]
|
||||
#[serde(default)]
|
||||
@@ -1166,7 +1169,18 @@ impl KeyboardOptions {
|
||||
}
|
||||
|
||||
pub async fn save(&self) -> Result<(), Error> {
|
||||
// TODO: set console keyboard
|
||||
write_file_atomic(
|
||||
"/media/startos/config/overlay/etc/vconsole.conf",
|
||||
format!(
|
||||
include_str!("./vconsole.conf.template"),
|
||||
model = self.model.as_deref().unwrap_or_default(),
|
||||
layout = &*self.layout,
|
||||
variant = self.variant.as_deref().unwrap_or_default(),
|
||||
options = self.options.join(","),
|
||||
keymap = self.keymap.as_deref().unwrap_or(&*self.layout),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
write_file_atomic(
|
||||
"/media/startos/config/overlay/etc/X11/xorg.conf.d/00-keyboard.conf",
|
||||
format!(
|
||||
@@ -1203,10 +1217,7 @@ pub struct SetLanguageParams {
|
||||
pub language: InternedString,
|
||||
}
|
||||
|
||||
pub async fn set_language(
|
||||
ctx: RpcContext,
|
||||
SetLanguageParams { language }: SetLanguageParams,
|
||||
) -> Result<(), Error> {
|
||||
pub async fn save_language(language: &str) -> Result<(), Error> {
|
||||
write_file_atomic(
|
||||
"/etc/locale.gen",
|
||||
format!("{language}.UTF-8 UTF-8\n").as_bytes(),
|
||||
@@ -1225,6 +1236,15 @@ pub async fn set_language(
|
||||
format!("LANG={language}.UTF-8\n").as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_language(
|
||||
ctx: RpcContext,
|
||||
SetLanguageParams { language }: SetLanguageParams,
|
||||
) -> Result<(), Error> {
|
||||
set_locale(&*language);
|
||||
save_language(&*language).await?;
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
db.as_public_mut()
|
||||
@@ -1234,7 +1254,6 @@ pub async fn set_language(
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
rust_i18n::set_locale(&*language);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
7
core/src/system/vconsole.conf.template
Normal file
7
core/src/system/vconsole.conf.template
Normal file
@@ -0,0 +1,7 @@
|
||||
XKBMODEL="{model}"
|
||||
XKBLAYOUT="{layout}"
|
||||
XKBVARIANT="{variant}"
|
||||
XKBOPTIONS="{options}"
|
||||
|
||||
KEYMAP="{keymap}"
|
||||
|
||||
@@ -25,6 +25,7 @@ impl LshwDevice {
|
||||
Self::Display(_) => "display",
|
||||
}
|
||||
}
|
||||
#[instrument(skip_all)]
|
||||
pub fn from_value(value: &Value) -> Option<Self> {
|
||||
match value["class"].as_str() {
|
||||
Some("processor") => Some(LshwDevice::Processor(LshwProcessor::from_value(value))),
|
||||
@@ -41,6 +42,7 @@ pub struct LshwProcessor {
|
||||
pub capabilities: BTreeSet<InternedString>,
|
||||
}
|
||||
impl LshwProcessor {
|
||||
#[instrument(skip_all)]
|
||||
fn from_value(value: &Value) -> Self {
|
||||
Self {
|
||||
product: value["product"].as_str().map(From::from),
|
||||
@@ -63,6 +65,7 @@ pub struct LshwDisplay {
|
||||
pub driver: Option<InternedString>,
|
||||
}
|
||||
impl LshwDisplay {
|
||||
#[instrument(skip_all)]
|
||||
fn from_value(value: &Value) -> Self {
|
||||
Self {
|
||||
product: value["product"].as_str().map(From::from),
|
||||
|
||||
Reference in New Issue
Block a user