mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 12:33:40 +00:00
Compare commits
10 Commits
feat/resta
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
653a0a1428 | ||
|
|
0b004a19ae | ||
|
|
ce1da028ce | ||
|
|
0d4dcf6c61 | ||
|
|
8359712cd9 | ||
|
|
f46cdc6ee5 | ||
|
|
c96b38f915 | ||
|
|
c1c8dc8f9c | ||
|
|
e3b7277ccd | ||
|
|
b0b4b41c42 |
43
.github/workflows/startos-iso.yaml
vendored
43
.github/workflows/startos-iso.yaml
vendored
@@ -29,7 +29,7 @@ on:
|
||||
- aarch64
|
||||
- aarch64-nonfree
|
||||
- aarch64-nvidia
|
||||
# - raspberrypi
|
||||
- raspberrypi
|
||||
- riscv64
|
||||
- riscv64-nonfree
|
||||
deploy:
|
||||
@@ -296,6 +296,18 @@ jobs:
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Determine platforms
|
||||
id: platforms
|
||||
run: |
|
||||
INPUT="${{ github.event.inputs.platform }}"
|
||||
if [ "$INPUT" = "ALL" ]; then
|
||||
PLATFORMS="x86_64 x86_64-nonfree x86_64-nvidia aarch64 aarch64-nonfree aarch64-nvidia riscv64 riscv64-nonfree"
|
||||
else
|
||||
PLATFORMS="$INPUT"
|
||||
fi
|
||||
echo "list=$PLATFORMS" >> "$GITHUB_OUTPUT"
|
||||
echo "Platforms: $PLATFORMS"
|
||||
|
||||
- name: Download squashfs artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -347,10 +359,12 @@ jobs:
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
cd artifacts
|
||||
for file in *.iso *.squashfs; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file..."
|
||||
s3cmd put -P "$file" "${{ env.S3_BUCKET }}/v${VERSION}/$file"
|
||||
for PLATFORM in ${{ steps.platforms.outputs.list }}; do
|
||||
for file in *_${PLATFORM}.squashfs *_${PLATFORM}.iso; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file..."
|
||||
s3cmd put -P "$file" "${{ env.S3_BUCKET }}/v${VERSION}/$file"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Register OS version
|
||||
@@ -363,13 +377,14 @@ jobs:
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
cd artifacts
|
||||
for file in *.squashfs *.iso; do
|
||||
[ -f "$file" ] || continue
|
||||
PLATFORM=$(echo "$file" | sed 's/.*_\([^.]*\)\.\(squashfs\|iso\)$/\1/')
|
||||
echo "Indexing $file for platform $PLATFORM..."
|
||||
start-cli --registry="${{ env.REGISTRY }}" registry os asset add \
|
||||
--platform="$PLATFORM" \
|
||||
--version="$VERSION" \
|
||||
"$file" \
|
||||
"${{ env.S3_CDN }}/v${VERSION}/$file"
|
||||
for PLATFORM in ${{ steps.platforms.outputs.list }}; do
|
||||
for file in *_${PLATFORM}.squashfs *_${PLATFORM}.iso; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Indexing $file for platform $PLATFORM..."
|
||||
start-cli --registry="${{ env.REGISTRY }}" registry os asset add \
|
||||
--platform="$PLATFORM" \
|
||||
--version="$VERSION" \
|
||||
"$file" \
|
||||
"${{ env.S3_CDN }}/v${VERSION}/$file"
|
||||
done
|
||||
done
|
||||
|
||||
@@ -58,15 +58,18 @@ iptables -t nat -A ${NAME}_OUTPUT -d "$sip" -p udp --dport "$sport" -j DNAT --to
|
||||
iptables -A ${NAME}_FORWARD -d $dip -p tcp --dport $dport -m state --state NEW -j ACCEPT
|
||||
iptables -A ${NAME}_FORWARD -d $dip -p udp --dport $dport -m state --state NEW -j ACCEPT
|
||||
|
||||
# NAT hairpin: masquerade traffic from the bridge subnet or host to the DNAT
|
||||
# target, so replies route back through the host for proper NAT reversal.
|
||||
# Container-to-container hairpin (source is on the bridge subnet)
|
||||
if [ -n "$bridge_subnet" ]; then
|
||||
iptables -t nat -A ${NAME}_POSTROUTING -s "$bridge_subnet" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
|
||||
iptables -t nat -A ${NAME}_POSTROUTING -s "$bridge_subnet" -d "$dip" -p udp --dport "$dport" -j MASQUERADE
|
||||
fi
|
||||
# Host-to-container hairpin (host connects to its own gateway IP, source is sip)
|
||||
iptables -t nat -A ${NAME}_POSTROUTING -s "$sip" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
|
||||
iptables -t nat -A ${NAME}_POSTROUTING -s "$sip" -d "$dip" -p udp --dport "$dport" -j MASQUERADE
|
||||
# NAT hairpin: masquerade so replies route back through this host for proper
|
||||
# NAT reversal instead of taking a direct path that bypasses conntrack.
|
||||
# Host-to-target hairpin: locally-originated packets whose original destination
|
||||
# was sip (before OUTPUT DNAT rewrote it to dip). Using --ctorigdst ties the
|
||||
# rule to this specific sip, so multiple WAN IPs forwarding the same port to
|
||||
# different targets each get their own masquerade.
|
||||
iptables -t nat -A ${NAME}_POSTROUTING -m addrtype --src-type LOCAL -m conntrack --ctorigdst "$sip" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
|
||||
iptables -t nat -A ${NAME}_POSTROUTING -m addrtype --src-type LOCAL -m conntrack --ctorigdst "$sip" -d "$dip" -p udp --dport "$dport" -j MASQUERADE
|
||||
# Same-subnet hairpin: when traffic originates from the same subnet as the DNAT
|
||||
# target (e.g. a container reaching another container, or a WireGuard peer
|
||||
# connecting to itself via the tunnel's public IP).
|
||||
iptables -t nat -A ${NAME}_POSTROUTING -s "$dip/$dprefix" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
|
||||
iptables -t nat -A ${NAME}_POSTROUTING -s "$dip/$dprefix" -d "$dip" -p udp --dport "$dport" -j MASQUERADE
|
||||
|
||||
exit $err
|
||||
|
||||
@@ -125,10 +125,10 @@ impl Public {
|
||||
},
|
||||
status_info: ServerStatus {
|
||||
backup_progress: None,
|
||||
updated: false,
|
||||
update_progress: None,
|
||||
shutting_down: false,
|
||||
restarting: false,
|
||||
restart: None,
|
||||
},
|
||||
unread_notification_count: 0,
|
||||
password_hash: account.password.clone(),
|
||||
@@ -220,6 +220,16 @@ pub struct ServerInfo {
|
||||
pub keyboard: Option<KeyboardOptions>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum RestartReason {
|
||||
Mdns,
|
||||
Language,
|
||||
Kiosk,
|
||||
Update,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[model = "Model<Self>"]
|
||||
@@ -364,12 +374,13 @@ pub struct BackupProgress {
|
||||
#[ts(export)]
|
||||
pub struct ServerStatus {
|
||||
pub backup_progress: Option<BTreeMap<PackageId, BackupProgress>>,
|
||||
pub updated: bool,
|
||||
pub update_progress: Option<FullProgress>,
|
||||
#[serde(default)]
|
||||
pub shutting_down: bool,
|
||||
#[serde(default)]
|
||||
pub restarting: bool,
|
||||
#[serde(default)]
|
||||
pub restart: Option<RestartReason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
|
||||
|
||||
@@ -7,7 +7,7 @@ use tracing::instrument;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::context::RpcContext;
|
||||
use crate::db::model::public::ServerInfo;
|
||||
use crate::db::model::public::{RestartReason, ServerInfo};
|
||||
use crate::prelude::*;
|
||||
use crate::util::Invoke;
|
||||
|
||||
@@ -272,6 +272,7 @@ pub async fn set_hostname_rpc(
|
||||
}
|
||||
if let Some(hostname) = &hostname {
|
||||
hostname.save(server_info)?;
|
||||
server_info.as_status_info_mut().as_restart_mut().ser(&Some(RestartReason::Mdns))?;
|
||||
}
|
||||
ServerHostnameInfo::load(server_info)
|
||||
})
|
||||
|
||||
@@ -371,11 +371,11 @@ pub async fn init(
|
||||
let ram = get_mem_info().await?.total.0 as u64 * 1024 * 1024;
|
||||
let devices = lshw().await?;
|
||||
let status_info = ServerStatus {
|
||||
updated: false,
|
||||
update_progress: None,
|
||||
backup_progress: None,
|
||||
shutting_down: false,
|
||||
restarting: false,
|
||||
restart: None,
|
||||
};
|
||||
db.mutate(|v| {
|
||||
let server_info = v.as_public_mut().as_server_info_mut();
|
||||
|
||||
@@ -241,11 +241,19 @@ pub async fn check_port(
|
||||
.await
|
||||
.map_or(false, |r| r.is_ok());
|
||||
|
||||
let local_ipv4 = ip_info
|
||||
.subnets
|
||||
.iter()
|
||||
.find_map(|s| match s.addr() {
|
||||
IpAddr::V4(v4) => Some(v4),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(Ipv4Addr::UNSPECIFIED);
|
||||
let client = reqwest::Client::builder();
|
||||
#[cfg(target_os = "linux")]
|
||||
let client = client
|
||||
.interface(gateway.as_str())
|
||||
.local_address(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
|
||||
.local_address(IpAddr::V4(local_ipv4));
|
||||
let client = client.build()?;
|
||||
|
||||
let mut res = None;
|
||||
@@ -282,12 +290,7 @@ pub async fn check_port(
|
||||
));
|
||||
};
|
||||
|
||||
let hairpinning = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
tokio::net::TcpStream::connect(SocketAddr::new(ip.into(), port)),
|
||||
)
|
||||
.await
|
||||
.map_or(false, |r| r.is_ok());
|
||||
let hairpinning = check_hairpin(gateway, local_ipv4, ip, port).await;
|
||||
|
||||
Ok(CheckPortRes {
|
||||
ip,
|
||||
@@ -298,6 +301,30 @@ pub async fn check_port(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn check_hairpin(gateway: GatewayId, local_ipv4: Ipv4Addr, ip: Ipv4Addr, port: u16) -> bool {
|
||||
let hairpinning = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let dest = SocketAddr::new(ip.into(), port);
|
||||
let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None)?;
|
||||
socket.bind_device(Some(gateway.as_str().as_bytes()))?;
|
||||
socket.bind(&SocketAddr::new(IpAddr::V4(local_ipv4), 0).into())?;
|
||||
socket.set_nonblocking(true)?;
|
||||
let socket = unsafe {
|
||||
use std::os::fd::{FromRawFd, IntoRawFd};
|
||||
tokio::net::TcpSocket::from_raw_fd(socket.into_raw_fd())
|
||||
};
|
||||
socket.connect(dest).await.map(|_| ())
|
||||
})
|
||||
.await
|
||||
.map_or(false, |r| r.is_ok());
|
||||
hairpinning
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
async fn check_hairpin(_: GatewayId, _: Ipv4Addr, _: Ipv4Addr, _: u16) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
|
||||
#[group(skip)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -784,12 +811,16 @@ async fn watcher(
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_wan_ipv4(iface: &str, base_url: &Url) -> Result<Option<Ipv4Addr>, Error> {
|
||||
async fn get_wan_ipv4(
|
||||
iface: &str,
|
||||
base_url: &Url,
|
||||
local_ipv4: Ipv4Addr,
|
||||
) -> Result<Option<Ipv4Addr>, Error> {
|
||||
let client = reqwest::Client::builder();
|
||||
#[cfg(target_os = "linux")]
|
||||
let client = client
|
||||
.interface(iface)
|
||||
.local_address(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
|
||||
.local_address(IpAddr::V4(local_ipv4));
|
||||
let url = base_url.join("/ip").with_kind(ErrorKind::ParseUrl)?;
|
||||
let text = client
|
||||
.build()?
|
||||
@@ -1412,7 +1443,14 @@ async fn poll_ip_info(
|
||||
Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback)
|
||||
)
|
||||
{
|
||||
match get_wan_ipv4(iface.as_str(), &echoip_url).await {
|
||||
let local_ipv4 = subnets
|
||||
.iter()
|
||||
.find_map(|s| match s.addr() {
|
||||
IpAddr::V4(v4) => Some(v4),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(Ipv4Addr::UNSPECIFIED);
|
||||
match get_wan_ipv4(iface.as_str(), &echoip_url, local_ipv4).await {
|
||||
Ok(a) => {
|
||||
wan_ip = a;
|
||||
}
|
||||
|
||||
@@ -615,6 +615,7 @@ fn check_matching_info_short() {
|
||||
sdk_version: None,
|
||||
hardware_acceleration: false,
|
||||
plugins: BTreeSet::new(),
|
||||
satisfies: BTreeSet::new(),
|
||||
},
|
||||
icon: DataUrl::from_vec("image/png", vec![]),
|
||||
dependency_metadata: BTreeMap::new(),
|
||||
|
||||
@@ -110,6 +110,8 @@ pub struct PackageMetadata {
|
||||
pub hardware_acceleration: bool,
|
||||
#[serde(default)]
|
||||
pub plugins: BTreeSet<PluginId>,
|
||||
#[serde(default)]
|
||||
pub satisfies: BTreeSet<VersionString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, HasModel, TS)]
|
||||
|
||||
@@ -197,7 +197,6 @@ impl TryFrom<ManifestV1> for Manifest {
|
||||
Ok(Self {
|
||||
id: value.id,
|
||||
version: version.into(),
|
||||
satisfies: BTreeSet::new(),
|
||||
can_migrate_from: VersionRange::any(),
|
||||
can_migrate_to: VersionRange::none(),
|
||||
metadata: PackageMetadata {
|
||||
@@ -219,6 +218,7 @@ impl TryFrom<ManifestV1> for Manifest {
|
||||
PackageProcedure::Script(_) => false,
|
||||
},
|
||||
plugins: BTreeSet::new(),
|
||||
satisfies: BTreeSet::new(),
|
||||
},
|
||||
images: BTreeMap::new(),
|
||||
volumes: value
|
||||
|
||||
@@ -32,7 +32,6 @@ pub(crate) fn current_version() -> Version {
|
||||
pub struct Manifest {
|
||||
pub id: PackageId,
|
||||
pub version: VersionString,
|
||||
pub satisfies: BTreeSet<VersionString>,
|
||||
#[ts(type = "string")]
|
||||
pub can_migrate_to: VersionRange,
|
||||
#[ts(type = "string")]
|
||||
|
||||
@@ -358,7 +358,7 @@ pub async fn check_dependencies(
|
||||
};
|
||||
let manifest = package.as_state_info().as_manifest(ManifestPreference::New);
|
||||
let installed_version = manifest.as_version().de()?.into_version();
|
||||
let satisfies = manifest.as_satisfies().de()?;
|
||||
let satisfies = manifest.as_metadata().as_satisfies().de()?;
|
||||
let installed_version = Some(installed_version.clone().into());
|
||||
let is_running = package
|
||||
.as_status_info()
|
||||
|
||||
@@ -16,6 +16,7 @@ use ts_rs::TS;
|
||||
|
||||
use crate::bins::set_locale;
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
use crate::db::model::public::RestartReason;
|
||||
use crate::disk::util::{get_available, get_used};
|
||||
use crate::logs::{LogSource, LogsParams, SYSTEM_UNIT};
|
||||
use crate::prelude::*;
|
||||
@@ -351,10 +352,9 @@ pub fn kiosk<C: Context>() -> ParentHandler<C> {
|
||||
from_fn_async(|ctx: RpcContext| async move {
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
db.as_public_mut()
|
||||
.as_server_info_mut()
|
||||
.as_kiosk_mut()
|
||||
.ser(&Some(true))
|
||||
let server_info = db.as_public_mut().as_server_info_mut();
|
||||
server_info.as_kiosk_mut().ser(&Some(true))?;
|
||||
server_info.as_status_info_mut().as_restart_mut().ser(&Some(RestartReason::Kiosk))
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
@@ -369,10 +369,9 @@ pub fn kiosk<C: Context>() -> ParentHandler<C> {
|
||||
from_fn_async(|ctx: RpcContext| async move {
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
db.as_public_mut()
|
||||
.as_server_info_mut()
|
||||
.as_kiosk_mut()
|
||||
.ser(&Some(false))
|
||||
let server_info = db.as_public_mut().as_server_info_mut();
|
||||
server_info.as_kiosk_mut().ser(&Some(false))?;
|
||||
server_info.as_status_info_mut().as_restart_mut().ser(&Some(RestartReason::Kiosk))
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
@@ -1367,10 +1366,11 @@ pub async fn set_language(
|
||||
save_language(&*language).await?;
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
db.as_public_mut()
|
||||
.as_server_info_mut()
|
||||
let server_info = db.as_public_mut().as_server_info_mut();
|
||||
server_info
|
||||
.as_language_mut()
|
||||
.ser(&Some(language.clone()))
|
||||
.ser(&Some(language.clone()))?;
|
||||
server_info.as_status_info_mut().as_restart_mut().ser(&Some(RestartReason::Language))
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
|
||||
@@ -19,6 +19,7 @@ use ts_rs::TS;
|
||||
|
||||
use crate::PLATFORM;
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
use crate::db::model::public::RestartReason;
|
||||
use crate::notifications::{NotificationLevel, notify};
|
||||
use crate::prelude::*;
|
||||
use crate::progress::{
|
||||
@@ -81,8 +82,9 @@ pub async fn update_system(
|
||||
.into_public()
|
||||
.into_server_info()
|
||||
.into_status_info()
|
||||
.into_updated()
|
||||
.into_restart()
|
||||
.de()?
|
||||
== Some(RestartReason::Update)
|
||||
{
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("update.already-updated-restart-required")),
|
||||
@@ -281,10 +283,18 @@ async fn maybe_do_update(
|
||||
|
||||
let start_progress = progress.snapshot();
|
||||
|
||||
let status = ctx
|
||||
.db
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
let mut status = peeked.as_public().as_server_info().as_status_info().de()?;
|
||||
let server_info = db.as_public_mut().as_server_info_mut();
|
||||
|
||||
if server_info.as_status_info().as_restart().de()?.is_some() {
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("update.already-updated-restart-required")),
|
||||
crate::ErrorKind::InvalidRequest,
|
||||
));
|
||||
}
|
||||
|
||||
let mut status = server_info.as_status_info().de()?;
|
||||
if status.update_progress.is_some() {
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("update.already-updating")),
|
||||
@@ -293,22 +303,12 @@ async fn maybe_do_update(
|
||||
}
|
||||
|
||||
status.update_progress = Some(start_progress);
|
||||
db.as_public_mut()
|
||||
.as_server_info_mut()
|
||||
.as_status_info_mut()
|
||||
.ser(&status)?;
|
||||
Ok(status)
|
||||
server_info.as_status_info_mut().ser(&status)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
|
||||
if status.updated {
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("update.already-updated-restart-required")),
|
||||
crate::ErrorKind::InvalidRequest,
|
||||
));
|
||||
}
|
||||
|
||||
let progress_task = NonDetachingJoinHandle::from(tokio::spawn(progress.clone().sync_to_db(
|
||||
ctx.db.clone(),
|
||||
|db| {
|
||||
@@ -338,10 +338,15 @@ async fn maybe_do_update(
|
||||
Ok(()) => {
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
let status_info =
|
||||
db.as_public_mut().as_server_info_mut().as_status_info_mut();
|
||||
status_info.as_update_progress_mut().ser(&None)?;
|
||||
status_info.as_updated_mut().ser(&true)
|
||||
let server_info = db.as_public_mut().as_server_info_mut();
|
||||
server_info
|
||||
.as_status_info_mut()
|
||||
.as_update_progress_mut()
|
||||
.ser(&None)?;
|
||||
server_info
|
||||
.as_status_info_mut()
|
||||
.as_restart_mut()
|
||||
.ser(&Some(RestartReason::Update))
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
|
||||
@@ -28,7 +28,14 @@ impl VersionT for Version {
|
||||
&V0_3_0_COMPAT
|
||||
}
|
||||
#[instrument(skip_all)]
|
||||
fn up(self, _db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
|
||||
fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
|
||||
let status_info = db["public"]["serverInfo"]["statusInfo"]
|
||||
.as_object_mut();
|
||||
if let Some(m) = status_info {
|
||||
m.remove("updated");
|
||||
m.insert("restart".into(), Value::Null);
|
||||
}
|
||||
|
||||
Ok(Value::Null)
|
||||
}
|
||||
fn down(self, _db: &mut Value) -> Result<(), Error> {
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { VolumeId } from './VolumeId'
|
||||
export type Manifest = {
|
||||
id: PackageId
|
||||
version: Version
|
||||
satisfies: Array<Version>
|
||||
canMigrateTo: string
|
||||
canMigrateFrom: string
|
||||
images: { [key: ImageId]: ImageConfig }
|
||||
@@ -37,4 +36,5 @@ export type Manifest = {
|
||||
sdkVersion: string | null
|
||||
hardwareAcceleration: boolean
|
||||
plugins: Array<PluginId>
|
||||
satisfies: Array<Version>
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { MerkleArchiveCommitment } from './MerkleArchiveCommitment'
|
||||
import type { PackageId } from './PackageId'
|
||||
import type { PluginId } from './PluginId'
|
||||
import type { RegistryAsset } from './RegistryAsset'
|
||||
import type { Version } from './Version'
|
||||
|
||||
export type PackageVersionInfo = {
|
||||
icon: DataUrl
|
||||
@@ -31,4 +32,5 @@ export type PackageVersionInfo = {
|
||||
sdkVersion: string | null
|
||||
hardwareAcceleration: boolean
|
||||
plugins: Array<PluginId>
|
||||
satisfies: Array<Version>
|
||||
}
|
||||
|
||||
3
sdk/base/lib/osBindings/RestartReason.ts
Normal file
3
sdk/base/lib/osBindings/RestartReason.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type RestartReason = 'mdns' | 'language' | 'kiosk' | 'update'
|
||||
@@ -2,11 +2,12 @@
|
||||
import type { BackupProgress } from './BackupProgress'
|
||||
import type { FullProgress } from './FullProgress'
|
||||
import type { PackageId } from './PackageId'
|
||||
import type { RestartReason } from './RestartReason'
|
||||
|
||||
export type ServerStatus = {
|
||||
backupProgress: { [key: PackageId]: BackupProgress } | null
|
||||
updated: boolean
|
||||
updateProgress: FullProgress | null
|
||||
shuttingDown: boolean
|
||||
restarting: boolean
|
||||
restart: RestartReason | null
|
||||
}
|
||||
|
||||
@@ -236,6 +236,7 @@ export { RenameGatewayParams } from './RenameGatewayParams'
|
||||
export { ReplayId } from './ReplayId'
|
||||
export { RequestCommitment } from './RequestCommitment'
|
||||
export { ResetPasswordParams } from './ResetPasswordParams'
|
||||
export { RestartReason } from './RestartReason'
|
||||
export { RestorePackageParams } from './RestorePackageParams'
|
||||
export { RunActionParams } from './RunActionParams'
|
||||
export { Security } from './Security'
|
||||
|
||||
@@ -4,8 +4,16 @@ import {
|
||||
HostListener,
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidatorFn,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { WA_IS_MOBILE } from '@ng-web-apis/platform'
|
||||
import {
|
||||
DialogService,
|
||||
DiskInfo,
|
||||
@@ -14,13 +22,14 @@ import {
|
||||
i18nPipe,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import { WA_IS_MOBILE } from '@ng-web-apis/platform'
|
||||
import { TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiInput,
|
||||
TuiNotification,
|
||||
TUI_VALIDATION_ERRORS,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
@@ -29,49 +38,55 @@ import {
|
||||
TuiSelect,
|
||||
TuiTooltip,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { filter, Subscription } from 'rxjs'
|
||||
import { TuiCardLarge, TuiForm, TuiHeader } from '@taiga-ui/layout'
|
||||
import { distinctUntilChanged, filter, Subscription } from 'rxjs'
|
||||
import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@if (!shuttingDown) {
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||
</header>
|
||||
|
||||
@if (loading) {
|
||||
@if (loading) {
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||
</header>
|
||||
<tui-loader />
|
||||
} @else if (drives.length === 0) {
|
||||
</section>
|
||||
} @else if (drives.length === 0) {
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||
</header>
|
||||
<p tuiNotification size="m" appearance="warning">
|
||||
{{
|
||||
'No drives found. Please connect a drive and click Refresh.'
|
||||
| i18n
|
||||
}}
|
||||
</p>
|
||||
} @else {
|
||||
<tui-textfield
|
||||
[stringify]="stringify"
|
||||
[disabledItemHandler]="osDisabled"
|
||||
>
|
||||
<footer>
|
||||
<button tuiButton appearance="secondary" (click)="refresh()">
|
||||
{{ 'Refresh' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
} @else {
|
||||
<form tuiCardLarge="compact" tuiForm [formGroup]="form">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||
</header>
|
||||
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>{{ 'OS Drive' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[ngModel]="selectedOsDrive"
|
||||
(ngModelChange)="onOsDriveChange($event)"
|
||||
formControlName="osDrive"
|
||||
[items]="drives"
|
||||
></select>
|
||||
} @else {
|
||||
<input
|
||||
tuiSelect
|
||||
[ngModel]="selectedOsDrive"
|
||||
(ngModelChange)="onOsDriveChange($event)"
|
||||
/>
|
||||
<input tuiSelect formControlName="osDrive" />
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
@@ -82,24 +97,28 @@ import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
|
||||
}
|
||||
<tui-icon [tuiTooltip]="osDriveTooltip" />
|
||||
</tui-textfield>
|
||||
@if (form.controls.osDrive.touched && form.controls.osDrive.invalid) {
|
||||
<tui-error formControlName="osDrive" />
|
||||
}
|
||||
|
||||
<tui-textfield
|
||||
[stringify]="stringify"
|
||||
[disabledItemHandler]="dataDisabled"
|
||||
>
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedDataDrive"
|
||||
(ngModelChange)="onDataDriveChange($event)"
|
||||
formControlName="dataDrive"
|
||||
[items]="drives"
|
||||
[tuiValidator]="
|
||||
form.controls.osDrive.value | tuiMapper: dataValidator
|
||||
"
|
||||
></select>
|
||||
} @else {
|
||||
<input
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedDataDrive"
|
||||
(ngModelChange)="onDataDriveChange($event)"
|
||||
formControlName="dataDrive"
|
||||
[tuiValidator]="
|
||||
form.controls.osDrive.value | tuiMapper: dataValidator
|
||||
"
|
||||
/>
|
||||
}
|
||||
@if (!mobile) {
|
||||
@@ -117,6 +136,11 @@ import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
|
||||
}
|
||||
<tui-icon [tuiTooltip]="dataDriveTooltip" />
|
||||
</tui-textfield>
|
||||
@if (
|
||||
form.controls.dataDrive.touched && form.controls.dataDrive.invalid
|
||||
) {
|
||||
<tui-error formControlName="dataDrive" />
|
||||
}
|
||||
|
||||
<ng-template #driveContent let-drive>
|
||||
<span tuiTitle>
|
||||
@@ -126,24 +150,14 @@ import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
|
||||
</span>
|
||||
</span>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<footer>
|
||||
@if (drives.length === 0) {
|
||||
<button tuiButton appearance="secondary" (click)="refresh()">
|
||||
{{ 'Refresh' | i18n }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!selectedOsDrive || !selectedDataDrive"
|
||||
(click)="continue()"
|
||||
>
|
||||
<footer>
|
||||
<button tuiButton [disabled]="form.invalid" (click)="continue()">
|
||||
{{ 'Continue' | i18n }}
|
||||
</button>
|
||||
}
|
||||
</footer>
|
||||
</section>
|
||||
</footer>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
@@ -152,20 +166,34 @@ import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
TuiCardLarge,
|
||||
TuiForm,
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiInput,
|
||||
TuiNotification,
|
||||
TuiSelect,
|
||||
TuiDataListWrapper,
|
||||
TuiTooltip,
|
||||
TuiValidator,
|
||||
TuiMapperPipe,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: TUI_VALIDATION_ERRORS,
|
||||
useFactory: () => {
|
||||
const i18n = inject(i18nPipe)
|
||||
return {
|
||||
required: i18n.transform('Required'),
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
export default class DrivesPage {
|
||||
private readonly api = inject(ApiService)
|
||||
@@ -188,29 +216,63 @@ export default class DrivesPage {
|
||||
}
|
||||
|
||||
readonly osDriveTooltip = this.i18n.transform(
|
||||
'The drive where the StartOS operating system will be installed.',
|
||||
'The drive where the StartOS operating system will be installed. Minimum 18 GB.',
|
||||
)
|
||||
readonly dataDriveTooltip = this.i18n.transform(
|
||||
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.',
|
||||
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive. Minimum 20 GB, or 38 GB if using a single drive for both OS and data.',
|
||||
)
|
||||
|
||||
private readonly MIN_OS = 18 * 2 ** 30 // 18 GiB
|
||||
private readonly MIN_DATA = 20 * 2 ** 30 // 20 GiB
|
||||
private readonly MIN_BOTH = 38 * 2 ** 30 // 38 GiB
|
||||
|
||||
private readonly osCapacityValidator: ValidatorFn = ({
|
||||
value,
|
||||
}: AbstractControl) => {
|
||||
if (!value) return null
|
||||
return value.capacity < this.MIN_OS
|
||||
? {
|
||||
tooSmallOs: this.i18n.transform('OS drive must be at least 18 GB'),
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
readonly form = new FormGroup({
|
||||
osDrive: new FormControl<DiskInfo | null>(null, [
|
||||
Validators.required,
|
||||
this.osCapacityValidator,
|
||||
]),
|
||||
dataDrive: new FormControl<DiskInfo | null>(null, [Validators.required]),
|
||||
})
|
||||
|
||||
readonly dataValidator =
|
||||
(osDrive: DiskInfo | null): ValidatorFn =>
|
||||
({ value }: AbstractControl) => {
|
||||
if (!value) return null
|
||||
const sameAsOs = osDrive && value.logicalname === osDrive.logicalname
|
||||
const min = sameAsOs ? this.MIN_BOTH : this.MIN_DATA
|
||||
if (value.capacity < min) {
|
||||
return sameAsOs
|
||||
? {
|
||||
tooSmallBoth: this.i18n.transform(
|
||||
'OS + data combined require at least 38 GB',
|
||||
),
|
||||
}
|
||||
: {
|
||||
tooSmallData: this.i18n.transform(
|
||||
'Data drive must be at least 20 GB',
|
||||
),
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
drives: DiskInfo[] = []
|
||||
loading = true
|
||||
shuttingDown = false
|
||||
private dialogSub?: Subscription
|
||||
selectedOsDrive: DiskInfo | null = null
|
||||
selectedDataDrive: DiskInfo | null = null
|
||||
preserveData: boolean | null = null
|
||||
|
||||
readonly osDisabled = (drive: DiskInfo): boolean =>
|
||||
drive.capacity < this.MIN_OS
|
||||
|
||||
dataDisabled = (drive: DiskInfo): boolean => drive.capacity < this.MIN_DATA
|
||||
|
||||
readonly driveName = (drive: DiskInfo): string =>
|
||||
[drive.vendor, drive.model].filter(Boolean).join(' ') ||
|
||||
this.i18n.transform('Unknown Drive')
|
||||
@@ -228,51 +290,40 @@ export default class DrivesPage {
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadDrives()
|
||||
|
||||
this.form.controls.osDrive.valueChanges.subscribe(drive => {
|
||||
if (drive) {
|
||||
this.form.controls.osDrive.markAsTouched()
|
||||
}
|
||||
})
|
||||
|
||||
this.form.controls.dataDrive.valueChanges
|
||||
.pipe(distinctUntilChanged())
|
||||
.subscribe(drive => {
|
||||
this.preserveData = null
|
||||
if (drive) {
|
||||
this.form.controls.dataDrive.markAsTouched()
|
||||
if (toGuid(drive)) {
|
||||
this.showPreserveOverwriteDialog()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
this.selectedOsDrive = null
|
||||
this.selectedDataDrive = null
|
||||
this.form.reset()
|
||||
this.preserveData = null
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
onOsDriveChange(osDrive: DiskInfo | null) {
|
||||
this.selectedOsDrive = osDrive
|
||||
this.dataDisabled = (drive: DiskInfo) => {
|
||||
if (osDrive && drive.logicalname === osDrive.logicalname) {
|
||||
return drive.capacity < this.MIN_BOTH
|
||||
}
|
||||
return drive.capacity < this.MIN_DATA
|
||||
}
|
||||
|
||||
// Clear data drive if it's now invalid
|
||||
if (this.selectedDataDrive && this.dataDisabled(this.selectedDataDrive)) {
|
||||
this.selectedDataDrive = null
|
||||
this.preserveData = null
|
||||
}
|
||||
}
|
||||
|
||||
onDataDriveChange(drive: DiskInfo | null) {
|
||||
this.preserveData = null
|
||||
|
||||
if (!drive) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasStartOSData = !!toGuid(drive)
|
||||
if (hasStartOSData) {
|
||||
this.showPreserveOverwriteDialog()
|
||||
}
|
||||
}
|
||||
|
||||
continue() {
|
||||
if (!this.selectedOsDrive || !this.selectedDataDrive) return
|
||||
const osDrive = this.form.controls.osDrive.value
|
||||
const dataDrive = this.form.controls.dataDrive.value
|
||||
if (!osDrive || !dataDrive) return
|
||||
|
||||
const sameDevice =
|
||||
this.selectedOsDrive.logicalname === this.selectedDataDrive.logicalname
|
||||
const dataHasStartOS = !!toGuid(this.selectedDataDrive)
|
||||
const sameDevice = osDrive.logicalname === dataDrive.logicalname
|
||||
const dataHasStartOS = !!toGuid(dataDrive)
|
||||
|
||||
// Scenario 1: Same drive, has StartOS data, preserving → no warning
|
||||
if (sameDevice && dataHasStartOS && this.preserveData) {
|
||||
@@ -292,7 +343,7 @@ export default class DrivesPage {
|
||||
|
||||
private showPreserveOverwriteDialog() {
|
||||
let selectionMade = false
|
||||
const drive = this.selectedDataDrive
|
||||
const drive = this.form.controls.dataDrive.value
|
||||
const filesystem =
|
||||
drive?.filesystem ||
|
||||
drive?.partitions.find(p => p.guid)?.filesystem ||
|
||||
@@ -304,20 +355,20 @@ export default class DrivesPage {
|
||||
data: { isExt4 },
|
||||
})
|
||||
.subscribe({
|
||||
next: preserve => {
|
||||
selectionMade = true
|
||||
this.preserveData = preserve
|
||||
this.cdr.markForCheck()
|
||||
},
|
||||
complete: () => {
|
||||
if (!selectionMade) {
|
||||
// Dialog was dismissed without selection - clear the data drive
|
||||
this.selectedDataDrive = null
|
||||
this.preserveData = null
|
||||
next: preserve => {
|
||||
selectionMade = true
|
||||
this.preserveData = preserve
|
||||
this.cdr.markForCheck()
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
complete: () => {
|
||||
if (!selectionMade) {
|
||||
// Dialog was dismissed without selection - clear the data drive
|
||||
this.form.controls.dataDrive.reset()
|
||||
this.preserveData = null
|
||||
this.cdr.markForCheck()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private showOsDriveWarning() {
|
||||
@@ -360,13 +411,15 @@ export default class DrivesPage {
|
||||
}
|
||||
|
||||
private async installOs(wipe: boolean) {
|
||||
const osDrive = this.form.controls.osDrive.value!
|
||||
const dataDrive = this.form.controls.dataDrive.value!
|
||||
const loader = this.loader.open('Installing StartOS').subscribe()
|
||||
|
||||
try {
|
||||
const result = await this.api.installOs({
|
||||
osDrive: this.selectedOsDrive!.logicalname,
|
||||
osDrive: osDrive.logicalname,
|
||||
dataDrive: {
|
||||
logicalname: this.selectedDataDrive!.logicalname,
|
||||
logicalname: dataDrive.logicalname,
|
||||
wipe,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -485,7 +485,6 @@ export default {
|
||||
512: 'Der Kiosk-Modus ist auf diesem Gerät nicht verfügbar',
|
||||
513: 'Aktivieren',
|
||||
514: 'Deaktivieren',
|
||||
515: 'Diese Änderung wird nach dem nächsten Neustart wirksam',
|
||||
516: 'Empfohlen',
|
||||
517: 'Möchten Sie diese Aufgabe wirklich verwerfen?',
|
||||
518: 'Verwerfen',
|
||||
@@ -629,8 +628,8 @@ export default {
|
||||
697: 'Geben Sie das Passwort ein, das zum Verschlüsseln dieses Backups verwendet wurde.',
|
||||
698: 'Mehrere Backups gefunden. Wählen Sie aus, welches wiederhergestellt werden soll.',
|
||||
699: 'Backups',
|
||||
700: 'Das Laufwerk, auf dem das StartOS-Betriebssystem installiert wird.',
|
||||
701: 'Das Laufwerk, auf dem Ihre StartOS-Daten (Dienste, Einstellungen usw.) gespeichert werden. Dies kann dasselbe wie das OS-Laufwerk oder ein separates Laufwerk sein.',
|
||||
700: 'Das Laufwerk, auf dem das StartOS-Betriebssystem installiert wird. Mindestens 18 GB.',
|
||||
701: 'Das Laufwerk, auf dem Ihre StartOS-Daten (Dienste, Einstellungen usw.) gespeichert werden. Dies kann dasselbe wie das OS-Laufwerk oder ein separates Laufwerk sein. Mindestens 20 GB, oder 38 GB bei Verwendung eines einzelnen Laufwerks für OS und Daten.',
|
||||
702: 'Versuchen Sie nach der Datenübertragung von diesem Laufwerk nicht, erneut als Start9-Server davon zu booten. Dies kann zu Fehlfunktionen von Diensten, Datenbeschädigung oder Geldverlust führen.',
|
||||
703: 'Muss mindestens 12 Zeichen lang sein',
|
||||
704: 'Darf höchstens 64 Zeichen lang sein',
|
||||
@@ -717,11 +716,15 @@ export default {
|
||||
799: 'Nach Klick auf "Enroll MOK":',
|
||||
800: 'Geben Sie bei Aufforderung Ihr StartOS-Passwort ein',
|
||||
801: 'Ihr System hat Secure Boot aktiviert, was erfordert, dass alle Kernel-Module mit einem vertrauenswürdigen Schlüssel signiert sind. Einige Hardware-Treiber \u2014 wie die für NVIDIA-GPUs \u2014 sind nicht mit dem Standard-Distributionsschlüssel signiert. Die Registrierung des StartOS-Signaturschlüssels ermöglicht es Ihrer Firmware, diesen Modulen zu vertrauen, damit Ihre Hardware vollständig genutzt werden kann.',
|
||||
802: 'Die Übersetzungen auf Betriebssystemebene sind bereits aktiv. Ein Neustart ist erforderlich, damit die Übersetzungen auf Dienstebene wirksam werden.',
|
||||
803: 'Dieses Laufwerk verwendet ext4 und wird automatisch in btrfs konvertiert. Ein Backup wird dringend empfohlen, bevor Sie fortfahren.',
|
||||
804: 'Ich habe ein Backup meiner Daten',
|
||||
805: 'Öffentliche Domain hinzufügen',
|
||||
806: 'Ergebnis',
|
||||
807: 'Nach dem Öffnen der neuen Adresse werden Sie zum Neustart aufgefordert.',
|
||||
808: 'Ein Neustart ist erforderlich, damit die Dienstschnittstellen den neuen Hostnamen verwenden.',
|
||||
807: 'Download abgeschlossen. Neustart zum Anwenden.',
|
||||
808: 'Hostname geändert, Neustart damit installierte Dienste die neue Adresse verwenden',
|
||||
809: 'Sprache geändert, Neustart damit installierte Dienste die neue Sprache verwenden',
|
||||
810: 'Kioskmodus geändert, Neustart zum Anwenden',
|
||||
811: 'OS-Laufwerk muss mindestens 18 GB groß sein',
|
||||
812: 'Datenlaufwerk muss mindestens 20 GB groß sein',
|
||||
813: 'OS + Daten zusammen erfordern mindestens 38 GB',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -484,7 +484,6 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Kiosk Mode is unavailable on this device': 512,
|
||||
'Enable': 513,
|
||||
'Disable': 514,
|
||||
'This change will take effect after the next boot': 515,
|
||||
'Recommended': 516, // as in, we recommend this
|
||||
'Are you sure you want to dismiss this task?': 517,
|
||||
'Dismiss': 518, // as in, dismiss or delete a task
|
||||
@@ -629,8 +628,8 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Enter the password that was used to encrypt this backup.': 697,
|
||||
'Multiple backups found. Select which one to restore.': 698,
|
||||
'Backups': 699,
|
||||
'The drive where the StartOS operating system will be installed.': 700,
|
||||
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.': 701,
|
||||
'The drive where the StartOS operating system will be installed. Minimum 18 GB.': 700,
|
||||
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive. Minimum 20 GB, or 38 GB if using a single drive for both OS and data.': 701,
|
||||
'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.': 702,
|
||||
'Must be 12 characters or greater': 703,
|
||||
'Must be 64 character or less': 704,
|
||||
@@ -718,11 +717,15 @@ export const ENGLISH: Record<string, number> = {
|
||||
'After clicking "Enroll MOK":': 799,
|
||||
'When prompted, enter your StartOS password': 800,
|
||||
'Your system has Secure Boot enabled, which requires all kernel modules to be signed with a trusted key. Some hardware drivers \u2014 such as those for NVIDIA GPUs \u2014 are not signed by the default distribution key. Enrolling the StartOS signing key allows your firmware to trust these modules so your hardware can be fully utilized.': 801,
|
||||
'OS-level translations are already in effect. A restart is required for service-level translations to take effect.': 802,
|
||||
'This drive uses ext4 and will be automatically converted to btrfs. A backup is strongly recommended before proceeding.': 803,
|
||||
'I have a backup of my data': 804,
|
||||
'Add Public Domain': 805,
|
||||
'Result': 806,
|
||||
'After opening the new address, you will be prompted to restart.': 807,
|
||||
'A restart is required for service interfaces to use the new hostname.': 808,
|
||||
'Download complete. Restart to apply.': 807,
|
||||
'Hostname changed, restart for installed services to use the new address': 808,
|
||||
'Language changed, restart for installed services to use the new language': 809,
|
||||
'Kiosk mode changed, restart to apply': 810,
|
||||
'OS drive must be at least 18 GB': 811,
|
||||
'Data drive must be at least 20 GB': 812,
|
||||
'OS + data combined require at least 38 GB': 813,
|
||||
}
|
||||
|
||||
@@ -485,7 +485,6 @@ export default {
|
||||
512: 'El modo quiosco no está disponible en este dispositivo',
|
||||
513: 'Activar',
|
||||
514: 'Desactivar',
|
||||
515: 'Este cambio tendrá efecto después del próximo inicio',
|
||||
516: 'Recomendado',
|
||||
517: '¿Estás seguro de que deseas descartar esta tarea?',
|
||||
518: 'Descartar',
|
||||
@@ -629,8 +628,8 @@ export default {
|
||||
697: 'Introduzca la contraseña que se utilizó para cifrar esta copia de seguridad.',
|
||||
698: 'Se encontraron varias copias de seguridad. Seleccione cuál restaurar.',
|
||||
699: 'Copias de seguridad',
|
||||
700: 'La unidad donde se instalará el sistema operativo StartOS.',
|
||||
701: 'La unidad donde se almacenarán sus datos de StartOS (servicios, ajustes, etc.). Puede ser la misma que la unidad del sistema operativo o una unidad separada.',
|
||||
700: 'La unidad donde se instalará el sistema operativo StartOS. Mínimo 18 GB.',
|
||||
701: 'La unidad donde se almacenarán sus datos de StartOS (servicios, ajustes, etc.). Puede ser la misma que la unidad del sistema operativo o una unidad separada. Mínimo 20 GB, o 38 GB si se usa una sola unidad para el sistema operativo y los datos.',
|
||||
702: 'Después de transferir datos desde esta unidad, no intente arrancar desde ella nuevamente como un servidor Start9. Esto puede provocar fallos en los servicios, corrupción de datos o pérdida de fondos.',
|
||||
703: 'Debe tener 12 caracteres o más',
|
||||
704: 'Debe tener 64 caracteres o menos',
|
||||
@@ -717,11 +716,15 @@ export default {
|
||||
799: 'Después de hacer clic en "Enroll MOK":',
|
||||
800: 'Cuando se le solicite, ingrese su contraseña de StartOS',
|
||||
801: 'Su sistema tiene Secure Boot habilitado, lo que requiere que todos los módulos del kernel estén firmados con una clave de confianza. Algunos controladores de hardware \u2014 como los de las GPU NVIDIA \u2014 no están firmados con la clave de distribución predeterminada. Registrar la clave de firma de StartOS permite que su firmware confíe en estos módulos para que su hardware pueda utilizarse completamente.',
|
||||
802: 'Las traducciones a nivel del sistema operativo ya están en vigor. Se requiere un reinicio para que las traducciones a nivel de servicio surtan efecto.',
|
||||
803: 'Esta unidad usa ext4 y se convertirá automáticamente a btrfs. Se recomienda encarecidamente hacer una copia de seguridad antes de continuar.',
|
||||
804: 'Tengo una copia de seguridad de mis datos',
|
||||
805: 'Agregar dominio público',
|
||||
806: 'Resultado',
|
||||
807: 'Después de abrir la nueva dirección, se le pedirá que reinicie.',
|
||||
808: 'Se requiere un reinicio para que las interfaces de servicio utilicen el nuevo nombre de host.',
|
||||
807: 'Descarga completa. Reiniciar para aplicar.',
|
||||
808: 'Nombre de host cambiado, reiniciar para que los servicios instalados usen la nueva dirección',
|
||||
809: 'Idioma cambiado, reiniciar para que los servicios instalados usen el nuevo idioma',
|
||||
810: 'Modo kiosco cambiado, reiniciar para aplicar',
|
||||
811: 'La unidad del SO debe tener al menos 18 GB',
|
||||
812: 'La unidad de datos debe tener al menos 20 GB',
|
||||
813: 'SO + datos combinados requieren al menos 38 GB',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -485,7 +485,6 @@ export default {
|
||||
512: 'Le mode kiosque n’est pas disponible sur cet appareil',
|
||||
513: 'Activer',
|
||||
514: 'Désactiver',
|
||||
515: 'Ce changement va prendre effet après le prochain démarrage',
|
||||
516: 'Recommandé',
|
||||
517: 'Êtes-vous sûr de vouloir ignorer cette tâche ?',
|
||||
518: 'Ignorer',
|
||||
@@ -629,8 +628,8 @@ export default {
|
||||
697: 'Saisissez le mot de passe utilisé pour chiffrer cette sauvegarde.',
|
||||
698: 'Plusieurs sauvegardes trouvées. Sélectionnez celle à restaurer.',
|
||||
699: 'Sauvegardes',
|
||||
700: 'Le disque sur lequel le système d’exploitation StartOS sera installé.',
|
||||
701: 'Le disque sur lequel vos données StartOS (services, paramètres, etc.) seront stockées. Il peut s’agir du même disque que le système ou d’un disque séparé.',
|
||||
700: 'Le disque sur lequel le système d’exploitation StartOS sera installé. Minimum 18 Go.',
|
||||
701: 'Le disque sur lequel vos données StartOS (services, paramètres, etc.) seront stockées. Il peut s’agir du même disque que le système ou d’un disque séparé. Minimum 20 Go, ou 38 Go si un seul disque est utilisé pour le système et les données.',
|
||||
702: 'Après le transfert des données depuis ce disque, n’essayez pas de démarrer dessus à nouveau en tant que serveur Start9. Cela peut entraîner des dysfonctionnements des services, une corruption des données ou une perte de fonds.',
|
||||
703: 'Doit comporter au moins 12 caractères',
|
||||
704: 'Doit comporter au maximum 64 caractères',
|
||||
@@ -717,11 +716,15 @@ export default {
|
||||
799: 'Après avoir cliqué sur "Enroll MOK" :',
|
||||
800: 'Lorsque vous y êtes invité, entrez votre mot de passe StartOS',
|
||||
801: "Votre système a Secure Boot activé, ce qui exige que tous les modules du noyau soient signés avec une clé de confiance. Certains pilotes matériels \u2014 comme ceux des GPU NVIDIA \u2014 ne sont pas signés par la clé de distribution par défaut. L'enregistrement de la clé de signature StartOS permet à votre firmware de faire confiance à ces modules afin que votre matériel puisse être pleinement utilisé.",
|
||||
802: "Les traductions au niveau du système d'exploitation sont déjà en vigueur. Un redémarrage est nécessaire pour que les traductions au niveau des services prennent effet.",
|
||||
803: 'Ce disque utilise ext4 et sera automatiquement converti en btrfs. Il est fortement recommandé de faire une sauvegarde avant de continuer.',
|
||||
804: "J'ai une sauvegarde de mes données",
|
||||
805: 'Ajouter un domaine public',
|
||||
806: 'Résultat',
|
||||
807: 'Après avoir ouvert la nouvelle adresse, vous serez invité à redémarrer.',
|
||||
808: "Un redémarrage est nécessaire pour que les interfaces de service utilisent le nouveau nom d'hôte.",
|
||||
807: 'Téléchargement terminé. Redémarrer pour appliquer.',
|
||||
808: "Nom d'hôte modifié, redémarrer pour que les services installés utilisent la nouvelle adresse",
|
||||
809: 'Langue modifiée, redémarrer pour que les services installés utilisent la nouvelle langue',
|
||||
810: 'Mode kiosque modifié, redémarrer pour appliquer',
|
||||
811: 'Le disque système doit faire au moins 18 Go',
|
||||
812: 'Le disque de données doit faire au moins 20 Go',
|
||||
813: 'Système + données combinés nécessitent au moins 38 Go',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -485,7 +485,6 @@ export default {
|
||||
512: 'Tryb kiosku jest niedostępny na tym urządzeniu',
|
||||
513: 'Włącz',
|
||||
514: 'Wyłącz',
|
||||
515: 'Ta zmiana zacznie obowiązywać po następnym uruchomieniu',
|
||||
516: 'Zalecane',
|
||||
517: 'Czy na pewno chcesz odrzucić to zadanie?',
|
||||
518: 'Odrzuć',
|
||||
@@ -629,8 +628,8 @@ export default {
|
||||
697: 'Wprowadź hasło użyte do zaszyfrowania tej kopii zapasowej.',
|
||||
698: 'Znaleziono wiele kopii zapasowych. Wybierz, którą przywrócić.',
|
||||
699: 'Kopie zapasowe',
|
||||
700: 'Dysk, na którym zostanie zainstalowany system operacyjny StartOS.',
|
||||
701: 'Dysk, na którym będą przechowywane dane StartOS (usługi, ustawienia itp.). Może to być ten sam dysk co systemowy lub oddzielny dysk.',
|
||||
700: 'Dysk, na którym zostanie zainstalowany system operacyjny StartOS. Minimum 18 GB.',
|
||||
701: 'Dysk, na którym będą przechowywane dane StartOS (usługi, ustawienia itp.). Może to być ten sam dysk co systemowy lub oddzielny dysk. Minimum 20 GB lub 38 GB w przypadku jednego dysku na system i dane.',
|
||||
702: 'Po przeniesieniu danych z tego dysku nie próbuj ponownie uruchamiać z niego systemu jako serwer Start9. Może to spowodować nieprawidłowe działanie usług, uszkodzenie danych lub utratę środków.',
|
||||
703: 'Musi mieć co najmniej 12 znaków',
|
||||
704: 'Musi mieć maksymalnie 64 znaki',
|
||||
@@ -717,11 +716,15 @@ export default {
|
||||
799: 'Po kliknięciu "Enroll MOK":',
|
||||
800: 'Po wyświetleniu monitu wprowadź swoje hasło StartOS',
|
||||
801: 'Twój system ma włączony Secure Boot, co wymaga, aby wszystkie moduły jądra były podpisane zaufanym kluczem. Niektóre sterowniki sprzętowe \u2014 takie jak te dla GPU NVIDIA \u2014 nie są podpisane domyślnym kluczem dystrybucji. Zarejestrowanie klucza podpisu StartOS pozwala firmware ufać tym modułom, aby sprzęt mógł być w pełni wykorzystany.',
|
||||
802: 'Tłumaczenia na poziomie systemu operacyjnego są już aktywne. Wymagane jest ponowne uruchomienie, aby tłumaczenia na poziomie usług zaczęły obowiązywać.',
|
||||
803: 'Ten dysk używa ext4 i zostanie automatycznie skonwertowany na btrfs. Zdecydowanie zaleca się wykonanie kopii zapasowej przed kontynuowaniem.',
|
||||
804: 'Mam kopię zapasową moich danych',
|
||||
805: 'Dodaj domenę publiczną',
|
||||
806: 'Wynik',
|
||||
807: 'Po otwarciu nowego adresu zostaniesz poproszony o ponowne uruchomienie.',
|
||||
808: 'Ponowne uruchomienie jest wymagane, aby interfejsy usług używały nowej nazwy hosta.',
|
||||
807: 'Pobieranie zakończone. Uruchom ponownie, aby zastosować.',
|
||||
808: 'Nazwa hosta zmieniona, uruchom ponownie, aby zainstalowane usługi używały nowego adresu',
|
||||
809: 'Język zmieniony, uruchom ponownie, aby zainstalowane usługi używały nowego języka',
|
||||
810: 'Tryb kiosku zmieniony, uruchom ponownie, aby zastosować',
|
||||
811: 'Dysk systemowy musi mieć co najmniej 18 GB',
|
||||
812: 'Dysk danych musi mieć co najmniej 20 GB',
|
||||
813: 'System + dane łącznie wymagają co najmniej 38 GB',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -50,45 +50,32 @@ import { CHANGE_PASSWORD } from './change-password'
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div tuiCell>
|
||||
<span tuiTitle>
|
||||
<strong>Change password</strong>
|
||||
</span>
|
||||
<button tuiButton size="s" (click)="onChangePassword()">Change</button>
|
||||
</div>
|
||||
<div tuiCell>
|
||||
<span tuiTitle>
|
||||
<strong>Restart</strong>
|
||||
<span tuiSubtitle>Restart the VPS</span>
|
||||
</span>
|
||||
<button
|
||||
tuiButton
|
||||
size="s"
|
||||
appearance="secondary"
|
||||
iconStart="@tui.rotate-cw"
|
||||
[loading]="restarting()"
|
||||
(click)="onRestart()"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
</div>
|
||||
<div tuiCell>
|
||||
<span tuiTitle>
|
||||
<strong>Logout</strong>
|
||||
</span>
|
||||
<button
|
||||
tuiButton
|
||||
size="s"
|
||||
appearance="secondary-destructive"
|
||||
iconStart="@tui.log-out"
|
||||
(click)="onLogout()"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div tuiCardLarge [style.align-items]="'start'">
|
||||
<button tuiButton size="s" (click)="onChangePassword()">
|
||||
Change password
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
size="s"
|
||||
iconStart="@tui.rotate-cw"
|
||||
[loading]="restarting()"
|
||||
(click)="onRestart()"
|
||||
>
|
||||
Reboot VPS
|
||||
</button>
|
||||
<button tuiButton size="s" iconStart="@tui.log-out" (click)="onLogout()">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
[tuiCardLarge] {
|
||||
background: var(--tui-background-neutral-1);
|
||||
|
||||
@@ -148,9 +135,9 @@ export default class Settings {
|
||||
await this.api.restart()
|
||||
this.dialogs
|
||||
.open(
|
||||
'The VPS is restarting. Please wait 1\u20132 minutes, then refresh the page.',
|
||||
'The VPS is rebooting. Please wait 1\u20132 minutes, then refresh the page.',
|
||||
{
|
||||
label: 'Restarting',
|
||||
label: 'Rebooting',
|
||||
},
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
@@ -14,7 +14,7 @@ body {
|
||||
isolation: isolate;
|
||||
overflow-x: hidden;
|
||||
background:
|
||||
conic-gradient(var(--tui-background-base)),
|
||||
linear-gradient(var(--tui-background-base, #171717), var(--tui-background-base, #171717)),
|
||||
radial-gradient(circle at top right, #5240a8, transparent 40%),
|
||||
radial-gradient(circle at bottom right, #9236c9, transparent),
|
||||
radial-gradient(circle at 25% 100%, #5b65d5, transparent 30%),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { RouterOutlet } from '@angular/router'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { ErrorService, i18nPipe } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiCell,
|
||||
@@ -39,10 +39,7 @@ import { HeaderComponent } from './components/header/header.component'
|
||||
@if (update(); as update) {
|
||||
<tui-action-bar *tuiPopup="bar()">
|
||||
<span tuiCell="m">
|
||||
@if (update === true) {
|
||||
<tui-icon icon="@tui.check" class="g-positive" />
|
||||
Download complete, restart to apply changes
|
||||
} @else if (
|
||||
@if (
|
||||
update.overall && update.overall !== true && update.overall.total
|
||||
) {
|
||||
<tui-progress-circle
|
||||
@@ -58,9 +55,36 @@ import { HeaderComponent } from './components/header/header.component'
|
||||
Calculating download size
|
||||
}
|
||||
</span>
|
||||
@if (update === true) {
|
||||
<button tuiButton size="s" (click)="restart()">Restart</button>
|
||||
}
|
||||
</tui-action-bar>
|
||||
}
|
||||
@if (restartReason(); as reason) {
|
||||
<tui-action-bar *tuiPopup="bar()">
|
||||
<span tuiCell="m">
|
||||
<tui-icon icon="@tui.refresh-cw" />
|
||||
@switch (reason) {
|
||||
@case ('update') {
|
||||
{{ 'Download complete. Restart to apply.' | i18n }}
|
||||
}
|
||||
@case ('mdns') {
|
||||
{{
|
||||
'Hostname changed, restart for installed services to use the new address'
|
||||
| i18n
|
||||
}}
|
||||
}
|
||||
@case ('language') {
|
||||
{{
|
||||
'Language changed, restart for installed services to use the new language'
|
||||
| i18n
|
||||
}}
|
||||
}
|
||||
@case ('kiosk') {
|
||||
{{ 'Kiosk mode changed, restart to apply' | i18n }}
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<button tuiButton size="s" appearance="primary" (click)="restart()">
|
||||
{{ 'Restart' | i18n }}
|
||||
</button>
|
||||
</tui-action-bar>
|
||||
}
|
||||
`,
|
||||
@@ -114,6 +138,7 @@ import { HeaderComponent } from './components/header/header.component'
|
||||
TuiButton,
|
||||
TuiPopup,
|
||||
TuiCell,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export class PortalComponent {
|
||||
@@ -124,6 +149,9 @@ export class PortalComponent {
|
||||
|
||||
readonly name = toSignal(this.patch.watch$('serverInfo', 'name'))
|
||||
readonly update = toSignal(inject(OSService).updating$)
|
||||
readonly restartReason = toSignal(
|
||||
this.patch.watch$('serverInfo', 'statusInfo', 'restart'),
|
||||
)
|
||||
readonly bar = signal(true)
|
||||
|
||||
getProgress(size: number, downloaded: number): number {
|
||||
|
||||
@@ -31,7 +31,7 @@ import { hasCurrentDeps } from 'src/app/utils/has-deps'
|
||||
|
||||
import { MarketplaceAlertsService } from '../services/alerts.service'
|
||||
|
||||
type KEYS = 'id' | 'version' | 'alerts' | 'flavor'
|
||||
type KEYS = 'id' | 'version' | 'alerts' | 'flavor' | 'satisfies'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-controls',
|
||||
@@ -185,9 +185,13 @@ export class MarketplaceControlsComponent {
|
||||
}
|
||||
|
||||
private async dryInstall(url: string | null) {
|
||||
const { id, version } = this.pkg()
|
||||
const { id, version, satisfies } = this.pkg()
|
||||
const packages = await getAllPackages(this.patch)
|
||||
const breakages = dryUpdate({ id, version }, packages, this.exver)
|
||||
const breakages = dryUpdate(
|
||||
{ id, version, satisfies: satisfies || [] },
|
||||
packages,
|
||||
this.exver,
|
||||
)
|
||||
|
||||
if (!breakages.length || (await this.alerts.alertBreakages(breakages))) {
|
||||
this.installOrUpload(url)
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
TuiNotification,
|
||||
} from '@taiga-ui/core'
|
||||
import { injectContext } from '@taiga-ui/polymorpheus'
|
||||
import * as json from 'fast-json-patch'
|
||||
import { compare } from 'fast-json-patch'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { catchError, EMPTY, endWith, firstValueFrom, from, map } from 'rxjs'
|
||||
@@ -191,9 +190,7 @@ export class ActionInputModal {
|
||||
task.actionId === this.actionId &&
|
||||
task.when?.condition === 'input-not-matches' &&
|
||||
task.input &&
|
||||
json
|
||||
.compare(input, task.input.value)
|
||||
.some(op => op.op === 'add' || op.op === 'replace'),
|
||||
conflicts(task.input.value, input),
|
||||
),
|
||||
)
|
||||
.map(id => id)
|
||||
@@ -214,3 +211,26 @@ export class ActionInputModal {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the Rust backend's `conflicts()` function in core/src/service/action.rs.
|
||||
// A key in the partial that is missing from the full input is NOT a conflict.
|
||||
function conflicts(left: unknown, right: unknown): boolean {
|
||||
if (
|
||||
typeof left === 'object' &&
|
||||
left !== null &&
|
||||
!Array.isArray(left) &&
|
||||
typeof right === 'object' &&
|
||||
right !== null &&
|
||||
!Array.isArray(right)
|
||||
) {
|
||||
const l = left as Record<string, unknown>
|
||||
const r = right as Record<string, unknown>
|
||||
return Object.keys(l).some(k => (k in r ? conflicts(l[k], r[k]) : false))
|
||||
}
|
||||
|
||||
if (Array.isArray(left) && Array.isArray(right)) {
|
||||
return left.some(v => right.every(vr => conflicts(v, vr)))
|
||||
}
|
||||
|
||||
return left !== right
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ import {
|
||||
Component,
|
||||
inject,
|
||||
INJECTOR,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { WA_WINDOW } from '@ng-web-apis/common'
|
||||
import {
|
||||
DialogService,
|
||||
@@ -48,6 +47,7 @@ import { PatchDB } from 'patch-db-client'
|
||||
import { filter } from 'rxjs'
|
||||
import { ABOUT } from 'src/app/routes/portal/components/header/about.component'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { OSService } from 'src/app/services/os.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
@@ -96,14 +96,10 @@ import { UPDATE } from './update.component'
|
||||
[disabled]="os.updatingOrBackingUp$ | async"
|
||||
(click)="onUpdate()"
|
||||
>
|
||||
@if (server.statusInfo.updated) {
|
||||
{{ 'Restart to apply' | i18n }}
|
||||
@if (os.showUpdate$ | async) {
|
||||
{{ 'Update' | i18n }}
|
||||
} @else {
|
||||
@if (os.showUpdate$ | async) {
|
||||
{{ 'Update' | i18n }}
|
||||
} @else {
|
||||
{{ 'Check for updates' | i18n }}
|
||||
}
|
||||
{{ 'Check for updates' | i18n }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@@ -278,7 +274,7 @@ import { UPDATE } from './update.component'
|
||||
TuiAnimated,
|
||||
],
|
||||
})
|
||||
export default class SystemGeneralComponent implements OnInit {
|
||||
export default class SystemGeneralComponent {
|
||||
private readonly dialogs = inject(TuiResponsiveDialogService)
|
||||
private readonly loader = inject(TuiNotificationMiddleService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
@@ -288,20 +284,7 @@ export default class SystemGeneralComponent implements OnInit {
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
private readonly injector = inject(INJECTOR)
|
||||
private readonly win = inject(WA_WINDOW)
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
private readonly router = inject(Router)
|
||||
|
||||
ngOnInit() {
|
||||
this.route.queryParams
|
||||
.pipe(filter(params => params['restart'] === 'hostname'))
|
||||
.subscribe(async () => {
|
||||
await this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: {},
|
||||
})
|
||||
this.promptHostnameRestart()
|
||||
})
|
||||
}
|
||||
private readonly config = inject(ConfigService)
|
||||
|
||||
count = 0
|
||||
|
||||
@@ -321,7 +304,6 @@ export default class SystemGeneralComponent implements OnInit {
|
||||
|
||||
onLanguageChange(language: Language) {
|
||||
this.i18nService.setLang(language.name)
|
||||
this.promptLanguageRestart()
|
||||
}
|
||||
|
||||
// Expose shared utilities for template use
|
||||
@@ -371,9 +353,7 @@ export default class SystemGeneralComponent implements OnInit {
|
||||
}
|
||||
|
||||
onUpdate() {
|
||||
if (this.server()?.statusInfo.updated) {
|
||||
this.restart()
|
||||
} else if (this.os.updateAvailable$.value) {
|
||||
if (this.os.updateAvailable$.value) {
|
||||
this.update()
|
||||
} else {
|
||||
this.check()
|
||||
@@ -400,7 +380,7 @@ export default class SystemGeneralComponent implements OnInit {
|
||||
),
|
||||
)
|
||||
.subscribe(result => {
|
||||
if (this.win.location.hostname.endsWith('.local')) {
|
||||
if (this.config.accessType === 'mdns') {
|
||||
this.confirmNameChange(result)
|
||||
} else {
|
||||
this.saveName(result)
|
||||
@@ -433,24 +413,18 @@ export default class SystemGeneralComponent implements OnInit {
|
||||
await this.api.setHostname({ name, hostname })
|
||||
|
||||
if (wasLocal) {
|
||||
const { protocol, port } = this.win.location
|
||||
const portSuffix = port ? ':' + port : ''
|
||||
const newUrl = `${protocol}//${hostname}.local${portSuffix}/system/general?restart=hostname`
|
||||
|
||||
this.dialog
|
||||
.openConfirm({
|
||||
label: 'Hostname Changed',
|
||||
data: {
|
||||
content:
|
||||
`${this.i18n.transform('Your server is now reachable at')} ${hostname}.local. ${this.i18n.transform('After opening the new address, you will be prompted to restart.')}` as i18nKey,
|
||||
`${this.i18n.transform('Your server is now reachable at')} ${hostname}.local` as i18nKey,
|
||||
yes: 'Open new address',
|
||||
no: 'Dismiss',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.win.open(newUrl, '_blank'))
|
||||
} else {
|
||||
this.promptHostnameRestart()
|
||||
.subscribe(() => this.win.open(`https://${hostname}.local`, '_blank'))
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
@@ -526,7 +500,6 @@ export default class SystemGeneralComponent implements OnInit {
|
||||
|
||||
try {
|
||||
await this.api.toggleKiosk(true)
|
||||
this.promptRestart()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
@@ -546,7 +519,6 @@ export default class SystemGeneralComponent implements OnInit {
|
||||
options: [],
|
||||
})
|
||||
await this.api.toggleKiosk(true)
|
||||
this.promptRestart()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
@@ -559,7 +531,6 @@ export default class SystemGeneralComponent implements OnInit {
|
||||
|
||||
try {
|
||||
await this.api.toggleKiosk(false)
|
||||
this.promptRestart()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
@@ -567,50 +538,6 @@ export default class SystemGeneralComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private promptRestart() {
|
||||
this.dialog
|
||||
.openConfirm({
|
||||
label: 'Restart to apply',
|
||||
data: {
|
||||
content: 'This change will take effect after the next boot',
|
||||
yes: 'Restart now',
|
||||
no: 'Later',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.restart())
|
||||
}
|
||||
|
||||
private promptHostnameRestart() {
|
||||
this.dialog
|
||||
.openConfirm({
|
||||
label: 'Restart to apply',
|
||||
data: {
|
||||
content:
|
||||
'A restart is required for service interfaces to use the new hostname.',
|
||||
yes: 'Restart now',
|
||||
no: 'Later',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.restart())
|
||||
}
|
||||
|
||||
private promptLanguageRestart() {
|
||||
this.dialog
|
||||
.openConfirm({
|
||||
label: 'Restart to apply',
|
||||
data: {
|
||||
content:
|
||||
'OS-level translations are already in effect. A restart is required for service-level translations to take effect.',
|
||||
yes: 'Restart now',
|
||||
no: 'Later',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.restart())
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.dialogs
|
||||
.open(UPDATE, {
|
||||
|
||||
@@ -54,7 +54,7 @@ export default class StartOsUiComponent {
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
readonly iface: T.ServiceInterface = {
|
||||
id: '',
|
||||
id: 'startos-ui',
|
||||
name: 'StartOS UI',
|
||||
description: this.i18n.transform(
|
||||
'The web user interface for your StartOS server, accessible from any browser.',
|
||||
|
||||
@@ -18,10 +18,10 @@ import {
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiExpand,
|
||||
TuiIcon,
|
||||
TuiLink,
|
||||
TuiTitle,
|
||||
TuiExpand,
|
||||
} from '@taiga-ui/core'
|
||||
import { NgDompurifyPipe } from '@taiga-ui/dompurify'
|
||||
import {
|
||||
@@ -199,6 +199,7 @@ import UpdatesComponent from './updates.component'
|
||||
&[colspan]:only-child {
|
||||
padding: 0 3rem;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ export namespace Mock {
|
||||
export const ServerUpdated: T.ServerStatus = {
|
||||
backupProgress: null,
|
||||
updateProgress: null,
|
||||
updated: true,
|
||||
restarting: false,
|
||||
shuttingDown: false,
|
||||
restart: null,
|
||||
}
|
||||
|
||||
export const RegistryOSUpdate: T.OsVersionInfoMap = {
|
||||
@@ -459,6 +459,7 @@ export namespace Mock {
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
satisfies: [],
|
||||
dependencyMetadata: {},
|
||||
donationUrl: null,
|
||||
alerts: {
|
||||
@@ -501,6 +502,7 @@ export namespace Mock {
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
satisfies: [],
|
||||
dependencyMetadata: {},
|
||||
donationUrl: null,
|
||||
alerts: {
|
||||
@@ -553,6 +555,7 @@ export namespace Mock {
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
satisfies: [],
|
||||
dependencyMetadata: {},
|
||||
donationUrl: null,
|
||||
alerts: {
|
||||
@@ -595,6 +598,7 @@ export namespace Mock {
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
satisfies: [],
|
||||
dependencyMetadata: {},
|
||||
donationUrl: null,
|
||||
alerts: {
|
||||
@@ -649,6 +653,7 @@ export namespace Mock {
|
||||
gitHash: 'fakehash',
|
||||
icon: LND_ICON,
|
||||
sourceVersion: null,
|
||||
satisfies: [],
|
||||
dependencyMetadata: {
|
||||
bitcoind: BitcoinDep,
|
||||
'btc-rpc-proxy': ProxyDep,
|
||||
@@ -704,6 +709,7 @@ export namespace Mock {
|
||||
gitHash: 'fakehash',
|
||||
icon: LND_ICON,
|
||||
sourceVersion: null,
|
||||
satisfies: [],
|
||||
dependencyMetadata: {
|
||||
bitcoind: BitcoinDep,
|
||||
'btc-rpc-proxy': ProxyDep,
|
||||
@@ -757,12 +763,74 @@ export namespace Mock {
|
||||
upstreamRepo: 'https://github.com/bitcoin/bitcoin',
|
||||
marketingUrl: 'https://bitcoin.org',
|
||||
docsUrls: ['https://bitcoin.org'],
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
releaseNotes: `# Bitcoin Core v27.0.0 Release Notes
|
||||
|
||||
## Overview
|
||||
|
||||
This is a major release of Bitcoin Core with significant performance improvements, new RPC methods, and critical security patches. We strongly recommend all users upgrade as soon as possible.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- The deprecated \`getinfo\` RPC has been fully removed. Use \`getblockchaininfo\`, \`getnetworkinfo\`, and \`getwalletinfo\` instead.
|
||||
- Configuration option \`rpcallowip\` no longer accepts hostnames — only CIDR notation is supported (e.g. \`192.168.1.0/24\`).
|
||||
- The wallet database format has been migrated from BerkeleyDB to SQLite. Existing wallets will be automatically converted on first load. **This migration is irreversible.**
|
||||
|
||||
## New Features
|
||||
|
||||
- **Compact Block Filters (BIP 158):** Full support for serving compact block filters to light clients over the P2P network. Enable with \`-blockfilterindex=basic -peerblockfilters=1\`.
|
||||
- **Miniscript support in descriptors:** You can now use miniscript policies inside \`wsh()\` descriptors for more expressive spending conditions.
|
||||
- **New RPC: \`getdescriptoractivity\`:** Returns all wallet-relevant transactions for a given set of output descriptors within a block range.
|
||||
|
||||
## Performance Improvements
|
||||
|
||||
- Block validation is now 18% faster due to improved UTXO cache management and parallel script verification.
|
||||
- Initial block download (IBD) time reduced by approximately 25% on NVMe storage thanks to batched database writes.
|
||||
- Memory usage during reindex reduced from ~4.2 GB to ~2.8 GB peak.
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
\`\`\`ini
|
||||
# New options added in this release
|
||||
blockfilterindex=basic # Enable BIP 158 compact block filter index
|
||||
peerblockfilters=1 # Serve compact block filters to peers
|
||||
shutdownnotify=<cmd> # Execute command on clean shutdown
|
||||
v2transport=1 # Prefer BIP 324 encrypted P2P connections
|
||||
\`\`\`
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
1. Fixed a race condition in the mempool acceptance logic that could cause \`submitblock\` to return stale rejection reasons under high transaction throughput.
|
||||
2. Corrected fee estimation for transactions with many inputs where the estimator previously overestimated by up to 15%.
|
||||
3. Resolved an edge case where \`pruneblockchain\` could delete blocks still needed by an in-progress \`rescanblockchain\` operation.
|
||||
4. Fixed incorrect handling of \`OP_CHECKSIGADD\` in legacy script verification mode that could lead to consensus divergence on certain non-standard transactions.
|
||||
5. Patched a denial-of-service vector where a malicious peer could send specially crafted \`inv\` messages causing excessive memory allocation in the transaction request tracker.
|
||||
|
||||
## Dependency Updates
|
||||
|
||||
| Dependency | Old Version | New Version |
|
||||
|------------|-------------|-------------|
|
||||
| OpenSSL | 1.1.1w | 3.0.13 |
|
||||
| libevent | 2.1.12 | 2.2.1 |
|
||||
| Boost | 1.81.0 | 1.84.0 |
|
||||
| SQLite | 3.38.5 | 3.45.1 |
|
||||
| miniupnpc | 2.2.4 | 2.2.7 |
|
||||
|
||||
## Migration Guide
|
||||
|
||||
For users running Bitcoin Core as a service behind a reverse proxy, note that the default RPC authentication mechanism now uses cookie-based auth by default. If you previously relied on \`rpcuser\`/\`rpcpassword\`, you must explicitly set \`rpcauth\` in your configuration file. See https://github.com/bitcoin/bitcoin/blob/master/share/rpcauth/rpcauth.py for the auth string generator.
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Wallet encryption with very long passphrases (>1024 characters) may cause the wallet to become temporarily unresponsive during unlock. A fix is planned for v27.0.1.
|
||||
- The \`listtransactions\` RPC may return duplicate entries when called with \`include_watchonly=true\` on descriptor wallets that share derivation paths across multiple descriptors.
|
||||
|
||||
For the full changelog, see https://github.com/bitcoin/bitcoin/blob/v27.0.0/doc/release-notes/release-notes-27.0.0.md#full-changelog-with-detailed-descriptions-of-every-commit-and-pull-request-merged`,
|
||||
osVersion: '0.4.0',
|
||||
sdkVersion: '0.4.0-beta.49',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
satisfies: [],
|
||||
dependencyMetadata: {},
|
||||
donationUrl: null,
|
||||
alerts: {
|
||||
@@ -805,6 +873,7 @@ export namespace Mock {
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
satisfies: [],
|
||||
dependencyMetadata: {},
|
||||
donationUrl: null,
|
||||
alerts: {
|
||||
@@ -857,6 +926,7 @@ export namespace Mock {
|
||||
gitHash: 'fakehash',
|
||||
icon: LND_ICON,
|
||||
sourceVersion: null,
|
||||
satisfies: [],
|
||||
dependencyMetadata: {
|
||||
bitcoind: BitcoinDep,
|
||||
'btc-rpc-proxy': ProxyDep,
|
||||
@@ -912,6 +982,7 @@ export namespace Mock {
|
||||
gitHash: 'fakehash',
|
||||
icon: PROXY_ICON,
|
||||
sourceVersion: null,
|
||||
satisfies: [],
|
||||
dependencyMetadata: {
|
||||
bitcoind: BitcoinDep,
|
||||
},
|
||||
|
||||
@@ -435,14 +435,20 @@ export class MockApiService extends ApiService {
|
||||
async toggleKiosk(enable: boolean): Promise<null> {
|
||||
await pauseFor(2000)
|
||||
|
||||
const patch = [
|
||||
this.mockRevision([
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/kiosk',
|
||||
value: enable,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
])
|
||||
this.mockRevision([
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/statusInfo/restart',
|
||||
value: 'kiosk',
|
||||
},
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -450,7 +456,7 @@ export class MockApiService extends ApiService {
|
||||
async setHostname(params: T.SetServerHostnameParams): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch = [
|
||||
this.mockRevision([
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/name',
|
||||
@@ -461,8 +467,14 @@ export class MockApiService extends ApiService {
|
||||
path: '/serverInfo/hostname',
|
||||
value: params.hostname,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
])
|
||||
this.mockRevision([
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/statusInfo/restart',
|
||||
value: 'mdns',
|
||||
},
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -485,14 +497,20 @@ export class MockApiService extends ApiService {
|
||||
async setLanguage(params: SetLanguageParams): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch = [
|
||||
this.mockRevision([
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/language',
|
||||
value: params.language,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
])
|
||||
this.mockRevision([
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/statusInfo/restart',
|
||||
value: 'language',
|
||||
},
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1831,11 +1849,11 @@ export class MockApiService extends ApiService {
|
||||
this.mockRevision(patch2)
|
||||
|
||||
setTimeout(async () => {
|
||||
const patch3: Operation<boolean>[] = [
|
||||
const patch3: Operation<string>[] = [
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/statusInfo/updated',
|
||||
value: true,
|
||||
path: '/serverInfo/statusInfo/restart',
|
||||
value: 'update',
|
||||
},
|
||||
{
|
||||
op: PatchOp.REMOVE,
|
||||
|
||||
@@ -227,11 +227,11 @@ export const mockPatchData: DataModel = {
|
||||
postInitMigrationTodos: {},
|
||||
statusInfo: {
|
||||
// currentBackup: null,
|
||||
updated: false,
|
||||
updateProgress: null,
|
||||
restarting: false,
|
||||
shuttingDown: false,
|
||||
backupProgress: null,
|
||||
restart: null,
|
||||
},
|
||||
name: 'Random Words',
|
||||
hostname: 'random-words',
|
||||
|
||||
@@ -28,7 +28,7 @@ export class OSService {
|
||||
.pipe(shareReplay({ bufferSize: 1, refCount: true }))
|
||||
|
||||
readonly updating$ = this.statusInfo$.pipe(
|
||||
map(status => status.updateProgress ?? status.updated),
|
||||
map(status => status.updateProgress ?? false),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import { DataModel } from '../services/patch-db/data-model'
|
||||
import { getManifest } from './get-package-data'
|
||||
|
||||
export function dryUpdate(
|
||||
{ id, version }: { id: string; version: string },
|
||||
{
|
||||
id,
|
||||
version,
|
||||
satisfies,
|
||||
}: { id: string; version: string; satisfies: string[] },
|
||||
pkgs: DataModel['packageData'],
|
||||
exver: Exver,
|
||||
): string[] {
|
||||
@@ -13,10 +17,24 @@ export function dryUpdate(
|
||||
Object.keys(pkg.currentDependencies || {}).some(
|
||||
pkgId => pkgId === id,
|
||||
) &&
|
||||
!exver.satisfies(
|
||||
!versionSatisfies(
|
||||
version,
|
||||
satisfies,
|
||||
pkg.currentDependencies[id]?.versionRange || '',
|
||||
exver,
|
||||
),
|
||||
)
|
||||
.map(pkg => getManifest(pkg).title)
|
||||
}
|
||||
|
||||
function versionSatisfies(
|
||||
version: string,
|
||||
satisfies: string[],
|
||||
range: string,
|
||||
exver: Exver,
|
||||
): boolean {
|
||||
return (
|
||||
exver.satisfies(version, range) ||
|
||||
satisfies.some(v => exver.satisfies(v, range))
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user