mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 12:33:40 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f66ccec17 | ||
|
|
905aaafa2b | ||
|
|
12ca3e0aea | ||
|
|
f523a68e72 | ||
|
|
6ac87a51e4 | ||
|
|
3b930060a8 | ||
|
|
04f1511b52 | ||
|
|
01f14061ec | ||
|
|
e79b27e0bb | ||
|
|
8bc1ef415f | ||
|
|
c49fe9744e | ||
|
|
604d0ae052 | ||
|
|
4960aeedad | ||
|
|
1e8aa569b3 | ||
|
|
eeb557860e | ||
|
|
fdf473016b | ||
|
|
e53bf81cbc | ||
|
|
8ef1584a4d | ||
|
|
c9676ff018 | ||
|
|
e13fab7d9f | ||
|
|
a182b0c260 | ||
|
|
f3a30e40fe | ||
|
|
e7f4aefb72 | ||
|
|
476b9a3c9c | ||
|
|
659af734eb | ||
|
|
39a2685506 | ||
|
|
5e0b83fa4a | ||
|
|
7ea3aefdd5 | ||
|
|
8b286431e6 | ||
|
|
c640749c7c | ||
|
|
cb5bb34ed8 | ||
|
|
90d72fb0d4 | ||
|
|
20b3ab98df | ||
|
|
227b7a03d7 | ||
|
|
8942c29229 | ||
|
|
72cb451f5a | ||
|
|
8a7181a21c |
2
Makefile
2
Makefile
@@ -4,7 +4,7 @@ EMBASSY_SRC := raspios.img product_key.txt $(EMBASSY_BINS) backend/embassyd.serv
|
||||
COMPAT_SRC := $(shell find system-images/compat/src)
|
||||
UTILS_SRC := $(shell find system-images/utils/Dockerfile)
|
||||
BACKEND_SRC := $(shell find backend/src) $(shell find patch-db/*/src) $(shell find rpc-toolkit/*/src) backend/Cargo.toml backend/Cargo.lock
|
||||
FRONTEND_SRC := $(shell find frontend/projects) $(shell find frontend/styles) $(shell find frontend/assets)
|
||||
FRONTEND_SRC := $(shell find frontend/projects) $(shell find frontend/assets)
|
||||
PATCH_DB_CLIENT_SRC = $(shell find patch-db/client -not -path patch-db/client/dist)
|
||||
GIT_REFS := $(shell find .git/refs/heads)
|
||||
TMP_FILE := $(shell mktemp)
|
||||
|
||||
1229
backend/Cargo.lock
generated
1229
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ keywords = [
|
||||
name = "embassy-os"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/Start9Labs/embassy-os"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-rev.1"
|
||||
|
||||
[lib]
|
||||
name = "embassy"
|
||||
@@ -51,6 +51,7 @@ avahi-sys = { git = "https://github.com/Start9Labs/avahi-sys", version = "0.10.0
|
||||
"dynamic",
|
||||
], optional = true }
|
||||
base32 = "0.4.0"
|
||||
base64 = "0.13.0"
|
||||
basic-cookies = "0.1.4"
|
||||
bollard = "0.11.0"
|
||||
chrono = { version = "0.4.19", features = ["serde"] }
|
||||
@@ -107,7 +108,7 @@ serde_toml = { package = "toml", version = "0.5.8" }
|
||||
serde_yaml = "0.8.21"
|
||||
sha2 = "0.9.8"
|
||||
simple-logging = "2.0"
|
||||
sqlx = { version = "0.5", features = [
|
||||
sqlx = { version = "0.5.11", features = [
|
||||
"chrono",
|
||||
"offline",
|
||||
"runtime-tokio-rustls",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[Unit]
|
||||
Description=Embassy Init
|
||||
After=network.target ntp.service
|
||||
After=network.target
|
||||
Requires=network.target
|
||||
Wants=avahi-daemon.service nginx.service tor.service ntp.service
|
||||
Wants=avahi-daemon.service nginx.service tor.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
@@ -4,7 +4,9 @@ use std::time::Duration;
|
||||
|
||||
use embassy::context::rpc::RpcContextConfig;
|
||||
use embassy::context::{DiagnosticContext, SetupContext};
|
||||
use embassy::disk::fsck::RepairStrategy;
|
||||
use embassy::disk::main::DEFAULT_PASSWORD;
|
||||
use embassy::disk::REPAIR_DISK_PATH;
|
||||
use embassy::hostname::get_product_key;
|
||||
use embassy::middleware::cors::cors;
|
||||
use embassy::middleware::diagnostic::diagnostic;
|
||||
@@ -74,14 +76,31 @@ async fn setup_or_init(cfg_path: Option<&str>) -> Result<(), Error> {
|
||||
.with_kind(embassy::ErrorKind::Network)?;
|
||||
} else {
|
||||
let cfg = RpcContextConfig::load(cfg_path).await?;
|
||||
embassy::disk::main::import(
|
||||
tokio::fs::read_to_string("/embassy-os/disk.guid") // unique identifier for volume group - keeps track of the disk that goes with your embassy
|
||||
.await?
|
||||
.trim(),
|
||||
let guid_string = tokio::fs::read_to_string("/embassy-os/disk.guid") // unique identifier for volume group - keeps track of the disk that goes with your embassy
|
||||
.await?;
|
||||
let guid = guid_string.trim();
|
||||
let reboot = embassy::disk::main::import(
|
||||
guid,
|
||||
cfg.datadir(),
|
||||
if tokio::fs::metadata(REPAIR_DISK_PATH).await.is_ok() {
|
||||
RepairStrategy::Aggressive
|
||||
} else {
|
||||
RepairStrategy::Preen
|
||||
},
|
||||
DEFAULT_PASSWORD,
|
||||
)
|
||||
.await?;
|
||||
if tokio::fs::metadata(REPAIR_DISK_PATH).await.is_ok() {
|
||||
tokio::fs::remove_file(REPAIR_DISK_PATH)
|
||||
.await
|
||||
.with_ctx(|_| (embassy::ErrorKind::Filesystem, REPAIR_DISK_PATH))?;
|
||||
}
|
||||
if reboot.0 {
|
||||
embassy::disk::main::export(guid, cfg.datadir()).await?;
|
||||
Command::new("reboot")
|
||||
.invoke(embassy::ErrorKind::Unknown)
|
||||
.await?;
|
||||
}
|
||||
tracing::info!("Loaded Disk");
|
||||
embassy::init::init(&cfg, &get_product_key().await?).await?;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@ use std::collections::{BTreeMap, VecDeque};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::Docker;
|
||||
use chrono::Utc;
|
||||
use color_eyre::eyre::eyre;
|
||||
use patch_db::json_ptr::JsonPointer;
|
||||
use patch_db::{DbHandle, LockType, PatchDb, Revision};
|
||||
@@ -35,7 +34,7 @@ use crate::notifications::NotificationManager;
|
||||
use crate::setup::password_hash;
|
||||
use crate::shutdown::Shutdown;
|
||||
use crate::status::{MainStatus, Status};
|
||||
use crate::util::io::from_toml_async_reader;
|
||||
use crate::util::io::from_yaml_async_reader;
|
||||
use crate::util::{AsyncFileExt, Invoke};
|
||||
use crate::{Error, ResultExt};
|
||||
|
||||
@@ -61,7 +60,7 @@ impl RpcContextConfig {
|
||||
.await
|
||||
.with_ctx(|_| (crate::ErrorKind::Filesystem, cfg_path.display().to_string()))?
|
||||
{
|
||||
from_toml_async_reader(f).await
|
||||
from_yaml_async_reader(f).await
|
||||
} else {
|
||||
Ok(Self::default())
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::db::model::Database;
|
||||
use crate::hostname::{derive_hostname, derive_id, get_product_key};
|
||||
use crate::net::tor::os_key;
|
||||
use crate::setup::{password_hash, RecoveryStatus};
|
||||
use crate::util::io::from_toml_async_reader;
|
||||
use crate::util::io::from_yaml_async_reader;
|
||||
use crate::util::AsyncFileExt;
|
||||
use crate::{Error, ResultExt};
|
||||
|
||||
@@ -50,7 +50,7 @@ impl SetupContextConfig {
|
||||
.await
|
||||
.with_ctx(|_| (crate::ErrorKind::Filesystem, cfg_path.display().to_string()))?
|
||||
{
|
||||
from_toml_async_reader(f).await
|
||||
from_yaml_async_reader(f).await
|
||||
} else {
|
||||
Ok(Self::default())
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use rpc_toolkit::command;
|
||||
use rpc_toolkit::yajrc::RpcError;
|
||||
|
||||
use crate::context::DiagnosticContext;
|
||||
use crate::disk::repair;
|
||||
use crate::logs::{display_logs, fetch_logs, LogResponse, LogSource};
|
||||
use crate::shutdown::Shutdown;
|
||||
use crate::util::display_none;
|
||||
@@ -12,7 +13,7 @@ use crate::Error;
|
||||
|
||||
pub const SYSTEMD_UNIT: &'static str = "embassy-init";
|
||||
|
||||
#[command(subcommands(error, logs, exit, restart, forget_disk))]
|
||||
#[command(subcommands(error, logs, exit, restart, forget_disk, disk))]
|
||||
pub fn diagnostic() -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -56,7 +57,12 @@ pub fn restart(#[context] ctx: DiagnosticContext) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command(rename = "forget-disk", display(display_none))]
|
||||
#[command(subcommands(forget_disk, repair))]
|
||||
pub fn disk() -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command(rename = "forget", display(display_none))]
|
||||
pub async fn forget_disk() -> Result<(), Error> {
|
||||
let disk_guid = Path::new("/embassy-os/disk.guid");
|
||||
if tokio::fs::metadata(disk_guid).await.is_ok() {
|
||||
|
||||
120
backend/src/disk/fsck.rs
Normal file
120
backend/src/disk/fsck.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
|
||||
use color_eyre::eyre::eyre;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use tokio::process::Command;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{Error, ResultExt};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[must_use]
|
||||
pub struct RequiresReboot(pub bool);
|
||||
impl std::ops::BitOrAssign for RequiresReboot {
|
||||
fn bitor_assign(&mut self, rhs: Self) {
|
||||
self.0 |= rhs.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum RepairStrategy {
|
||||
Preen,
|
||||
Aggressive,
|
||||
}
|
||||
impl RepairStrategy {
|
||||
pub async fn e2fsck(
|
||||
&self,
|
||||
logicalname: impl AsRef<Path> + std::fmt::Debug,
|
||||
) -> Result<RequiresReboot, Error> {
|
||||
match self {
|
||||
RepairStrategy::Preen => e2fsck_preen(logicalname).await,
|
||||
RepairStrategy::Aggressive => e2fsck_aggressive(logicalname).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
pub async fn e2fsck_preen(
|
||||
logicalname: impl AsRef<Path> + std::fmt::Debug,
|
||||
) -> Result<RequiresReboot, Error> {
|
||||
e2fsck_runner(Command::new("e2fsck").arg("-p"), logicalname).await
|
||||
}
|
||||
|
||||
fn backup_existing_undo_file<'a>(path: &'a Path) -> BoxFuture<'a, Result<(), Error>> {
|
||||
async move {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
let bak = path.with_extension(format!(
|
||||
"{}.bak",
|
||||
path.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or_default()
|
||||
));
|
||||
backup_existing_undo_file(&bak).await?;
|
||||
tokio::fs::rename(path, &bak).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
pub async fn e2fsck_aggressive(
|
||||
logicalname: impl AsRef<Path> + std::fmt::Debug,
|
||||
) -> Result<RequiresReboot, Error> {
|
||||
let undo_path = Path::new("/embassy-os")
|
||||
.join(
|
||||
logicalname
|
||||
.as_ref()
|
||||
.file_name()
|
||||
.unwrap_or(OsStr::new("unknown")),
|
||||
)
|
||||
.with_extension("e2undo");
|
||||
backup_existing_undo_file(&undo_path).await?;
|
||||
e2fsck_runner(
|
||||
Command::new("e2fsck").arg("-y").arg("-z").arg(undo_path),
|
||||
logicalname,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn e2fsck_runner(
|
||||
e2fsck_cmd: &mut Command,
|
||||
logicalname: impl AsRef<Path> + std::fmt::Debug,
|
||||
) -> Result<RequiresReboot, Error> {
|
||||
let e2fsck_out = e2fsck_cmd.arg(logicalname.as_ref()).output().await?;
|
||||
let e2fsck_stderr = String::from_utf8(e2fsck_out.stderr)?;
|
||||
let code = e2fsck_out.status.code().ok_or_else(|| {
|
||||
Error::new(
|
||||
eyre!("e2fsck: process terminated by signal"),
|
||||
crate::ErrorKind::DiskManagement,
|
||||
)
|
||||
})?;
|
||||
if code & 4 != 0 {
|
||||
tracing::error!(
|
||||
"some filesystem errors NOT corrected on {}:\n{}",
|
||||
logicalname.as_ref().display(),
|
||||
e2fsck_stderr,
|
||||
);
|
||||
} else if code & 1 != 0 {
|
||||
tracing::warn!(
|
||||
"filesystem errors corrected on {}:\n{}",
|
||||
logicalname.as_ref().display(),
|
||||
e2fsck_stderr,
|
||||
);
|
||||
}
|
||||
if code < 8 {
|
||||
if code & 2 != 0 {
|
||||
tracing::warn!("reboot required");
|
||||
Ok(RequiresReboot(true))
|
||||
} else {
|
||||
Ok(RequiresReboot(false))
|
||||
}
|
||||
} else {
|
||||
Err(Error::new(
|
||||
eyre!("e2fsck: {}", e2fsck_stderr),
|
||||
crate::ErrorKind::DiskManagement,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use color_eyre::eyre::eyre;
|
||||
use tokio::process::Command;
|
||||
use tracing::instrument;
|
||||
|
||||
use super::fsck::{RepairStrategy, RequiresReboot};
|
||||
use super::util::pvscan;
|
||||
use crate::disk::mount::filesystem::block_dev::mount;
|
||||
use crate::disk::mount::filesystem::ReadWrite;
|
||||
@@ -196,7 +197,12 @@ pub async fn export<P: AsRef<Path>>(guid: &str, datadir: P) -> Result<(), Error>
|
||||
}
|
||||
|
||||
#[instrument(skip(datadir, password))]
|
||||
pub async fn import<P: AsRef<Path>>(guid: &str, datadir: P, password: &str) -> Result<(), Error> {
|
||||
pub async fn import<P: AsRef<Path>>(
|
||||
guid: &str,
|
||||
datadir: P,
|
||||
repair: RepairStrategy,
|
||||
password: &str,
|
||||
) -> Result<RequiresReboot, Error> {
|
||||
let scan = pvscan().await?;
|
||||
if scan
|
||||
.values()
|
||||
@@ -244,8 +250,7 @@ pub async fn import<P: AsRef<Path>>(guid: &str, datadir: P, password: &str) -> R
|
||||
.arg(guid)
|
||||
.invoke(crate::ErrorKind::DiskManagement)
|
||||
.await?;
|
||||
mount_all_fs(guid, datadir, password).await?;
|
||||
Ok(())
|
||||
mount_all_fs(guid, datadir, repair, password).await
|
||||
}
|
||||
|
||||
#[instrument(skip(datadir, password))]
|
||||
@@ -253,8 +258,9 @@ pub async fn mount_fs<P: AsRef<Path>>(
|
||||
guid: &str,
|
||||
datadir: P,
|
||||
name: &str,
|
||||
repair: RepairStrategy,
|
||||
password: &str,
|
||||
) -> Result<(), Error> {
|
||||
) -> Result<RequiresReboot, Error> {
|
||||
tokio::fs::write(PASSWORD_PATH, password)
|
||||
.await
|
||||
.with_ctx(|_| (crate::ErrorKind::Filesystem, PASSWORD_PATH))?;
|
||||
@@ -267,27 +273,26 @@ pub async fn mount_fs<P: AsRef<Path>>(
|
||||
.arg(format!("{}_{}", guid, name))
|
||||
.invoke(crate::ErrorKind::DiskManagement)
|
||||
.await?;
|
||||
mount(
|
||||
Path::new("/dev/mapper").join(format!("{}_{}", guid, name)),
|
||||
datadir.as_ref().join(name),
|
||||
ReadWrite,
|
||||
)
|
||||
.await?;
|
||||
let mapper_path = Path::new("/dev/mapper").join(format!("{}_{}", guid, name));
|
||||
let reboot = repair.e2fsck(&mapper_path).await?;
|
||||
mount(&mapper_path, datadir.as_ref().join(name), ReadWrite).await?;
|
||||
|
||||
tokio::fs::remove_file(PASSWORD_PATH)
|
||||
.await
|
||||
.with_ctx(|_| (crate::ErrorKind::Filesystem, PASSWORD_PATH))?;
|
||||
|
||||
Ok(())
|
||||
Ok(reboot)
|
||||
}
|
||||
|
||||
#[instrument(skip(datadir, password))]
|
||||
pub async fn mount_all_fs<P: AsRef<Path>>(
|
||||
guid: &str,
|
||||
datadir: P,
|
||||
repair: RepairStrategy,
|
||||
password: &str,
|
||||
) -> Result<(), Error> {
|
||||
mount_fs(guid, &datadir, "main", password).await?;
|
||||
mount_fs(guid, &datadir, "package-data", password).await?;
|
||||
Ok(())
|
||||
) -> Result<RequiresReboot, Error> {
|
||||
let mut reboot = RequiresReboot(false);
|
||||
reboot |= mount_fs(guid, &datadir, "main", repair, password).await?;
|
||||
reboot |= mount_fs(guid, &datadir, "package-data", repair, password).await?;
|
||||
Ok(reboot)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,20 @@ use clap::ArgMatches;
|
||||
use rpc_toolkit::command;
|
||||
|
||||
use self::util::DiskListResponse;
|
||||
use crate::util::display_none;
|
||||
use crate::util::serde::{display_serializable, IoFormat};
|
||||
use crate::Error;
|
||||
|
||||
pub mod fsck;
|
||||
pub mod main;
|
||||
pub mod mount;
|
||||
pub mod quirks;
|
||||
pub mod util;
|
||||
|
||||
pub const BOOT_RW_PATH: &'static str = "/media/boot-rw";
|
||||
pub const BOOT_RW_PATH: &str = "/media/boot-rw";
|
||||
pub const REPAIR_DISK_PATH: &str = "/embassy-os/repair-disk";
|
||||
|
||||
#[command(subcommands(list))]
|
||||
#[command(subcommands(list, repair))]
|
||||
pub fn disk() -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -79,3 +82,9 @@ pub async fn list(
|
||||
) -> Result<DiskListResponse, Error> {
|
||||
crate::disk::util::list().await
|
||||
}
|
||||
|
||||
#[command(display(display_none))]
|
||||
pub async fn repair() -> Result<(), Error> {
|
||||
tokio::fs::write(REPAIR_DISK_PATH, b"").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::num::ParseIntError;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -11,14 +12,15 @@ use crate::Error;
|
||||
|
||||
pub const QUIRK_PATH: &'static str = "/sys/module/usb_storage/parameters/quirks";
|
||||
|
||||
pub const WHITELIST: [(VendorId, ProductId); 4] = [
|
||||
pub const WHITELIST: [(VendorId, ProductId); 5] = [
|
||||
(VendorId(0x1d6b), ProductId(0x0002)), // root hub usb2
|
||||
(VendorId(0x1d6b), ProductId(0x0003)), // root hub usb3
|
||||
(VendorId(0x2109), ProductId(0x3431)),
|
||||
(VendorId(0x1058), ProductId(0x262f)), // western digital black HDD
|
||||
(VendorId(0x04e8), ProductId(0x4001)), // Samsung T7
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct VendorId(u16);
|
||||
impl std::str::FromStr for VendorId {
|
||||
type Err = ParseIntError;
|
||||
@@ -32,7 +34,7 @@ impl std::fmt::Display for VendorId {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ProductId(u16);
|
||||
impl std::str::FromStr for ProductId {
|
||||
type Err = ParseIntError;
|
||||
@@ -47,10 +49,13 @@ impl std::fmt::Display for ProductId {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Quirks(Vec<(VendorId, ProductId)>);
|
||||
pub struct Quirks(BTreeSet<(VendorId, ProductId)>);
|
||||
impl Quirks {
|
||||
pub fn add(&mut self, vendor: VendorId, product: ProductId) {
|
||||
self.0.push((vendor, product));
|
||||
self.0.insert((vendor, product));
|
||||
}
|
||||
pub fn remove(&mut self, vendor: VendorId, product: ProductId) {
|
||||
self.0.remove(&(vendor, product));
|
||||
}
|
||||
pub fn contains(&self, vendor: VendorId, product: ProductId) -> bool {
|
||||
self.0.contains(&(vendor, product))
|
||||
@@ -74,10 +79,10 @@ impl std::str::FromStr for Quirks {
|
||||
type Err = Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let s = s.trim();
|
||||
let mut quirks = Vec::new();
|
||||
let mut quirks = BTreeSet::new();
|
||||
for item in s.split(",") {
|
||||
if let [vendor, product, "u"] = item.splitn(3, ":").collect::<Vec<_>>().as_slice() {
|
||||
quirks.push((vendor.parse()?, product.parse()?));
|
||||
quirks.insert((vendor.parse()?, product.parse()?));
|
||||
} else {
|
||||
return Err(Error::new(
|
||||
eyre!("Invalid quirk: `{}`", item),
|
||||
@@ -106,7 +111,11 @@ pub async fn update_quirks(quirks: &mut Quirks) -> Result<Vec<String>, Error> {
|
||||
let product = tokio::fs::read_to_string(usb_device.path().join("idProduct"))
|
||||
.await?
|
||||
.parse()?;
|
||||
if WHITELIST.contains(&(vendor, product)) || quirks.contains(vendor, product) {
|
||||
if WHITELIST.contains(&(vendor, product)) {
|
||||
quirks.remove(vendor, product);
|
||||
continue;
|
||||
}
|
||||
if quirks.contains(vendor, product) {
|
||||
continue;
|
||||
}
|
||||
quirks.add(vendor, product);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::context::rpc::RpcContextConfig;
|
||||
@@ -8,6 +10,19 @@ use crate::Error;
|
||||
|
||||
pub const SYSTEM_REBUILD_PATH: &str = "/embassy-os/system-rebuild";
|
||||
|
||||
pub async fn check_time_is_synchronized() -> Result<bool, Error> {
|
||||
Ok(String::from_utf8(
|
||||
Command::new("timedatectl")
|
||||
.arg("show")
|
||||
.arg("-p")
|
||||
.arg("NTPSynchronized")
|
||||
.invoke(crate::ErrorKind::Unknown)
|
||||
.await?,
|
||||
)?
|
||||
.trim()
|
||||
== "NTPSynchronized=yes")
|
||||
}
|
||||
|
||||
pub async fn init(cfg: &RpcContextConfig, product_key: &str) -> Result<(), Error> {
|
||||
let should_rebuild = tokio::fs::metadata(SYSTEM_REBUILD_PATH).await.is_ok();
|
||||
let secret_store = cfg.secret_store().await?;
|
||||
@@ -100,6 +115,18 @@ pub async fn init(cfg: &RpcContextConfig, product_key: &str) -> Result<(), Error
|
||||
};
|
||||
info.save(&mut handle).await?;
|
||||
|
||||
let mut warn_time_not_synced = true;
|
||||
for _ in 0..60 {
|
||||
if check_time_is_synchronized().await? {
|
||||
warn_time_not_synced = false;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
if warn_time_not_synced {
|
||||
tracing::warn!("Timed out waiting for system time to synchronize");
|
||||
}
|
||||
|
||||
crate::version::init(&mut handle).await?;
|
||||
|
||||
if should_rebuild {
|
||||
|
||||
@@ -9,8 +9,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use color_eyre::eyre::eyre;
|
||||
use emver::VersionRange;
|
||||
use futures::future::{self, BoxFuture};
|
||||
use futures::{stream, FutureExt, StreamExt, TryStreamExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::{FutureExt, StreamExt, TryStreamExt};
|
||||
use http::header::CONTENT_LENGTH;
|
||||
use http::{Request, Response, StatusCode};
|
||||
use hyper::Body;
|
||||
@@ -365,13 +365,13 @@ pub async fn sideload(
|
||||
match pde.take() {
|
||||
Some(PackageDataEntry::Installed {
|
||||
installed,
|
||||
manifest,
|
||||
static_files,
|
||||
..
|
||||
}) => {
|
||||
*pde = Some(PackageDataEntry::Updating {
|
||||
install_progress: progress.clone(),
|
||||
installed,
|
||||
manifest,
|
||||
manifest: manifest.clone(),
|
||||
static_files,
|
||||
})
|
||||
}
|
||||
@@ -1197,34 +1197,33 @@ pub async fn install_s9pk<R: AsyncRead + AsyncSeek + Unpin>(
|
||||
dep_errs.save(&mut tx).await?;
|
||||
|
||||
if let PackageDataEntry::Updating {
|
||||
installed: prev,
|
||||
manifest: prev_manifest,
|
||||
..
|
||||
installed: prev, ..
|
||||
} = prev
|
||||
{
|
||||
let prev_is_configured = prev.status.configured;
|
||||
let prev_migration = prev_manifest
|
||||
let prev_migration = prev
|
||||
.manifest
|
||||
.migrations
|
||||
.to(
|
||||
ctx,
|
||||
version,
|
||||
pkg_id,
|
||||
&prev_manifest.version,
|
||||
&prev_manifest.volumes,
|
||||
&prev.manifest.version,
|
||||
&prev.manifest.volumes,
|
||||
)
|
||||
.map(futures::future::Either::Left);
|
||||
let migration = manifest
|
||||
.migrations
|
||||
.from(
|
||||
ctx,
|
||||
&prev_manifest.version,
|
||||
&prev.manifest.version,
|
||||
pkg_id,
|
||||
version,
|
||||
&manifest.volumes,
|
||||
)
|
||||
.map(futures::future::Either::Right);
|
||||
|
||||
let viable_migration = if prev_manifest.version > manifest.version {
|
||||
let viable_migration = if prev.manifest.version > manifest.version {
|
||||
prev_migration.or(migration)
|
||||
} else {
|
||||
migration.or(prev_migration)
|
||||
|
||||
@@ -77,6 +77,7 @@ pub fn main_api() -> Result<(), RpcError> {
|
||||
|
||||
#[command(subcommands(
|
||||
system::logs,
|
||||
system::kernel_logs,
|
||||
system::metrics,
|
||||
shutdown::shutdown,
|
||||
shutdown::restart,
|
||||
|
||||
@@ -106,6 +106,7 @@ fn deserialize_string_or_utf8_array<'de, D: serde::de::Deserializer<'de>>(
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LogSource {
|
||||
Kernel,
|
||||
Service(&'static str),
|
||||
Container(PackageId),
|
||||
}
|
||||
@@ -139,37 +140,42 @@ pub async fn fetch_logs(
|
||||
cursor: Option<String>,
|
||||
before_flag: bool,
|
||||
) -> Result<LogResponse, Error> {
|
||||
let limit = limit.unwrap_or(50);
|
||||
let limit_formatted = format!("-n{}", limit);
|
||||
let mut cmd = Command::new("journalctl");
|
||||
|
||||
let mut args = vec!["--output=json", "--output-fields=MESSAGE", &limit_formatted];
|
||||
let id_formatted = match id {
|
||||
let limit = limit.unwrap_or(50);
|
||||
|
||||
cmd.arg("--output=json");
|
||||
cmd.arg("--output-fields=MESSAGE");
|
||||
cmd.arg(format!("-n{}", limit));
|
||||
match id {
|
||||
LogSource::Kernel => {
|
||||
cmd.arg("-k");
|
||||
}
|
||||
LogSource::Service(id) => {
|
||||
args.push("-u");
|
||||
id.to_owned()
|
||||
cmd.arg("-u");
|
||||
cmd.arg(id);
|
||||
}
|
||||
LogSource::Container(id) => {
|
||||
format!("CONTAINER_NAME={}", DockerAction::container_name(&id, None))
|
||||
cmd.arg(format!(
|
||||
"CONTAINER_NAME={}",
|
||||
DockerAction::container_name(&id, None)
|
||||
));
|
||||
}
|
||||
};
|
||||
args.push(&id_formatted);
|
||||
|
||||
let cursor_formatted = format!("--after-cursor={}", cursor.clone().unwrap_or("".to_owned()));
|
||||
let mut get_prev_logs_and_reverse = false;
|
||||
if cursor.is_some() {
|
||||
args.push(&cursor_formatted);
|
||||
cmd.arg(&cursor_formatted);
|
||||
if before_flag {
|
||||
get_prev_logs_and_reverse = true;
|
||||
}
|
||||
}
|
||||
if get_prev_logs_and_reverse {
|
||||
args.push("--reverse");
|
||||
cmd.arg("--reverse");
|
||||
}
|
||||
|
||||
let mut child = Command::new("journalctl")
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
let mut child = cmd.stdout(Stdio::piped()).spawn()?;
|
||||
let out = BufReader::new(
|
||||
child
|
||||
.stdout
|
||||
|
||||
@@ -12,15 +12,45 @@ pub fn marketplace() -> Result<(), Error> {
|
||||
|
||||
#[command]
|
||||
pub async fn get(#[arg] url: Url) -> Result<Value, Error> {
|
||||
let response = reqwest::get(url)
|
||||
let mut response = reqwest::get(url)
|
||||
.await
|
||||
.with_kind(crate::ErrorKind::Network)?;
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.with_kind(crate::ErrorKind::Deserialization)
|
||||
match response
|
||||
.headers_mut()
|
||||
.remove("Content-Type")
|
||||
.as_ref()
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.split(";").next())
|
||||
.map(|h| h.trim())
|
||||
{
|
||||
Some("application/json") => response
|
||||
.json()
|
||||
.await
|
||||
.with_kind(crate::ErrorKind::Deserialization),
|
||||
Some("text/plain") => Ok(Value::String(
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.with_kind(crate::ErrorKind::Registry)?,
|
||||
)),
|
||||
Some(ctype) => Ok(Value::String(format!(
|
||||
"data:{};base64,{}",
|
||||
ctype,
|
||||
base64::encode_config(
|
||||
&response
|
||||
.bytes()
|
||||
.await
|
||||
.with_kind(crate::ErrorKind::Registry)?,
|
||||
base64::URL_SAFE
|
||||
)
|
||||
))),
|
||||
_ => Err(Error::new(
|
||||
eyre!("missing Content-Type"),
|
||||
crate::ErrorKind::Registry,
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
let message = response.text().await.with_kind(crate::ErrorKind::Network)?;
|
||||
Err(Error::new(
|
||||
|
||||
@@ -11,5 +11,8 @@ server {{
|
||||
client_max_body_size 0;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -79,6 +79,7 @@ pub async fn add(
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to add new WiFi network '{}': {}", ssid, err);
|
||||
tracing::debug!("{:?}", err);
|
||||
return Err(Error::new(
|
||||
color_eyre::eyre::eyre!("Failed adding {}", ssid),
|
||||
ErrorKind::Wifi,
|
||||
@@ -292,12 +293,12 @@ pub async fn get(
|
||||
wpa_supplicant.list_wifi_low()
|
||||
);
|
||||
let signal_strengths = signal_strengths?;
|
||||
let list_networks = list_networks?;
|
||||
let list_networks: BTreeSet<_> = list_networks?.into_iter().map(|(_, x)| x.ssid).collect();
|
||||
let available_wifi = {
|
||||
let mut wifi_list: Vec<WifiListOut> = signal_strengths
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter(|(ssid, _)| !list_networks.contains_key(ssid))
|
||||
.filter(|(ssid, _)| !list_networks.contains(ssid))
|
||||
.map(|(ssid, info)| WifiListOut {
|
||||
ssid,
|
||||
strength: info.strength,
|
||||
@@ -309,13 +310,13 @@ pub async fn get(
|
||||
wifi_list
|
||||
};
|
||||
let ssids: HashMap<Ssid, SignalStrength> = list_networks
|
||||
.into_keys()
|
||||
.map(|x| {
|
||||
.into_iter()
|
||||
.map(|ssid| {
|
||||
let signal_strength = signal_strengths
|
||||
.get(&x)
|
||||
.get(&ssid)
|
||||
.map(|x| x.strength)
|
||||
.unwrap_or_default();
|
||||
(x, signal_strength)
|
||||
(ssid, signal_strength)
|
||||
})
|
||||
.collect();
|
||||
let current = current_res?;
|
||||
@@ -341,10 +342,13 @@ pub async fn get_available(
|
||||
wpa_supplicant.list_wifi_low(),
|
||||
wpa_supplicant.list_networks_low()
|
||||
);
|
||||
let network_list = network_list?;
|
||||
let network_list = network_list?
|
||||
.into_iter()
|
||||
.map(|(_, info)| info.ssid)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut wifi_list: Vec<WifiListOut> = wifi_list?
|
||||
.into_iter()
|
||||
.filter(|(ssid, _)| !network_list.contains_key(ssid))
|
||||
.filter(|(ssid, _)| !network_list.contains(ssid))
|
||||
.map(|(ssid, info)| WifiListOut {
|
||||
ssid,
|
||||
strength: info.strength,
|
||||
@@ -369,7 +373,7 @@ pub async fn set_country(
|
||||
}
|
||||
let mut wpa_supplicant = ctx.wifi_manager.write().await;
|
||||
wpa_supplicant.set_country_low(country.alpha2()).await?;
|
||||
for (_ssid, network_id) in wpa_supplicant.list_networks_low().await? {
|
||||
for (network_id, _wifi_info) in wpa_supplicant.list_networks_low().await? {
|
||||
wpa_supplicant.remove_network_low(network_id).await?;
|
||||
}
|
||||
wpa_supplicant.remove_all_connections().await?;
|
||||
@@ -421,6 +425,12 @@ impl SignalStrength {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WifiInfo {
|
||||
ssid: Ssid,
|
||||
device: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Psk(String);
|
||||
impl WpaCli {
|
||||
@@ -446,20 +456,20 @@ impl WpaCli {
|
||||
}
|
||||
#[instrument(skip(self, psk))]
|
||||
pub async fn add_network_low(&mut self, ssid: &Ssid, psk: &Psk) -> Result<(), Error> {
|
||||
let _ = Command::new("nmcli")
|
||||
.arg("con")
|
||||
.arg("add")
|
||||
.arg("con-name")
|
||||
.arg(&ssid.0)
|
||||
.arg("ifname")
|
||||
.arg(&self.interface)
|
||||
.arg("type")
|
||||
.arg("wifi")
|
||||
.arg("ssid")
|
||||
.arg(&ssid.0)
|
||||
.invoke(ErrorKind::Wifi)
|
||||
.await?;
|
||||
let _ = Command::new("nmcli")
|
||||
if self.find_networks(ssid).await?.is_empty() {
|
||||
Command::new("nmcli")
|
||||
.arg("con")
|
||||
.arg("add")
|
||||
.arg("con-name")
|
||||
.arg(&ssid.0)
|
||||
.arg("type")
|
||||
.arg("wifi")
|
||||
.arg("ssid")
|
||||
.arg(&ssid.0)
|
||||
.invoke(ErrorKind::Wifi)
|
||||
.await?;
|
||||
}
|
||||
Command::new("nmcli")
|
||||
.arg("con")
|
||||
.arg("modify")
|
||||
.arg(&ssid.0)
|
||||
@@ -467,7 +477,20 @@ impl WpaCli {
|
||||
.arg("wpa-psk")
|
||||
.invoke(ErrorKind::Wifi)
|
||||
.await?;
|
||||
let _ = Command::new("nmcli")
|
||||
Command::new("nmcli")
|
||||
.arg("con")
|
||||
.arg("modify")
|
||||
.arg(&ssid.0)
|
||||
.arg("ifname")
|
||||
.arg(&self.interface)
|
||||
.invoke(ErrorKind::Wifi)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("Failed to set interface {} for {}", self.interface, ssid.0);
|
||||
tracing::debug!("{:?}", e);
|
||||
});
|
||||
Command::new("nmcli")
|
||||
.arg("con")
|
||||
.arg("modify")
|
||||
.arg(&ssid.0)
|
||||
@@ -526,27 +549,32 @@ impl WpaCli {
|
||||
Ok(())
|
||||
}
|
||||
#[instrument]
|
||||
pub async fn list_networks_low(&self) -> Result<BTreeMap<Ssid, NetworkId>, Error> {
|
||||
pub async fn list_networks_low(&self) -> Result<BTreeMap<NetworkId, WifiInfo>, Error> {
|
||||
let r = Command::new("nmcli")
|
||||
.arg("-t")
|
||||
.arg("c")
|
||||
.arg("show")
|
||||
.invoke(ErrorKind::Wifi)
|
||||
.await?;
|
||||
Ok(String::from_utf8(r)?
|
||||
.lines()
|
||||
let r = String::from_utf8(r)?;
|
||||
tracing::info!("JCWM: all the networks: {:?}", r);
|
||||
Ok(r.lines()
|
||||
.filter_map(|l| {
|
||||
let mut cs = l.split(':');
|
||||
let name = Ssid(cs.next()?.to_owned());
|
||||
let uuid = NetworkId(cs.next()?.to_owned());
|
||||
let connection_type = cs.next()?;
|
||||
let device = cs.next()?;
|
||||
if !device.contains("wlan0") || !connection_type.contains("wireless") {
|
||||
let device = cs.next();
|
||||
if !connection_type.contains("wireless") {
|
||||
return None;
|
||||
}
|
||||
Some((name, uuid))
|
||||
let info = WifiInfo {
|
||||
ssid: name,
|
||||
device: device.map(|x| x.to_owned()),
|
||||
};
|
||||
Some((uuid, info))
|
||||
})
|
||||
.collect::<BTreeMap<Ssid, NetworkId>>())
|
||||
.collect::<BTreeMap<NetworkId, WifiInfo>>())
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
@@ -606,12 +634,37 @@ impl WpaCli {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn check_network(&self, ssid: &Ssid) -> Result<Option<NetworkId>, Error> {
|
||||
Ok(self.list_networks_low().await?.remove(ssid))
|
||||
async fn check_active_network(&self, ssid: &Ssid) -> Result<Option<NetworkId>, Error> {
|
||||
Ok(self
|
||||
.list_networks_low()
|
||||
.await?
|
||||
.iter()
|
||||
.find_map(|(network_id, wifi_info)| {
|
||||
wifi_info.device.as_ref()?;
|
||||
if wifi_info.ssid == *ssid {
|
||||
Some(network_id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}))
|
||||
}
|
||||
pub async fn find_networks(&self, ssid: &Ssid) -> Result<Vec<NetworkId>, Error> {
|
||||
Ok(self
|
||||
.list_networks_low()
|
||||
.await?
|
||||
.iter()
|
||||
.filter_map(|(network_id, wifi_info)| {
|
||||
if wifi_info.ssid == *ssid {
|
||||
Some(network_id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
#[instrument(skip(db))]
|
||||
pub async fn select_network(&mut self, db: impl DbHandle, ssid: &Ssid) -> Result<bool, Error> {
|
||||
let m_id = self.check_network(ssid).await?;
|
||||
let m_id = self.check_active_network(ssid).await?;
|
||||
match m_id {
|
||||
None => Err(Error::new(
|
||||
color_eyre::eyre::eyre!("SSID Not Found"),
|
||||
@@ -663,14 +716,15 @@ impl WpaCli {
|
||||
}
|
||||
#[instrument(skip(db))]
|
||||
pub async fn remove_network(&mut self, db: impl DbHandle, ssid: &Ssid) -> Result<bool, Error> {
|
||||
match self.check_network(ssid).await? {
|
||||
None => Ok(false),
|
||||
Some(x) => {
|
||||
self.remove_network_low(x).await?;
|
||||
self.save_config(db).await?;
|
||||
Ok(true)
|
||||
}
|
||||
let found_networks = self.find_networks(ssid).await?;
|
||||
if found_networks.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
for network_id in found_networks {
|
||||
self.remove_network_low(network_id).await?;
|
||||
}
|
||||
self.save_config(db).await?;
|
||||
Ok(true)
|
||||
}
|
||||
#[instrument(skip(psk, db))]
|
||||
pub async fn set_add_network(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
|
||||
map $http_upgrade $connection_upgrade {{
|
||||
default upgrade;
|
||||
'' $http_connection;
|
||||
}}
|
||||
|
||||
server {{
|
||||
listen 443 ssl default_server;
|
||||
|
||||
@@ -68,7 +68,7 @@ impl ImageTag {
|
||||
crate::ErrorKind::ValidateS9pk,
|
||||
));
|
||||
}
|
||||
if id != &self.package_id {
|
||||
if version != &self.version {
|
||||
return Err(Error::new(
|
||||
eyre!(
|
||||
"Contains image with incorrect version: expected {} received {}",
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::context::rpc::RpcContextConfig;
|
||||
use crate::context::setup::SetupResult;
|
||||
use crate::context::SetupContext;
|
||||
use crate::db::model::RecoveredPackageInfo;
|
||||
use crate::disk::fsck::RepairStrategy;
|
||||
use crate::disk::main::DEFAULT_PASSWORD;
|
||||
use crate::disk::mount::filesystem::block_dev::BlockDev;
|
||||
use crate::disk::mount::filesystem::cifs::Cifs;
|
||||
@@ -96,7 +97,13 @@ pub async fn attach(
|
||||
#[context] ctx: SetupContext,
|
||||
#[arg] guid: Arc<String>,
|
||||
) -> Result<SetupResult, Error> {
|
||||
crate::disk::main::import(&*guid, &ctx.datadir, DEFAULT_PASSWORD).await?;
|
||||
crate::disk::main::import(
|
||||
&*guid,
|
||||
&ctx.datadir,
|
||||
RepairStrategy::Preen,
|
||||
DEFAULT_PASSWORD,
|
||||
)
|
||||
.await?;
|
||||
let product_key_path = Path::new("/embassy-data/main/product_key.txt");
|
||||
if tokio::fs::metadata(product_key_path).await.is_ok() {
|
||||
let pkey = tokio::fs::read_to_string(product_key_path).await?;
|
||||
@@ -301,7 +308,13 @@ pub async fn execute_inner(
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
crate::disk::main::import(&*guid, &ctx.datadir, DEFAULT_PASSWORD).await?;
|
||||
crate::disk::main::import(
|
||||
&*guid,
|
||||
&ctx.datadir,
|
||||
RepairStrategy::Preen,
|
||||
DEFAULT_PASSWORD,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let res = if let Some(recovery_source) = recovery_source {
|
||||
let (tor_addr, root_ca, recover_fut) = recover(
|
||||
|
||||
@@ -8,7 +8,7 @@ use tokio::sync::RwLock;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::context::RpcContext;
|
||||
use crate::disk::util::{get_available, get_percentage, get_used};
|
||||
use crate::disk::util::{get_available, get_used};
|
||||
use crate::logs::{display_logs, fetch_logs, LogResponse, LogSource};
|
||||
use crate::shutdown::Shutdown;
|
||||
use crate::util::serde::{display_serializable, IoFormat};
|
||||
@@ -31,6 +31,21 @@ pub async fn logs(
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[command(rename = "kernel-logs", display(display_logs))]
|
||||
pub async fn kernel_logs(
|
||||
#[arg] limit: Option<usize>,
|
||||
#[arg] cursor: Option<String>,
|
||||
#[arg] before_flag: Option<bool>,
|
||||
) -> Result<LogResponse, Error> {
|
||||
Ok(fetch_logs(
|
||||
LogSource::Kernel,
|
||||
limit,
|
||||
cursor,
|
||||
before_flag.unwrap_or(false),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct MetricLeaf<T> {
|
||||
value: T,
|
||||
|
||||
@@ -88,40 +88,40 @@ fn display_update_result(status: WithRevision<UpdateResult>, _: &ArgMatches<'_>)
|
||||
const HEADER_KEY: &str = "x-eos-hash";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum WritableDrives {
|
||||
pub enum WritableDrives {
|
||||
Green,
|
||||
Blue,
|
||||
}
|
||||
impl WritableDrives {
|
||||
fn label(&self) -> &'static str {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Green => "green",
|
||||
Self::Blue => "blue",
|
||||
}
|
||||
}
|
||||
fn block_dev(&self) -> &'static Path {
|
||||
pub fn block_dev(&self) -> &'static Path {
|
||||
Path::new(match self {
|
||||
Self::Green => "/dev/mmcblk0p3",
|
||||
Self::Blue => "/dev/mmcblk0p4",
|
||||
})
|
||||
}
|
||||
fn part_uuid(&self) -> &'static str {
|
||||
pub fn part_uuid(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Green => "cb15ae4d-03",
|
||||
Self::Blue => "cb15ae4d-04",
|
||||
}
|
||||
}
|
||||
fn as_fs(&self) -> impl FileSystem {
|
||||
pub fn as_fs(&self) -> impl FileSystem {
|
||||
BlockDev::new(self.block_dev())
|
||||
}
|
||||
}
|
||||
|
||||
/// This will be where we are going to be putting the new update
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct NewLabel(WritableDrives);
|
||||
pub struct NewLabel(pub WritableDrives);
|
||||
|
||||
/// This is our current label where the os is running
|
||||
struct CurrentLabel(WritableDrives);
|
||||
pub struct CurrentLabel(pub WritableDrives);
|
||||
|
||||
lazy_static! {
|
||||
static ref PARSE_COLOR: Regex = Regex::new("LABEL=(\\w+)[ \t]+/").unwrap();
|
||||
@@ -259,7 +259,7 @@ async fn do_update(
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
async fn query_mounted_label() -> Result<(NewLabel, CurrentLabel), Error> {
|
||||
pub async fn query_mounted_label() -> Result<(NewLabel, CurrentLabel), Error> {
|
||||
let output = tokio::fs::read_to_string("/etc/fstab")
|
||||
.await
|
||||
.with_ctx(|_| (crate::ErrorKind::Filesystem, "/etc/fstab"))?;
|
||||
@@ -445,9 +445,18 @@ async fn swap_boot_label(new_label: NewLabel) -> Result<(), Error> {
|
||||
new_label.0.label()
|
||||
))
|
||||
.arg(mounted.as_ref().join("etc/fstab"))
|
||||
.output()
|
||||
.invoke(crate::ErrorKind::Filesystem)
|
||||
.await?;
|
||||
mounted.unmount().await?;
|
||||
Command::new("sed")
|
||||
.arg("-i")
|
||||
.arg(&format!(
|
||||
"s/PARTUUID=cb15ae4d-\\(03\\|04\\)/PARTUUID={}/g",
|
||||
new_label.0.part_uuid()
|
||||
))
|
||||
.arg(Path::new(BOOT_RW_PATH).join("cmdline.txt.orig"))
|
||||
.invoke(crate::ErrorKind::Filesystem)
|
||||
.await?;
|
||||
Command::new("sed")
|
||||
.arg("-i")
|
||||
.arg(&format!(
|
||||
@@ -455,7 +464,7 @@ async fn swap_boot_label(new_label: NewLabel) -> Result<(), Error> {
|
||||
new_label.0.part_uuid()
|
||||
))
|
||||
.arg(Path::new(BOOT_RW_PATH).join("cmdline.txt"))
|
||||
.output()
|
||||
.invoke(crate::ErrorKind::Filesystem)
|
||||
.await?;
|
||||
|
||||
UPDATED.store(true, Ordering::SeqCst);
|
||||
|
||||
@@ -9,13 +9,15 @@ use rpc_toolkit::command;
|
||||
use crate::{Error, ResultExt};
|
||||
|
||||
mod v0_3_0;
|
||||
mod v0_3_0_1;
|
||||
|
||||
pub type Current = v0_3_0::Version;
|
||||
pub type Current = v0_3_0_1::Version;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Version {
|
||||
V0_3_0(Wrapper<v0_3_0::Version>),
|
||||
V0_3_0_1(Wrapper<v0_3_0_1::Version>),
|
||||
Other(emver::Version),
|
||||
}
|
||||
|
||||
@@ -111,6 +113,7 @@ pub async fn init<Db: DbHandle>(db: &mut Db) -> Result<(), Error> {
|
||||
let version: Version = db.get(&ptr).await?;
|
||||
match version {
|
||||
Version::V0_3_0(v) => v.0.migrate_to(&Current::new(), db).await?,
|
||||
Version::V0_3_0_1(v) => v.0.migrate_to(&Current::new(), db).await?,
|
||||
Version::Other(_) => {
|
||||
return Err(Error::new(
|
||||
eyre!("Cannot downgrade"),
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::*;
|
||||
|
||||
const V0_3_0: emver::Version = emver::Version::new(0, 3, 0, 0);
|
||||
lazy_static! {
|
||||
static ref V0_3_0_COMPAT: VersionRange = VersionRange::Conj(
|
||||
pub static ref V0_3_0_COMPAT: VersionRange = VersionRange::Conj(
|
||||
Box::new(VersionRange::Anchor(
|
||||
emver::GTE,
|
||||
emver::Version::new(0, 3, 0, 0),
|
||||
|
||||
46
backend/src/version/v0_3_0_1.rs
Normal file
46
backend/src/version/v0_3_0_1.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use std::path::Path;
|
||||
|
||||
use emver::VersionRange;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::*;
|
||||
use crate::disk::quirks::{fetch_quirks, save_quirks, update_quirks};
|
||||
use crate::disk::BOOT_RW_PATH;
|
||||
use crate::update::query_mounted_label;
|
||||
use crate::util::Invoke;
|
||||
|
||||
const V0_3_0_1: emver::Version = emver::Version::new(0, 3, 0, 1);
|
||||
|
||||
pub struct Version;
|
||||
#[async_trait]
|
||||
impl VersionT for Version {
|
||||
type Previous = v0_3_0::Version;
|
||||
fn new() -> Self {
|
||||
Version
|
||||
}
|
||||
fn semver(&self) -> emver::Version {
|
||||
V0_3_0_1
|
||||
}
|
||||
fn compat(&self) -> &'static VersionRange {
|
||||
&*v0_3_0::V0_3_0_COMPAT
|
||||
}
|
||||
async fn up<Db: DbHandle>(&self, _db: &mut Db) -> Result<(), Error> {
|
||||
let (_, current) = query_mounted_label().await?;
|
||||
Command::new("sed")
|
||||
.arg("-i")
|
||||
.arg(&format!(
|
||||
"s/PARTUUID=cb15ae4d-\\(03\\|04\\)/PARTUUID={}/g",
|
||||
current.0.part_uuid()
|
||||
))
|
||||
.arg(Path::new(BOOT_RW_PATH).join("cmdline.txt.orig"))
|
||||
.invoke(crate::ErrorKind::Filesystem)
|
||||
.await?;
|
||||
let mut q = fetch_quirks().await?;
|
||||
update_quirks(&mut q).await?;
|
||||
save_quirks(&q).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn down<Db: DbHandle>(&self, _db: &mut Db) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ passwd -l start9
|
||||
! test -f /etc/docker/daemon.json || rm /etc/docker/daemon.json
|
||||
mount -o remount,rw /boot
|
||||
|
||||
apt-mark hold raspberrypi-bootloader
|
||||
apt-mark hold raspberrypi-kernel
|
||||
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
tor \
|
||||
@@ -34,7 +37,6 @@ apt-get install -y \
|
||||
ecryptfs-utils \
|
||||
cifs-utils \
|
||||
samba-common-bin \
|
||||
ntp \
|
||||
network-manager \
|
||||
vim \
|
||||
jq \
|
||||
@@ -87,9 +89,9 @@ rm -rf /var/lib/tor/*
|
||||
raspi-config nonint enable_overlayfs
|
||||
|
||||
# create a copy of the cmdline *without* the quirk string, so that it can be easily amended
|
||||
sudo sed -i 's/usb-storage.quirks=152d:0562:u,14cd:121c:u,0781:cfcb:u //g' /boot/cmdline.txt
|
||||
sudo cp /boot/cmdline.txt /boot/cmdline.txt.orig
|
||||
sudo sed -i 's/^/usb-storage.quirks=152d:0562:u,14cd:121c:u,0781:cfcb:u /g' /boot/cmdline.txt
|
||||
sed -i 's/usb-storage.quirks=152d:0562:u,14cd:121c:u,0781:cfcb:u //g' /boot/cmdline.txt
|
||||
cp /boot/cmdline.txt /boot/cmdline.txt.orig
|
||||
sed -i 's/^/usb-storage.quirks=152d:0562:u,14cd:121c:u,0781:cfcb:u /g' /boot/cmdline.txt
|
||||
|
||||
systemctl disable initialization.service
|
||||
sudo systemctl restart NetworkManager
|
||||
|
||||
@@ -96,3 +96,10 @@ echo "Verification Succeeded"
|
||||
|
||||
sudo e2label update.img red
|
||||
echo "Image Relabeled to \"red\""
|
||||
|
||||
echo "Compressing..."
|
||||
if which pv > /dev/null; then
|
||||
cat update.img | pv -s $FS_SIZE | gzip > update.img.gz
|
||||
else
|
||||
cat update.img | gzip > update.img.gz
|
||||
fi
|
||||
|
||||
@@ -74,6 +74,7 @@ sudo mkdir -p /tmp/eos-mnt/var/www/html
|
||||
sudo cp -R frontend/dist/diagnostic-ui /tmp/eos-mnt/var/www/html/diagnostic
|
||||
sudo cp -R frontend/dist/setup-wizard /tmp/eos-mnt/var/www/html/setup
|
||||
sudo cp -R frontend/dist/ui /tmp/eos-mnt/var/www/html/main
|
||||
sudo cp index.html /tmp/eos-mnt/var/www/html/index.html
|
||||
|
||||
# Make the .ssh directory
|
||||
sudo mkdir -p /tmp/eos-mnt/root/.ssh
|
||||
|
||||
@@ -38,8 +38,9 @@
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"styles/variables.scss",
|
||||
"styles/global.scss",
|
||||
"projects/shared/styles/variables.scss",
|
||||
"projects/shared/styles/global.scss",
|
||||
"projects/shared/styles/shared.scss",
|
||||
"projects/ui/src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
@@ -157,8 +158,9 @@
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"styles/variables.scss",
|
||||
"styles/global.scss",
|
||||
"projects/shared/styles/variables.scss",
|
||||
"projects/shared/styles/global.scss",
|
||||
"projects/shared/styles/shared.scss",
|
||||
"projects/setup-wizard/src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
@@ -276,8 +278,9 @@
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"styles/variables.scss",
|
||||
"styles/global.scss",
|
||||
"projects/shared/styles/variables.scss",
|
||||
"projects/shared/styles/global.scss",
|
||||
"projects/shared/styles/shared.scss",
|
||||
"projects/diagnostic-ui/src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
@@ -376,6 +379,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"marketplace": {
|
||||
"projectType": "library",
|
||||
"root": "projects/marketplace",
|
||||
"sourceRoot": "projects/marketplace/src",
|
||||
"prefix": "lib",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:ng-packagr",
|
||||
"options": {
|
||||
"project": "projects/marketplace/ng-package.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"tsConfig": "projects/marketplace/tsconfig.prod.json"
|
||||
},
|
||||
"development": {
|
||||
"tsConfig": "projects/marketplace/tsconfig.json"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"projectType": "library",
|
||||
"root": "projects/shared",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"mocks": {
|
||||
"maskAs": "tor",
|
||||
"skipStartupAlerts": true
|
||||
}
|
||||
},
|
||||
"targetArch": "aarch64"
|
||||
}
|
||||
}
|
||||
|
||||
3009
frontend/package-lock.json
generated
3009
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "embassy-os",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.0.1",
|
||||
"author": "Start9 Labs, Inc",
|
||||
"homepage": "https://start9.com/",
|
||||
"scripts": {
|
||||
@@ -15,21 +15,21 @@
|
||||
"build:setup-wizard": "ng run setup-wizard:build",
|
||||
"build:ui": "ng run ui:build && tsc projects/ui/postprocess.ts && node projects/ui/postprocess.js && git log | head -n1 > dist/ui/git-hash.txt",
|
||||
"build:all": "npm run build:deps && npm run build:diagnostic-ui && npm run build:setup-wizard && npm run build:ui",
|
||||
"start:diagnostic-ui": "npm run-script build-config && ionic serve --project diagnostic-ui",
|
||||
"start:setup-wizard": "npm run-script build-config && ionic serve --project setup-wizard",
|
||||
"start:ui": "npm run-script build-config && ionic serve --project ui",
|
||||
"start:diagnostic-ui": "npm run-script build-config && ionic serve --project diagnostic-ui --address 0.0.0.0",
|
||||
"start:setup-wizard": "npm run-script build-config && ionic serve --project setup-wizard --address 0.0.0.0",
|
||||
"start:ui": "npm run-script build-config && ionic serve --project ui --ip --address 0.0.0.0",
|
||||
"build-config": "node build-config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^13.2.0",
|
||||
"@angular/common": "^13.2.0",
|
||||
"@angular/compiler": "^13.2.0",
|
||||
"@angular/core": "^13.2.0",
|
||||
"@angular/forms": "^13.2.0",
|
||||
"@angular/platform-browser": "^13.2.0",
|
||||
"@angular/platform-browser-dynamic": "^13.2.0",
|
||||
"@angular/router": "^13.2.0",
|
||||
"@ionic/angular": "^6.0.3",
|
||||
"@angular/animations": "^13.3.0",
|
||||
"@angular/common": "^13.3.0",
|
||||
"@angular/compiler": "^13.3.0",
|
||||
"@angular/core": "^13.3.0",
|
||||
"@angular/forms": "^13.3.0",
|
||||
"@angular/platform-browser": "^13.3.0",
|
||||
"@angular/platform-browser-dynamic": "^13.3.0",
|
||||
"@angular/router": "^13.3.0",
|
||||
"@ionic/angular": "^6.0.13",
|
||||
"@ionic/storage-angular": "^3.0.6",
|
||||
"@materia-ui/ngx-monaco-editor": "^6.0.0",
|
||||
"@start9labs/argon2": "^0.1.0",
|
||||
@@ -37,13 +37,13 @@
|
||||
"aes-js": "^3.1.2",
|
||||
"ajv": "^6.12.6",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"core-js": "^3.17.2",
|
||||
"dompurify": "^2.3.3",
|
||||
"fast-json-patch": "^3.1.0",
|
||||
"core-js": "^3.21.1",
|
||||
"dompurify": "^2.3.6",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^6.4.6",
|
||||
"js-yaml": "^4.1.0",
|
||||
"marked": "^4.0.0",
|
||||
"monaco-editor": "^0.32.0",
|
||||
"monaco-editor": "^0.33.0",
|
||||
"mustache": "^4.2.0",
|
||||
"ng-qrcode": "^6.0.0",
|
||||
"patch-db-client": "file: ../../../patch-db/client",
|
||||
@@ -51,32 +51,32 @@
|
||||
"rxjs": "^6.6.7",
|
||||
"tslib": "^2.3.0",
|
||||
"uuid": "^8.3.2",
|
||||
"zone.js": "^0.11.4"
|
||||
"zone.js": "^0.11.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^13.1.4",
|
||||
"@angular/cli": "^13.1.4",
|
||||
"@angular/compiler-cli": "^13.2.0",
|
||||
"@angular/language-service": "^13.2.0",
|
||||
"@ionic/cli": "^6.18.1",
|
||||
"@angular/compiler-cli": "^13.3.0",
|
||||
"@angular/language-service": "^13.3.0",
|
||||
"@ionic/cli": "^6.19.0",
|
||||
"@types/aes-js": "^3.1.1",
|
||||
"@types/dompurify": "^2.3.3",
|
||||
"@types/estree": "^0.0.51",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/marked": "^4.0.0",
|
||||
"@types/marked": "^4.0.3",
|
||||
"@types/mustache": "^4.1.2",
|
||||
"@types/node": "^16.9.1",
|
||||
"@types/pbkdf2": "^3.1.0",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"husky": "^4.3.8",
|
||||
"lint-staged": "^12.1.2",
|
||||
"ng-packagr": "^13.0.0",
|
||||
"node-html-parser": "^5.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"lint-staged": "^12.3.7",
|
||||
"ng-packagr": "^13.3.0",
|
||||
"node-html-parser": "^5.3.3",
|
||||
"prettier": "^2.6.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"ts-node": "^10.4.0",
|
||||
"ts-node": "^10.7.0",
|
||||
"tslint": "^6.1.3",
|
||||
"typescript": "^4.5.2"
|
||||
"typescript": "^4.6.3"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { HttpClientModule } from '@angular/common/http'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { MockApiService } from './services/api/mock-api.service'
|
||||
import { LiveApiService } from './services/api/live-api.service'
|
||||
import { HttpService } from './services/http.service'
|
||||
import { GlobalErrorHandler } from './services/global-error-handler.service'
|
||||
import { WorkspaceConfig } from '@start9labs/shared'
|
||||
|
||||
@@ -29,14 +28,7 @@ const { useMocks } = require('../../../../config.json') as WorkspaceConfig
|
||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||
{
|
||||
provide: ApiService,
|
||||
useFactory: (http: HttpService) => {
|
||||
if (useMocks) {
|
||||
return new MockApiService()
|
||||
} else {
|
||||
return new LiveApiService(http)
|
||||
}
|
||||
},
|
||||
deps: [HttpService],
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
},
|
||||
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
|
||||
],
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
}}
|
||||
</ion-button>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="error.code === 2 || error.code === 48"
|
||||
class="ion-padding-top"
|
||||
>
|
||||
<ion-button (click)="repairDrive()"> {{ 'Repair Drive' }} </ion-button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #refresh>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { LoadingController } from '@ionic/angular'
|
||||
import {
|
||||
AlertController,
|
||||
IonicSafeString,
|
||||
LoadingController,
|
||||
} from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
|
||||
@Component({
|
||||
@@ -20,6 +24,7 @@ export class HomePage {
|
||||
constructor(
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly api: ApiService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -48,16 +53,34 @@ export class HomePage {
|
||||
this.error = {
|
||||
code: 25,
|
||||
problem:
|
||||
'Storage drive corrupted. This could be the result of data corruption or a physical damage.',
|
||||
'Storage drive corrupted. This could be the result of data corruption or physical damage.',
|
||||
solution:
|
||||
'It may or may not be possible to re-use this drive by reformatting and recovering from backup. To enter recovery mode, click ENTER RECOVERY MODE below, then follow instructions. No data will be erased during this step.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// filesystem I/O error - disk needs repair
|
||||
} else if (error.code === 2) {
|
||||
this.error = {
|
||||
code: 2,
|
||||
problem: 'Filesystem I/O error.',
|
||||
solution:
|
||||
'Repairing the disk could help resolve this issue. This will occur on a restart between the bep and chime. Please DO NOT unplug the drive or Embassy during this time or the situation will become worse.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// disk management error - disk needs repair
|
||||
} else if (error.code === 48) {
|
||||
this.error = {
|
||||
code: 48,
|
||||
problem: 'Disk management error.',
|
||||
solution:
|
||||
'Repairing the disk could help resolve this issue. This will occur on a restart between the bep and chime. Please DO NOT unplug the drive or Embassy during this time or the situation will become worse.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
} else {
|
||||
this.error = {
|
||||
code: error.code,
|
||||
problem: error.message,
|
||||
solution: 'Please conact support.',
|
||||
solution: 'Please contact support.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
}
|
||||
@@ -101,6 +124,53 @@ export class HomePage {
|
||||
}
|
||||
}
|
||||
|
||||
async repairDrive(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.api.repairDisk()
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
async presentAlertRepairDisk() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'RepairDisk',
|
||||
message: new IonicSafeString(
|
||||
`<ion-text color="warning">Warning:</ion-text> This action will attempt to preform a disk repair operation and system reboot. No data will be deleted. This action should only be executed if directed by a Start9 support specialist. We recommend backing up your device before preforming this action. If anything happens to the device during the reboot (between the bep and chime), such as loosing power, a power surge, unplugging the drive, or unplugging the Embassy, the filesystem *will* be in an unrecoverable state. Please proceed with caution.`,
|
||||
),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Repair',
|
||||
handler: () => {
|
||||
try {
|
||||
this.api.repairDisk().then(_ => {
|
||||
this.restart()
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
refreshPage(): void {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
export abstract class ApiService {
|
||||
abstract getError (): Promise<GetErrorRes>
|
||||
abstract restart (): Promise<void>
|
||||
abstract forgetDrive (): Promise<void>
|
||||
abstract getLogs (params: GetLogsReq): Promise<GetLogsRes>
|
||||
abstract getError(): Promise<GetErrorRes>
|
||||
abstract restart(): Promise<void>
|
||||
abstract forgetDrive(): Promise<void>
|
||||
abstract repairDisk(): Promise<void>
|
||||
abstract getLogs(params: GetLogsReq): Promise<GetLogsRes>
|
||||
}
|
||||
|
||||
export interface GetErrorRes {
|
||||
code: number,
|
||||
message: string,
|
||||
code: number
|
||||
message: string
|
||||
data: { details: string }
|
||||
}
|
||||
|
||||
export type GetLogsReq = { cursor?: string, before_flag?: boolean, limit?: number }
|
||||
export type GetLogsReq = {
|
||||
cursor?: string
|
||||
before_flag?: boolean
|
||||
limit?: number
|
||||
}
|
||||
export type GetLogsRes = LogsRes
|
||||
|
||||
export type LogsRes = { entries: Log[], 'start-cursor'?: string, 'end-cursor'?: string }
|
||||
export type LogsRes = {
|
||||
entries: Log[]
|
||||
'start-cursor'?: string
|
||||
'end-cursor'?: string
|
||||
}
|
||||
|
||||
export interface Log {
|
||||
timestamp: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,14 @@ export class LiveApiService extends ApiService {
|
||||
|
||||
forgetDrive(): Promise<void> {
|
||||
return this.http.rpcRequest<void>({
|
||||
method: 'diagnostic.forget-disk',
|
||||
method: 'diagnostic.disk.forget',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
repairDisk(): Promise<void> {
|
||||
return this.http.rpcRequest<void>({
|
||||
method: 'diagnostic.disk.repair',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,6 +33,11 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
async repairDisk(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
return null
|
||||
}
|
||||
|
||||
async getLogs(params: GetLogsReq): Promise<GetLogsRes> {
|
||||
await pauseFor(1000)
|
||||
let entries: Log[]
|
||||
|
||||
7
frontend/projects/marketplace/ng-package.json
Normal file
7
frontend/projects/marketplace/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/marketplace",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
13
frontend/projects/marketplace/package.json
Normal file
13
frontend/projects/marketplace/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@start9labs/marketplace",
|
||||
"version": "0.0.1",
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^13.2.0",
|
||||
"@angular/core": "^13.2.0",
|
||||
"@start9labs/shared": "^0.0.1",
|
||||
"fuse.js": "^6.4.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<ion-button
|
||||
*ngFor="let cat of categories"
|
||||
fill="clear"
|
||||
class="category"
|
||||
[class.category_selected]="cat === category"
|
||||
(click)="switchCategory(cat)"
|
||||
>
|
||||
{{ cat }}
|
||||
</ion-button>
|
||||
@@ -0,0 +1,14 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.category {
|
||||
font-weight: 300;
|
||||
color: var(--ion-color-dark-shade);
|
||||
|
||||
&_selected {
|
||||
font-weight: bold;
|
||||
font-size: 17px;
|
||||
color: var(--color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-categories',
|
||||
templateUrl: 'categories.component.html',
|
||||
styleUrls: ['categories.component.scss'],
|
||||
host: {
|
||||
class: 'hidden-scrollbar ion-text-center',
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CategoriesComponent {
|
||||
@Input()
|
||||
categories = new Set<string>()
|
||||
|
||||
@Input()
|
||||
category = ''
|
||||
|
||||
@Output()
|
||||
readonly categoryChange = new EventEmitter<string>()
|
||||
|
||||
switchCategory(category: string): void {
|
||||
this.category = category
|
||||
this.categoryChange.emit(category)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { CategoriesComponent } from './categories.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule],
|
||||
declarations: [CategoriesComponent],
|
||||
exports: [CategoriesComponent],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
@@ -0,0 +1,10 @@
|
||||
<ion-item [routerLink]="['/marketplace', pkg.manifest.id]">
|
||||
<ion-thumbnail slot="start">
|
||||
<img alt="" [src]="'data:image/png;base64,' + pkg.icon | trustUrl" />
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h2 class="pkg-title">{{ pkg.manifest.title }}</h2>
|
||||
<h3>{{ pkg.manifest.description.short }}</h3>
|
||||
<ng-content></ng-content>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
@@ -0,0 +1,4 @@
|
||||
.pkg-title {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
|
||||
import { MarketplacePkg } from '../../../types/marketplace-pkg'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-item',
|
||||
templateUrl: 'item.component.html',
|
||||
styleUrls: ['item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ItemComponent {
|
||||
@Input()
|
||||
pkg: MarketplacePkg
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
|
||||
import { ItemComponent } from './item.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [IonicModule, RouterModule, SharedPipesModule],
|
||||
declarations: [ItemComponent],
|
||||
exports: [ItemComponent],
|
||||
})
|
||||
export class ItemModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col sizeSm="8" offset-sm="2">
|
||||
<ion-toolbar color="transparent">
|
||||
<ion-searchbar
|
||||
enterkeyhint="search"
|
||||
color="dark"
|
||||
debounce="250"
|
||||
[ngModel]="query"
|
||||
(ngModelChange)="onModelChange($event)"
|
||||
></ion-searchbar>
|
||||
</ion-toolbar>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
@@ -0,0 +1,4 @@
|
||||
:host {
|
||||
display: block;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-search',
|
||||
templateUrl: 'search.component.html',
|
||||
styleUrls: ['search.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SearchComponent {
|
||||
@Input()
|
||||
query = ''
|
||||
|
||||
@Output()
|
||||
readonly queryChange = new EventEmitter<string>()
|
||||
|
||||
onModelChange(query: string) {
|
||||
this.query = query
|
||||
this.queryChange.emit(query)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { SearchComponent } from './search.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [IonicModule, FormsModule],
|
||||
declarations: [SearchComponent],
|
||||
exports: [SearchComponent],
|
||||
})
|
||||
export class SearchModule {}
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="hidden-scrollbar ion-text-center">
|
||||
<ion-button *ngFor="let cat of ['', '', '', '', '', '', '']" fill="clear">
|
||||
<ion-skeleton-text
|
||||
animated
|
||||
style="width: 80px; border-radius: 0"
|
||||
></ion-skeleton-text>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<div class="divider" style="margin: 24px 0"></div>
|
||||
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col
|
||||
*ngFor="let pkg of ['', '', '', '']"
|
||||
sizeXs="12"
|
||||
sizeSm="12"
|
||||
sizeMd="6"
|
||||
>
|
||||
<ion-item>
|
||||
<ion-thumbnail slot="start">
|
||||
<ion-skeleton-text
|
||||
style="border-radius: 100%"
|
||||
animated
|
||||
></ion-skeleton-text>
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<ion-skeleton-text
|
||||
animated
|
||||
style="width: 150px; height: 18px; margin-bottom: 8px"
|
||||
></ion-skeleton-text>
|
||||
<ion-skeleton-text animated style="width: 400px"></ion-skeleton-text>
|
||||
<ion-skeleton-text animated style="width: 100px"></ion-skeleton-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-skeleton',
|
||||
templateUrl: 'skeleton.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SkeletonComponent {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { SkeletonComponent } from './skeleton.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule],
|
||||
declarations: [SkeletonComponent],
|
||||
exports: [SkeletonComponent],
|
||||
})
|
||||
export class SkeletonModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
<ion-content>
|
||||
<ng-container *ngIf="notes$ | async as notes; else loading">
|
||||
<div *ngFor="let note of notes | keyvalue: asIsOrder">
|
||||
<ion-button
|
||||
expand="full"
|
||||
color="light"
|
||||
class="version-button"
|
||||
[class.ion-activated]="isSelected(note.key)"
|
||||
(click)="setSelected(note.key)"
|
||||
>
|
||||
<p class="version">{{ note.key | displayEmver }}</p>
|
||||
</ion-button>
|
||||
<ion-card
|
||||
elementRef
|
||||
#element="elementRef"
|
||||
class="panel"
|
||||
color="light"
|
||||
[id]="note.key"
|
||||
[style.maxHeight.px]="getDocSize(note.key, element)"
|
||||
>
|
||||
<ion-text
|
||||
id="release-notes"
|
||||
[innerHTML]="note.value | markdown"
|
||||
></ion-text>
|
||||
</ion-card>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #loading>
|
||||
<text-spinner text="Loading Release Notes"></text-spinner>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,23 @@
|
||||
:host {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin: 0;
|
||||
padding: 0 24px;
|
||||
transition: max-height 0.2s ease-out;
|
||||
}
|
||||
|
||||
.active {
|
||||
border: 5px solid #4d4d4d;
|
||||
}
|
||||
|
||||
.version-button {
|
||||
height: 50px;
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.version {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChangeDetectionStrategy, Component, ElementRef } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { AbstractMarketplaceService } from '../../services/marketplace.service'
|
||||
|
||||
@Component({
|
||||
selector: 'release-notes',
|
||||
templateUrl: './release-notes.component.html',
|
||||
styleUrls: ['./release-notes.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ReleaseNotesComponent {
|
||||
private readonly pkgId = this.route.snapshot.paramMap.get('pkgId')
|
||||
|
||||
private selected: string | null = null
|
||||
|
||||
readonly notes$ = this.marketplaceService.getReleaseNotes(this.pkgId)
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly marketplaceService: AbstractMarketplaceService,
|
||||
) {}
|
||||
|
||||
isSelected(key: string): boolean {
|
||||
return this.selected === key
|
||||
}
|
||||
|
||||
setSelected(selected: string) {
|
||||
this.selected = this.isSelected(selected) ? null : selected
|
||||
}
|
||||
|
||||
getDocSize(key: string, { nativeElement }: ElementRef<HTMLElement>) {
|
||||
return this.isSelected(key) ? nativeElement.scrollHeight : 0
|
||||
}
|
||||
|
||||
asIsOrder(a: any, b: any) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { MarkdownPage } from './markdown.page'
|
||||
import {
|
||||
EmverPipesModule,
|
||||
MarkdownPipeModule,
|
||||
TextSpinnerComponentModule,
|
||||
ElementModule,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
import { ReleaseNotesComponent } from './release-notes.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [MarkdownPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
MarkdownPipeModule,
|
||||
TextSpinnerComponentModule,
|
||||
EmverPipesModule,
|
||||
MarkdownPipeModule,
|
||||
ElementModule,
|
||||
],
|
||||
exports: [MarkdownPage],
|
||||
declarations: [ReleaseNotesComponent],
|
||||
exports: [ReleaseNotesComponent],
|
||||
})
|
||||
export class MarkdownPageModule {}
|
||||
export class ReleaseNotesModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!-- release notes -->
|
||||
<ion-item-divider>
|
||||
New in {{ pkg.manifest.version | displayEmver }}
|
||||
<ion-button routerLink="notes" class="all-notes" fill="clear" color="dark">
|
||||
All Release Notes
|
||||
<ion-icon slot="end" name="arrow-forward"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
<ion-item lines="none" color="transparent">
|
||||
<ion-label>
|
||||
<div
|
||||
class="release-notes"
|
||||
[innerHTML]="pkg.manifest['release-notes'] | markdown"
|
||||
></div>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<!-- description -->
|
||||
<ion-item-divider>Description</ion-item-divider>
|
||||
<ion-item lines="none" color="transparent">
|
||||
<ion-label>
|
||||
<div class="release-notes">{{ pkg.manifest.description.long }}</div>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
@@ -0,0 +1,9 @@
|
||||
.all-notes {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.release-notes {
|
||||
overflow: auto;
|
||||
max-height: 120px;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
|
||||
import { MarketplacePkg } from '../../../types/marketplace-pkg'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-about',
|
||||
templateUrl: 'about.component.html',
|
||||
styleUrls: ['about.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AboutComponent {
|
||||
@Input()
|
||||
pkg: MarketplacePkg
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { EmverPipesModule, MarkdownPipeModule } from '@start9labs/shared'
|
||||
|
||||
import { AboutComponent } from './about.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
IonicModule,
|
||||
MarkdownPipeModule,
|
||||
EmverPipesModule,
|
||||
],
|
||||
declarations: [AboutComponent],
|
||||
exports: [AboutComponent],
|
||||
})
|
||||
export class AboutModule {}
|
||||
@@ -0,0 +1,76 @@
|
||||
<ion-item-divider>Additional Info</ion-item-divider>
|
||||
<ion-card>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col sizeSm="12" sizeMd="6">
|
||||
<ion-item-group>
|
||||
<ion-item button detail="false" (click)="presentAlertVersions()">
|
||||
<ion-label>
|
||||
<h2>Other Versions</h2>
|
||||
<p>Click to view other versions</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="chevron-forward"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item button detail="false" (click)="presentModalMd('license')">
|
||||
<ion-label>
|
||||
<h2>License</h2>
|
||||
<p>{{ pkg.manifest.license }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="chevron-forward"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
button
|
||||
detail="false"
|
||||
(click)="presentModalMd('instructions')"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Instructions</h2>
|
||||
<p>Click to view instructions</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="chevron-forward"></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-col>
|
||||
<ion-col sizeSm="12" sizeMd="6">
|
||||
<ion-item-group>
|
||||
<ion-item
|
||||
[href]="pkg.manifest['upstream-repo']"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Source Repository</h2>
|
||||
<p>{{ pkg.manifest['upstream-repo'] }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
[href]="pkg.manifest['wrapper-repo']"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Wrapper Repository</h2>
|
||||
<p>{{ pkg.manifest['wrapper-repo'] }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
[href]="pkg.manifest['support-site']"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Support Site</h2>
|
||||
<p>{{ pkg.manifest['support-site'] }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-card>
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
import { AlertController, ModalController } from '@ionic/angular'
|
||||
import { displayEmver, Emver, MarkdownComponent } from '@start9labs/shared'
|
||||
|
||||
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
|
||||
import { MarketplacePkg } from '../../../types/marketplace-pkg'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-additional',
|
||||
templateUrl: 'additional.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AdditionalComponent {
|
||||
@Input()
|
||||
pkg: MarketplacePkg
|
||||
|
||||
@Output()
|
||||
version = new EventEmitter<string>()
|
||||
|
||||
constructor(
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly emver: Emver,
|
||||
private readonly marketplaceService: AbstractMarketplaceService,
|
||||
) {}
|
||||
|
||||
async presentAlertVersions() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Versions',
|
||||
inputs: this.pkg.versions
|
||||
.sort((a, b) => -1 * this.emver.compare(a, b))
|
||||
.map(v => ({
|
||||
name: v, // for CSS
|
||||
type: 'radio',
|
||||
label: displayEmver(v), // appearance on screen
|
||||
value: v, // literal SEM version value
|
||||
checked: this.pkg.manifest.version === v,
|
||||
})),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Ok',
|
||||
handler: (version: string) => this.version.emit(version),
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async presentModalMd(title: string) {
|
||||
const content = this.marketplaceService.getPackageMarkdown(
|
||||
title,
|
||||
this.pkg.manifest.id,
|
||||
)
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: { title, content },
|
||||
component: MarkdownComponent,
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { MarkdownModule } from '@start9labs/shared'
|
||||
|
||||
import { AdditionalComponent } from './additional.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [IonicModule, MarkdownModule],
|
||||
declarations: [AdditionalComponent],
|
||||
exports: [AdditionalComponent],
|
||||
})
|
||||
export class AdditionalModule {}
|
||||
@@ -0,0 +1,30 @@
|
||||
<ion-item-divider>Dependencies</ion-item-divider>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col
|
||||
*ngFor="let dep of pkg.manifest.dependencies | keyvalue"
|
||||
sizeSm="12"
|
||||
sizeMd="6"
|
||||
>
|
||||
<ion-item [routerLink]="['/marketplace', dep.key]">
|
||||
<ion-thumbnail slot="start">
|
||||
<img alt="" [src]="getImg(dep.key) | trustUrl" />
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h2>
|
||||
{{ pkg['dependency-metadata'][dep.key].title }}
|
||||
<ng-container [ngSwitch]="dep.value.requirement.type">
|
||||
<span *ngSwitchCase="'required'">(required)</span>
|
||||
<span *ngSwitchCase="'opt-out'">(required by default)</span>
|
||||
<span *ngSwitchCase="'opt-in'">(optional)</span>
|
||||
</ng-container>
|
||||
</h2>
|
||||
<p>
|
||||
<small>{{ dep.value.version | displayEmver }}</small>
|
||||
</p>
|
||||
<p>{{ dep.value.description }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
|
||||
import { MarketplacePkg } from '../../../types/marketplace-pkg'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-dependencies',
|
||||
templateUrl: 'dependencies.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DependenciesComponent {
|
||||
@Input()
|
||||
pkg: MarketplacePkg
|
||||
|
||||
getImg(key: string): string {
|
||||
return 'data:image/png;base64,' + this.pkg['dependency-metadata'][key].icon
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { EmverPipesModule, SharedPipesModule } from '@start9labs/shared'
|
||||
|
||||
import { DependenciesComponent } from './dependencies.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
IonicModule,
|
||||
SharedPipesModule,
|
||||
EmverPipesModule,
|
||||
],
|
||||
declarations: [DependenciesComponent],
|
||||
exports: [DependenciesComponent],
|
||||
})
|
||||
export class DependenciesModule {}
|
||||
@@ -0,0 +1,22 @@
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col sizeXs="12" sizeMd="9">
|
||||
<div class="header">
|
||||
<img
|
||||
class="logo"
|
||||
alt=""
|
||||
[src]="'data:image/png;base64,' + pkg.icon | trustUrl"
|
||||
/>
|
||||
<div class="text">
|
||||
<h1 class="title">{{ pkg.manifest.title }}</h1>
|
||||
<p class="version">{{ pkg.manifest.version | displayEmver }}</p>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</ion-col>
|
||||
<ion-col sizeMd="3" sizeXs="12" class="ion-align-self-center">
|
||||
<ng-content select="[position=controls]"></ng-content>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
<ng-content select="ion-row"></ng-content>
|
||||
</ion-grid>
|
||||
@@ -0,0 +1,26 @@
|
||||
.header {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
padding: 2%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
min-width: 15%;
|
||||
max-width: 18%;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-left: 5%;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 0 -2px;
|
||||
font-size: calc(20px + 3vw);
|
||||
}
|
||||
|
||||
.version {
|
||||
padding: 4px 0 12px 0;
|
||||
margin: 0;
|
||||
font-size: calc(10px + 1vw);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
|
||||
import { MarketplacePkg } from '../../../types/marketplace-pkg'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-package',
|
||||
templateUrl: 'package.component.html',
|
||||
styleUrls: ['package.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PackageComponent {
|
||||
@Input()
|
||||
pkg: MarketplacePkg
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { EmverPipesModule, SharedPipesModule } from '@start9labs/shared'
|
||||
|
||||
import { PackageComponent } from './package.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [IonicModule, SharedPipesModule, EmverPipesModule],
|
||||
declarations: [PackageComponent],
|
||||
exports: [PackageComponent],
|
||||
})
|
||||
export class PackageModule {}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NgModule, Pipe, PipeTransform } from '@angular/core'
|
||||
import Fuse from 'fuse.js/dist/fuse.min.js'
|
||||
|
||||
import { MarketplacePkg } from '../types/marketplace-pkg'
|
||||
import { MarketplaceManifest } from '../types/marketplace-manifest'
|
||||
|
||||
const defaultOps = {
|
||||
isCaseSensitive: false,
|
||||
includeScore: true,
|
||||
shouldSort: true,
|
||||
includeMatches: false,
|
||||
findAllMatches: false,
|
||||
minMatchCharLength: 1,
|
||||
location: 0,
|
||||
threshold: 0.6,
|
||||
distance: 100,
|
||||
useExtendedSearch: false,
|
||||
ignoreLocation: false,
|
||||
ignoreFieldNorm: false,
|
||||
keys: [
|
||||
'manifest.id',
|
||||
'manifest.title',
|
||||
'manifest.description.short',
|
||||
'manifest.description.long',
|
||||
],
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'filterPackages',
|
||||
})
|
||||
export class FilterPackagesPipe implements PipeTransform {
|
||||
transform(
|
||||
packages: MarketplacePkg[] | null,
|
||||
query: string,
|
||||
category: string,
|
||||
local: Record<string, { manifest: MarketplaceManifest }> = {},
|
||||
): MarketplacePkg[] | null {
|
||||
if (!packages) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const fuse = new Fuse(packages, defaultOps)
|
||||
|
||||
return fuse.search(query).map(p => p.item)
|
||||
}
|
||||
|
||||
if (category === 'updates') {
|
||||
return packages.filter(
|
||||
({ manifest }) =>
|
||||
local[manifest.id] &&
|
||||
manifest.version !== local[manifest.id].manifest.version,
|
||||
)
|
||||
}
|
||||
|
||||
const pkgsToSort = packages.filter(
|
||||
p => category === 'all' || p.categories.includes(category),
|
||||
)
|
||||
const fuse = new Fuse(pkgsToSort, { ...defaultOps, threshold: 1 })
|
||||
|
||||
return fuse
|
||||
.search(category !== 'all' ? category || '' : 'bit')
|
||||
.map(p => p.item)
|
||||
}
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
declarations: [FilterPackagesPipe],
|
||||
exports: [FilterPackagesPipe],
|
||||
})
|
||||
export class FilterPackagesPipeModule {}
|
||||
32
frontend/projects/marketplace/src/public-api.ts
Normal file
32
frontend/projects/marketplace/src/public-api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Public API Surface of @start9labs/marketplace
|
||||
*/
|
||||
|
||||
export * from './pages/list/categories/categories.component'
|
||||
export * from './pages/list/categories/categories.module'
|
||||
export * from './pages/list/item/item.component'
|
||||
export * from './pages/list/item/item.module'
|
||||
export * from './pages/list/search/search.component'
|
||||
export * from './pages/list/search/search.module'
|
||||
export * from './pages/list/skeleton/skeleton.component'
|
||||
export * from './pages/list/skeleton/skeleton.module'
|
||||
export * from './pages/release-notes/release-notes.component'
|
||||
export * from './pages/release-notes/release-notes.module'
|
||||
export * from './pages/show/about/about.component'
|
||||
export * from './pages/show/about/about.module'
|
||||
export * from './pages/show/additional/additional.component'
|
||||
export * from './pages/show/additional/additional.module'
|
||||
export * from './pages/show/dependencies/dependencies.component'
|
||||
export * from './pages/show/dependencies/dependencies.module'
|
||||
export * from './pages/show/package/package.component'
|
||||
export * from './pages/show/package/package.module'
|
||||
|
||||
export * from './pipes/filter-packages.pipe'
|
||||
|
||||
export * from './services/marketplace.service'
|
||||
|
||||
export * from './types/dependency'
|
||||
export * from './types/marketplace'
|
||||
export * from './types/marketplace-data'
|
||||
export * from './types/marketplace-manifest'
|
||||
export * from './types/marketplace-pkg'
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Observable } from 'rxjs'
|
||||
import { MarketplacePkg } from '../types/marketplace-pkg'
|
||||
import { Marketplace } from '../types/marketplace'
|
||||
|
||||
export abstract class AbstractMarketplaceService {
|
||||
abstract install(id: string, version?: string): Observable<unknown>
|
||||
|
||||
abstract getMarketplace(): Observable<Marketplace>
|
||||
|
||||
abstract getReleaseNotes(id: string): Observable<Record<string, string>>
|
||||
|
||||
abstract getCategories(): Observable<string[]>
|
||||
|
||||
abstract getPackages(): Observable<MarketplacePkg[]>
|
||||
|
||||
abstract getPackageMarkdown(type: string, pkgId: string): Observable<string>
|
||||
|
||||
abstract getPackage(id: string, version: string): Observable<MarketplacePkg>
|
||||
}
|
||||
17
frontend/projects/marketplace/src/types/dependency.ts
Normal file
17
frontend/projects/marketplace/src/types/dependency.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface Dependency<T> {
|
||||
version: string
|
||||
requirement:
|
||||
| {
|
||||
type: 'opt-in'
|
||||
how: string
|
||||
}
|
||||
| {
|
||||
type: 'opt-out'
|
||||
how: string
|
||||
}
|
||||
| {
|
||||
type: 'required'
|
||||
}
|
||||
description: string | null
|
||||
config: T
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MarketplaceData {
|
||||
categories: string[]
|
||||
name: string
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Url } from '@start9labs/shared'
|
||||
|
||||
import { Dependency } from './dependency'
|
||||
|
||||
export interface MarketplaceManifest<T = unknown> {
|
||||
id: string
|
||||
title: string
|
||||
version: string
|
||||
description: {
|
||||
short: string
|
||||
long: string
|
||||
}
|
||||
'release-notes': string
|
||||
license: string // name
|
||||
'wrapper-repo': Url
|
||||
'upstream-repo': Url
|
||||
'support-site': Url
|
||||
'marketing-site': Url
|
||||
'donation-url': Url | null
|
||||
alerts: {
|
||||
install: string | null
|
||||
uninstall: string | null
|
||||
restore: string | null
|
||||
start: string | null
|
||||
stop: string | null
|
||||
}
|
||||
dependencies: Record<string, Dependency<T>>
|
||||
}
|
||||
17
frontend/projects/marketplace/src/types/marketplace-pkg.ts
Normal file
17
frontend/projects/marketplace/src/types/marketplace-pkg.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Url } from '@start9labs/shared'
|
||||
import { MarketplaceManifest } from './marketplace-manifest'
|
||||
|
||||
export interface MarketplacePkg {
|
||||
icon: Url
|
||||
license: Url
|
||||
instructions: Url
|
||||
manifest: MarketplaceManifest
|
||||
categories: string[]
|
||||
versions: string[]
|
||||
'dependency-metadata': {
|
||||
[id: string]: {
|
||||
title: string
|
||||
icon: Url
|
||||
}
|
||||
}
|
||||
}
|
||||
4
frontend/projects/marketplace/src/types/marketplace.ts
Normal file
4
frontend/projects/marketplace/src/types/marketplace.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Marketplace {
|
||||
url: string
|
||||
name: string
|
||||
}
|
||||
13
frontend/projects/marketplace/tsconfig.json
Normal file
13
frontend/projects/marketplace/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "../../out-tsc/lib",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": ["src/test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
10
frontend/projects/marketplace/tsconfig.prod.json
Normal file
10
frontend/projects/marketplace/tsconfig.prod.json
Normal file
@@ -0,0 +1,10 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.lib.json",
|
||||
"compilerOptions": {
|
||||
"declarationMap": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"compilationMode": "partial"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { ErrorToastService } from './services/error-toast.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from './services/state.service'
|
||||
|
||||
@Component({
|
||||
@@ -30,7 +30,7 @@ export class AppComponent {
|
||||
await this.navCtrl.navigateForward(`/recover`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(e.message)
|
||||
this.errorToastService.present(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DiskInfo,
|
||||
DiskRecoverySource,
|
||||
} from 'src/app/services/api/api.service'
|
||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from '../../modals/password/password.page'
|
||||
|
||||
@@ -74,7 +74,7 @@ export class EmbassyPage {
|
||||
await alert.present()
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(e.message)
|
||||
this.errorToastService.present(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
@@ -142,9 +142,9 @@ export class EmbassyPage {
|
||||
await this.navCtrl.navigateForward(`/success`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(
|
||||
`${e.message}\n\nRestart Embassy to try again.`,
|
||||
)
|
||||
this.errorToastService.present({
|
||||
message: `${e.message}\n\nRestart Embassy to try again.`,
|
||||
})
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@ionic/angular'
|
||||
import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page'
|
||||
import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service'
|
||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from '../../modals/password/password.page'
|
||||
import { ProdKeyModal } from '../../modals/prod-key-modal/prod-key-modal.page'
|
||||
@@ -120,7 +120,7 @@ export class RecoverPage {
|
||||
this.hasShownGuidAlert = true
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(e.message)
|
||||
this.errorToastService.present(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
@@ -206,7 +206,7 @@ export class RecoverPage {
|
||||
await this.stateService.importDrive(guid)
|
||||
await this.navCtrl.navigateForward(`/success`)
|
||||
} catch (e) {
|
||||
this.errorToastService.present(e.message)
|
||||
this.errorToastService.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core'
|
||||
import { ToastController } from '@ionic/angular'
|
||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ToastController } from '@ionic/angular'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ErrorToastService {
|
||||
private toast: HTMLIonToastElement
|
||||
|
||||
constructor (
|
||||
private readonly toastCtrl: ToastController,
|
||||
) { }
|
||||
|
||||
async present (message: string): Promise<void> {
|
||||
if (this.toast) return
|
||||
|
||||
this.toast = await this.toastCtrl.create({
|
||||
header: 'Error',
|
||||
message,
|
||||
duration: 0,
|
||||
position: 'top',
|
||||
cssClass: 'error-toast',
|
||||
animated: true,
|
||||
buttons: [
|
||||
{
|
||||
side: 'end',
|
||||
icon: 'close',
|
||||
handler: () => {
|
||||
this.dismiss()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await this.toast.present()
|
||||
}
|
||||
|
||||
async dismiss (): Promise<void> {
|
||||
if (this.toast) {
|
||||
await this.toast.dismiss()
|
||||
this.toast = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
CifsRecoverySource,
|
||||
DiskRecoverySource,
|
||||
} from './api/api.service'
|
||||
import { ErrorToastService } from './error-toast.service'
|
||||
import { pauseFor } from '@start9labs/shared'
|
||||
import { pauseFor, ErrorToastService } from '@start9labs/shared'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -51,9 +50,9 @@ export class StateService {
|
||||
try {
|
||||
progress = await this.apiService.getRecoveryStatus()
|
||||
} catch (e) {
|
||||
this.errorToastService.present(
|
||||
`${e.message}\n\nRestart Embassy to try again.`,
|
||||
)
|
||||
this.errorToastService.present({
|
||||
message: `${e.message}\n\nRestart Embassy to try again.`,
|
||||
})
|
||||
}
|
||||
if (progress) {
|
||||
this.dataTransferProgress = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/shared",
|
||||
"assets": ["styles"],
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^13.2.0",
|
||||
"@angular/core": "^13.2.0",
|
||||
"@ionic/angular": "^6.0.3",
|
||||
"@start9labs/emver": "^0.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ title | titlecase }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item *ngIf="error$ | async as error">
|
||||
<ion-label>
|
||||
<ion-text safeLinks color="danger">{{ error }}</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<div
|
||||
*ngIf="content$ | async as result; else loading"
|
||||
safeLinks
|
||||
class="content-padding"
|
||||
[innerHTML]="result | markdown"
|
||||
></div>
|
||||
|
||||
<ng-template #loading>
|
||||
<text-spinner [text]="'Loading ' + title | titlecase"></text-spinner>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { defer, isObservable, Observable, of } from 'rxjs'
|
||||
import { catchError, ignoreElements, share } from 'rxjs/operators'
|
||||
|
||||
import { getErrorMessage } from '../../services/error-toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'markdown',
|
||||
templateUrl: './markdown.component.html',
|
||||
styleUrls: ['./markdown.component.scss'],
|
||||
})
|
||||
export class MarkdownComponent {
|
||||
@Input() content?: string | Observable<string>
|
||||
@Input() title = ''
|
||||
|
||||
private readonly data$ = defer(() =>
|
||||
isObservable(this.content) ? this.content : of(this.content),
|
||||
).pipe(share())
|
||||
|
||||
readonly error$ = this.data$.pipe(
|
||||
ignoreElements(),
|
||||
catchError(e => of(getErrorMessage(e))),
|
||||
)
|
||||
|
||||
readonly content$ = this.data$.pipe(catchError(() => of([])))
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
async dismiss() {
|
||||
return this.modalCtrl.dismiss(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { MarkdownPipeModule } from '../../pipes/markdown/markdown.module'
|
||||
import { SafeLinksModule } from '../../directives/safe-links/safe-links.module'
|
||||
import { TextSpinnerComponentModule } from '../text-spinner/text-spinner.component.module'
|
||||
import { MarkdownComponent } from './markdown.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [MarkdownComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
MarkdownPipeModule,
|
||||
TextSpinnerComponentModule,
|
||||
SafeLinksModule,
|
||||
],
|
||||
exports: [MarkdownComponent],
|
||||
})
|
||||
export class MarkdownModule {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Directive, ElementRef, Inject } from '@angular/core'
|
||||
|
||||
@Directive({
|
||||
selector: '[elementRef]',
|
||||
exportAs: 'elementRef',
|
||||
})
|
||||
export class ElementDirective<T extends Element> extends ElementRef<T> {
|
||||
constructor(@Inject(ElementRef) { nativeElement }: ElementRef<T>) {
|
||||
super(nativeElement)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
|
||||
import { ElementDirective } from './element.directive'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ElementDirective],
|
||||
exports: [ElementDirective],
|
||||
})
|
||||
export class ElementModule {}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { AfterViewInit, Directive, ElementRef, Inject } from '@angular/core'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
|
||||
// TODO: Refactor to use `MutationObserver` so it works with dynamic content
|
||||
@Directive({
|
||||
selector: '[safeLinks]',
|
||||
})
|
||||
export class SafeLinksDirective implements AfterViewInit {
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
private readonly elementRef: ElementRef<HTMLElement>,
|
||||
) {}
|
||||
|
||||
ngAfterViewInit() {
|
||||
Array.from(this.document.links)
|
||||
.filter(
|
||||
link =>
|
||||
link.hostname !== this.document.location.hostname &&
|
||||
this.elementRef.nativeElement.contains(link),
|
||||
)
|
||||
.forEach(link => {
|
||||
link.target = '_blank'
|
||||
link.setAttribute('rel', 'noreferrer')
|
||||
link.classList.add('externalLink')
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user