Merge pull request #624 from kinode-dao/bp/app_ui_fixes

app_store UI fixes
This commit is contained in:
barraguda 2024-12-18 05:22:09 +07:00 committed by GitHub
commit 03928c413b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1647 additions and 388 deletions

View File

@ -297,6 +297,9 @@ interface downloads {
blob-not-found, blob-not-found,
vfs-error, vfs-error,
handling-error(string), handling-error(string),
timeout,
invalid-manifest,
offline,
} }
/// Notification that a download is complete /// Notification that a download is complete
@ -306,12 +309,26 @@ interface downloads {
err: option<download-error>, err: option<download-error>,
} }
/// Request for an auto-download complete /// Variant for an auto-download complete
record auto-download-complete-request { variant auto-download-complete-request {
download-info: download-complete-request, success(auto-download-success),
err(auto-download-error),
}
/// Auto-download success
record auto-download-success {
package-id: package-id,
version-hash: string,
manifest-hash: string, manifest-hash: string,
} }
/// Auto-download error
record auto-download-error {
package-id: package-id,
version-hash: string,
tries: list<tuple<string, download-error>>, // (mirror, error)
}
/// Represents a hash mismatch error /// Represents a hash mismatch error
record hash-mismatch { record hash-mismatch {
desired: string, desired: string,

View File

@ -3,11 +3,13 @@
//! and sends back http_responses. //! and sends back http_responses.
//! //!
use crate::{ use crate::{
kinode::process::chain::{ChainRequests, ChainResponses}, kinode::process::{
kinode::process::downloads::{ chain::{ChainRequests, ChainResponses},
DownloadRequests, DownloadResponses, Entry, LocalDownloadRequest, RemoveFileRequest, downloads::{
DownloadRequests, DownloadResponses, Entry, LocalDownloadRequest, RemoveFileRequest,
},
}, },
state::{MirrorCheck, PackageState, State}, state::{MirrorCheck, PackageState, State, Updates},
}; };
use kinode_process_lib::{ use kinode_process_lib::{
http::{self, server, Method, StatusCode}, http::{self, server, Method, StatusCode},
@ -28,6 +30,7 @@ pub fn init_frontend(our: &Address, http_server: &mut server::HttpServer) {
"/downloads", // all downloads "/downloads", // all downloads
"/installed", // all installed apps "/installed", // all installed apps
"/ourapps", // all apps we've published "/ourapps", // all apps we've published
"/updates", // all auto_updates
"/apps/:id", // detail about an on-chain app "/apps/:id", // detail about an on-chain app
"/downloads/:id", // local downloads for an app "/downloads/:id", // local downloads for an app
"/installed/:id", // detail about an installed app "/installed/:id", // detail about an installed app
@ -38,6 +41,7 @@ pub fn init_frontend(our: &Address, http_server: &mut server::HttpServer) {
"/downloads/:id/mirror", // start mirroring a version of a downloaded app "/downloads/:id/mirror", // start mirroring a version of a downloaded app
"/downloads/:id/remove", // remove a downloaded app "/downloads/:id/remove", // remove a downloaded app
"/apps/:id/auto-update", // set auto-updating a version of a downloaded app "/apps/:id/auto-update", // set auto-updating a version of a downloaded app
"/updates/:id/clear", // clear update info for an app.
"/mirrorcheck/:node", // check if a node/mirror is online/offline "/mirrorcheck/:node", // check if a node/mirror is online/offline
] { ] {
http_server http_server
@ -207,9 +211,10 @@ fn make_widget() -> String {
pub fn handle_http_request( pub fn handle_http_request(
our: &Address, our: &Address,
state: &mut State, state: &mut State,
updates: &mut Updates,
req: &server::IncomingHttpRequest, req: &server::IncomingHttpRequest,
) -> (server::HttpResponse, Option<LazyLoadBlob>) { ) -> (server::HttpResponse, Option<LazyLoadBlob>) {
match serve_paths(our, state, req) { match serve_paths(our, state, updates, req) {
Ok((status_code, _headers, body)) => ( Ok((status_code, _headers, body)) => (
server::HttpResponse::new(status_code).header("Content-Type", "application/json"), server::HttpResponse::new(status_code).header("Content-Type", "application/json"),
Some(LazyLoadBlob { Some(LazyLoadBlob {
@ -248,13 +253,13 @@ fn gen_package_info(id: &PackageId, state: &PackageState) -> serde_json::Value {
"our_version_hash": state.our_version_hash, "our_version_hash": state.our_version_hash,
"verified": state.verified, "verified": state.verified,
"caps_approved": state.caps_approved, "caps_approved": state.caps_approved,
"pending_update_hash": state.pending_update_hash,
}) })
} }
fn serve_paths( fn serve_paths(
our: &Address, our: &Address,
state: &mut State, state: &mut State,
updates: &mut Updates,
req: &server::IncomingHttpRequest, req: &server::IncomingHttpRequest,
) -> anyhow::Result<(http::StatusCode, Option<HashMap<String, String>>, Vec<u8>)> { ) -> anyhow::Result<(http::StatusCode, Option<HashMap<String, String>>, Vec<u8>)> {
let method = req.method()?; let method = req.method()?;
@ -533,7 +538,6 @@ fn serve_paths(
.ok_or(anyhow::anyhow!("missing blob"))? .ok_or(anyhow::anyhow!("missing blob"))?
.bytes; .bytes;
let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let version_hash = body_json let version_hash = body_json
.get("version_hash") .get("version_hash")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@ -697,6 +701,31 @@ fn serve_paths(
)), )),
} }
} }
// GET all failed/pending auto_updates
"/updates" => {
let serialized = serde_json::to_vec(&updates).unwrap_or_default();
return Ok((StatusCode::OK, None, serialized));
}
// POST clear all failed/pending auto_updates for a package_id
"/updates/:id/clear" => {
let Ok(package_id) = get_package_id(url_params) else {
return Ok((
StatusCode::BAD_REQUEST,
None,
format!("Missing package_id").into_bytes(),
));
};
if method != Method::POST {
return Ok((
StatusCode::METHOD_NOT_ALLOWED,
None,
format!("Invalid method {method} for {bound_path}").into_bytes(),
));
}
let _ = updates.package_updates.remove(&package_id);
updates.save();
Ok((StatusCode::OK, None, vec![]))
}
// GET online/offline mirrors for a listed app // GET online/offline mirrors for a listed app
"/mirrorcheck/:node" => { "/mirrorcheck/:node" => {
if method != Method::GET { if method != Method::GET {

View File

@ -42,7 +42,7 @@ use kinode_process_lib::{
LazyLoadBlob, Message, PackageId, Response, LazyLoadBlob, Message, PackageId, Response,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use state::State; use state::{State, UpdateInfo, Updates};
wit_bindgen::generate!({ wit_bindgen::generate!({
path: "target/wit", path: "target/wit",
@ -81,15 +81,19 @@ fn init(our: Address) {
let mut http_server = http::server::HttpServer::new(5); let mut http_server = http::server::HttpServer::new(5);
http_api::init_frontend(&our, &mut http_server); http_api::init_frontend(&our, &mut http_server);
// state = state built from the filesystem, installed packages
// updates = state saved with get/set_state(), auto_update metadata.
let mut state = State::load().expect("state loading failed"); let mut state = State::load().expect("state loading failed");
let mut updates = Updates::load();
loop { loop {
match await_message() { match await_message() {
Err(send_error) => { Err(send_error) => {
print_to_terminal(1, &format!("main: got network error: {send_error}")); print_to_terminal(1, &format!("main: got network error: {send_error}"));
} }
Ok(message) => { Ok(message) => {
if let Err(e) = handle_message(&our, &mut state, &mut http_server, &message) { if let Err(e) =
handle_message(&our, &mut state, &mut updates, &mut http_server, &message)
{
let error_message = format!("error handling message: {e:?}"); let error_message = format!("error handling message: {e:?}");
print_to_terminal(1, &error_message); print_to_terminal(1, &error_message);
Response::new() Response::new()
@ -109,6 +113,7 @@ fn init(our: Address) {
fn handle_message( fn handle_message(
our: &Address, our: &Address,
state: &mut State, state: &mut State,
updates: &mut Updates,
http_server: &mut http::server::HttpServer, http_server: &mut http::server::HttpServer,
message: &Message, message: &Message,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
@ -132,7 +137,7 @@ fn handle_message(
} }
http_server.handle_request( http_server.handle_request(
server_request, server_request,
|incoming| http_api::handle_http_request(our, state, &incoming), |incoming| http_api::handle_http_request(our, state, updates, &incoming),
|_channel_id, _message_type, _blob| { |_channel_id, _message_type, _blob| {
// not expecting any websocket messages from FE currently // not expecting any websocket messages from FE currently
}, },
@ -166,40 +171,80 @@ fn handle_message(
"auto download complete from non-local node" "auto download complete from non-local node"
)); ));
} }
// auto_install case:
// the downloads process has given us the new package manifest's
// capability hashes, and the old package's capability hashes.
// we can use these to determine if the new package has the same
// capabilities as the old one, and if so, auto-install it.
let manifest_hash = req.manifest_hash; match req {
let package_id = req.download_info.package_id; AutoDownloadCompleteRequest::Success(succ) => {
let version_hash = req.download_info.version_hash; // auto_install case:
// the downloads process has given us the new package manifest's
// capability hashes, and the old package's capability hashes.
// we can use these to determine if the new package has the same
// capabilities as the old one, and if so, auto-install it.
let manifest_hash = succ.manifest_hash;
let package_id = succ.package_id;
let version_hash = succ.version_hash;
let process_lib_package_id = package_id.clone().to_process_lib(); let process_lib_package_id = package_id.clone().to_process_lib();
// first, check if we have the package and get its manifest hash // first, check if we have the package and get its manifest hash
let should_auto_install = state let should_auto_install = state
.packages .packages
.get(&process_lib_package_id) .get(&process_lib_package_id)
.map(|package| package.manifest_hash == Some(manifest_hash.clone())) .map(|package| package.manifest_hash == Some(manifest_hash.clone()))
.unwrap_or(false); .unwrap_or(false);
if should_auto_install { if should_auto_install {
if let Err(e) = if let Err(e) =
utils::install(&package_id, None, &version_hash, state, &our.node) utils::install(&package_id, None, &version_hash, state, &our.node)
{ {
if let Some(package) = state.packages.get_mut(&process_lib_package_id) { println!("error auto-installing package: {e}");
package.pending_update_hash = Some(version_hash); // Get or create the outer map for this package
updates
.package_updates
.entry(package_id.to_process_lib())
.or_default()
.insert(
version_hash.clone(),
UpdateInfo {
errors: vec![],
pending_manifest_hash: Some(manifest_hash.clone()),
},
);
updates.save();
} else {
println!(
"auto-installed update for package: {process_lib_package_id}"
);
}
} else {
// TODO.
updates
.package_updates
.entry(package_id.to_process_lib())
.or_default()
.insert(
version_hash.clone(),
UpdateInfo {
errors: vec![],
pending_manifest_hash: Some(manifest_hash.clone()),
},
);
updates.save();
} }
println!("error auto-installing package: {e}");
} else {
println!("auto-installed update for package: {process_lib_package_id}");
} }
} else { AutoDownloadCompleteRequest::Err(err) => {
if let Some(package) = state.packages.get_mut(&process_lib_package_id) { println!("error auto-downloading package: {err:?}");
package.pending_update_hash = Some(version_hash); updates
println!("error auto-installing package: manifest hash mismatch"); .package_updates
.entry(err.package_id.to_process_lib())
.or_default()
.insert(
err.version_hash.clone(),
UpdateInfo {
errors: err.tries,
pending_manifest_hash: None,
},
);
updates.save();
} }
} }
} }

View File

@ -1,5 +1,5 @@
use crate::{utils, VFS_TIMEOUT}; use crate::{kinode::process::downloads::DownloadError, utils, VFS_TIMEOUT};
use kinode_process_lib::{kimap, vfs, PackageId}; use kinode_process_lib::{get_state, kimap, set_state, vfs, PackageId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -54,9 +54,6 @@ pub struct PackageState {
/// capabilities have changed. if they have changed, auto-install must fail /// capabilities have changed. if they have changed, auto-install must fail
/// and the user must approve the new capabilities. /// and the user must approve the new capabilities.
pub manifest_hash: Option<String>, pub manifest_hash: Option<String>,
/// stores the version hash of a failed auto-install attempt, which can be
/// later installed by the user by approving new caps.
pub pending_update_hash: Option<String>,
} }
// this seems cleaner to me right now with pending_update_hash, but given how we serialize // this seems cleaner to me right now with pending_update_hash, but given how we serialize
@ -133,7 +130,6 @@ impl State {
verified: true, // implicitly verified (TODO re-evaluate) verified: true, // implicitly verified (TODO re-evaluate)
caps_approved: false, // must re-approve if you want to do something ?? caps_approved: false, // must re-approve if you want to do something ??
manifest_hash: Some(manifest_hash), manifest_hash: Some(manifest_hash),
pending_update_hash: None, // ... this could be a separate state saved. don't want to reflect this info on-disk as a file.
}, },
); );
@ -147,3 +143,76 @@ impl State {
Ok(()) Ok(())
} }
} }
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Updates {
#[serde(with = "package_id_map")]
pub package_updates: HashMap<PackageId, HashMap<String, UpdateInfo>>, // package id -> version_hash -> update info
}
impl Default for Updates {
fn default() -> Self {
Self {
package_updates: HashMap::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UpdateInfo {
pub errors: Vec<(String, DownloadError)>, // errors collected by downloads process
pub pending_manifest_hash: Option<String>, // pending manifest hash that differed from the installed one
}
impl Updates {
pub fn load() -> Self {
let bytes = get_state();
if let Some(bytes) = bytes {
serde_json::from_slice(&bytes).unwrap_or_default()
} else {
Self::default()
}
}
pub fn save(&self) {
let bytes = serde_json::to_vec(self).unwrap_or_default();
set_state(&bytes);
}
}
// note: serde_json doesn't support non-string keys when serializing maps, so
// we have to use a custom simple serializer.
mod package_id_map {
use super::*;
use std::{collections::HashMap, str::FromStr};
pub fn serialize<S>(
map: &HashMap<PackageId, HashMap<String, UpdateInfo>>,
s: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap;
let mut map_ser = s.serialize_map(Some(map.len()))?;
for (k, v) in map {
map_ser.serialize_entry(&k.to_string(), v)?;
}
map_ser.end()
}
pub fn deserialize<'de, D>(
d: D,
) -> Result<HashMap<PackageId, HashMap<String, UpdateInfo>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let string_map = HashMap::<String, HashMap<String, UpdateInfo>>::deserialize(d)?;
Ok(string_map
.into_iter()
.filter_map(|(k, v)| PackageId::from_str(&k).ok().map(|pid| (pid, v)))
.collect())
}
}

View File

@ -225,7 +225,6 @@ pub fn install(
verified: true, // sideloaded apps are implicitly verified because there is no "source" to verify against verified: true, // sideloaded apps are implicitly verified because there is no "source" to verify against
caps_approved: true, // TODO see if we want to auto-approve local installs caps_approved: true, // TODO see if we want to auto-approve local installs
manifest_hash: Some(manifest_hash), manifest_hash: Some(manifest_hash),
pending_update_hash: None, // TODO: doublecheck if problematically overwrites auto_update state.
}; };
if let Ok(extracted) = extract_api(&process_package_id) { if let Ok(extracted) = extract_api(&process_package_id) {

View File

@ -42,13 +42,18 @@
//! mechanism is implemented in the FT worker for improved modularity and performance. //! mechanism is implemented in the FT worker for improved modularity and performance.
//! //!
use crate::kinode::process::downloads::{ use crate::kinode::process::downloads::{
AutoDownloadCompleteRequest, AutoUpdateRequest, DirEntry, DownloadCompleteRequest, AutoDownloadCompleteRequest, AutoDownloadError, AutoUpdateRequest, DirEntry,
DownloadError, DownloadRequests, DownloadResponses, Entry, FileEntry, HashMismatch, DownloadCompleteRequest, DownloadError, DownloadRequests, DownloadResponses, Entry, FileEntry,
LocalDownloadRequest, RemoteDownloadRequest, RemoveFileRequest, HashMismatch, LocalDownloadRequest, RemoteDownloadRequest, RemoveFileRequest,
};
use std::{
collections::{HashMap, HashSet},
io::Read,
str::FromStr,
}; };
use std::{collections::HashSet, io::Read, str::FromStr};
use ft_worker_lib::{spawn_receive_transfer, spawn_send_transfer}; use ft_worker_lib::{spawn_receive_transfer, spawn_send_transfer};
use kinode::process::downloads::AutoDownloadSuccess;
use kinode_process_lib::{ use kinode_process_lib::{
await_message, call_init, get_blob, get_state, await_message, call_init, get_blob, get_state,
http::client, http::client,
@ -69,7 +74,6 @@ wit_bindgen::generate!({
mod ft_worker_lib; mod ft_worker_lib;
pub const VFS_TIMEOUT: u64 = 5; // 5s pub const VFS_TIMEOUT: u64 = 5; // 5s
pub const APP_SHARE_TIMEOUT: u64 = 120; // 120s
#[derive(Debug, Serialize, Deserialize, process_macros::SerdeJsonInto)] #[derive(Debug, Serialize, Deserialize, process_macros::SerdeJsonInto)]
#[serde(untagged)] // untagged as a meta-type for all incoming responses #[serde(untagged)] // untagged as a meta-type for all incoming responses
@ -78,6 +82,15 @@ pub enum Resp {
HttpClient(Result<client::HttpClientResponse, client::HttpClientError>), HttpClient(Result<client::HttpClientResponse, client::HttpClientError>),
} }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AutoUpdateStatus {
mirrors_left: HashSet<String>, // set(node/url)
mirrors_failed: Vec<(String, DownloadError)>, // vec(node/url, error)
active_mirror: String, // (node/url)
}
type AutoUpdates = HashMap<(PackageId, String), AutoUpdateStatus>;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct State { pub struct State {
// persisted metadata about which packages we are mirroring // persisted metadata about which packages we are mirroring
@ -117,13 +130,11 @@ fn init(our: Address) {
let mut tmp = let mut tmp =
vfs::open_dir("/app-store:sys/downloads/tmp", true, None).expect("could not open tmp"); vfs::open_dir("/app-store:sys/downloads/tmp", true, None).expect("could not open tmp");
let mut auto_updates: HashSet<(PackageId, String)> = HashSet::new(); // metadata for in-flight auto-updates
let mut auto_updates: AutoUpdates = HashMap::new();
loop { loop {
match await_message() { match await_message() {
Err(send_error) => {
print_to_terminal(1, &format!("downloads: got network error: {send_error}"));
}
Ok(message) => { Ok(message) => {
if let Err(e) = handle_message( if let Err(e) = handle_message(
&our, &our,
@ -143,6 +154,33 @@ fn init(our: Address) {
.unwrap(); .unwrap();
} }
} }
Err(send_error) => {
print_to_terminal(1, &format!("downloads: got network error: {send_error}"));
if let Some(context) = &send_error.context {
if let Ok(download_request) =
serde_json::from_slice::<LocalDownloadRequest>(&context)
{
let key = (
download_request.package_id.to_process_lib(),
download_request.desired_version_hash.clone(),
);
// Get the error first
let error = if send_error.kind.is_timeout() {
DownloadError::Timeout
} else if send_error.kind.is_offline() {
DownloadError::Offline
} else {
DownloadError::HandlingError(send_error.to_string())
};
// Then remove and get metadata
if let Some(metadata) = auto_updates.remove(&key) {
try_next_mirror(metadata, key, &mut auto_updates, error);
}
}
}
}
} }
} }
} }
@ -157,7 +195,7 @@ fn handle_message(
message: &Message, message: &Message,
downloads: &mut Directory, downloads: &mut Directory,
_tmp: &mut Directory, _tmp: &mut Directory,
auto_updates: &mut HashSet<(PackageId, String)>, auto_updates: &mut AutoUpdates,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if message.is_request() { if message.is_request() {
match message.body().try_into()? { match message.body().try_into()? {
@ -174,8 +212,12 @@ fn handle_message(
} = download_request.clone(); } = download_request.clone();
if download_from.starts_with("http") { if download_from.starts_with("http") {
// use http-client to GET it // use http_client to GET it
Request::to(("our", "http-client", "distro", "sys")) print_to_terminal(
1,
"kicking off http download for {package_id:?} and {version_hash:?}",
);
Request::to(("our", "http_client", "distro", "sys"))
.body( .body(
serde_json::to_vec(&client::HttpClientAction::Http( serde_json::to_vec(&client::HttpClientAction::Http(
client::OutgoingHttpRequest { client::OutgoingHttpRequest {
@ -200,7 +242,6 @@ fn handle_message(
&package_id, &package_id,
&desired_version_hash, &desired_version_hash,
&download_from, &download_from,
APP_SHARE_TIMEOUT,
)?; )?;
Request::to((&download_from, "downloads", "app-store", "sys")) Request::to((&download_from, "downloads", "app-store", "sys"))
@ -236,13 +277,8 @@ fn handle_message(
} }
let target_worker = Address::from_str(&worker_address)?; let target_worker = Address::from_str(&worker_address)?;
let _ = spawn_send_transfer( let _ =
our, spawn_send_transfer(our, &package_id, &desired_version_hash, &target_worker)?;
&package_id,
&desired_version_hash,
APP_SHARE_TIMEOUT,
&target_worker,
)?;
let resp = DownloadResponses::Success; let resp = DownloadResponses::Success;
Response::new().body(&resp).send()?; Response::new().body(&resp).send()?;
} }
@ -257,50 +293,30 @@ fn handle_message(
if !message.is_local(our) { if !message.is_local(our) {
return Err(anyhow::anyhow!("got non local download complete")); return Err(anyhow::anyhow!("got non local download complete"));
} }
// if we have a pending auto_install, forward that context to the main process.
// it will check if the caps_hashes match (no change in capabilities), and auto_install if it does.
let manifest_hash = if auto_updates.remove(&( // forward to main:app_store:sys, pushed to UI via websockets
req.package_id.clone().to_process_lib(), Request::to(("our", "main", "app_store", "sys"))
req.version_hash.clone(),
)) {
match get_manifest_hash(
req.package_id.clone().to_process_lib(),
req.version_hash.clone(),
) {
Ok(manifest_hash) => Some(manifest_hash),
Err(e) => {
print_to_terminal(
1,
&format!("auto_update: error getting manifest hash: {:?}", e),
);
None
}
}
} else {
None
};
// pushed to UI via websockets
Request::to(("our", "main", "app-store", "sys"))
.body(serde_json::to_vec(&req)?) .body(serde_json::to_vec(&req)?)
.send()?; .send()?;
// trigger auto-update install trigger to main:app-store:sys // Check if this is an auto-update download
if let Some(manifest_hash) = manifest_hash { let key = (
let auto_download_complete_req = AutoDownloadCompleteRequest { req.package_id.clone().to_process_lib(),
download_info: req.clone(), req.version_hash.clone(),
manifest_hash, );
};
print_to_terminal( if let Some(metadata) = auto_updates.remove(&key) {
1, if let Some(err) = req.err {
&format!( try_next_mirror(metadata, key, auto_updates, err);
"auto_update download complete: triggering install on main:app-store:sys" } else if let Err(_e) = handle_auto_update_success(key.0.clone(), key.1.clone())
), {
); try_next_mirror(
Request::to(("our", "main", "app-store", "sys")) metadata,
.body(serde_json::to_vec(&auto_download_complete_req)?) key,
.send()?; auto_updates,
DownloadError::InvalidManifest,
);
}
} }
} }
DownloadRequests::GetFiles(maybe_id) => { DownloadRequests::GetFiles(maybe_id) => {
@ -414,29 +430,61 @@ fn handle_message(
} = auto_update_request.clone(); } = auto_update_request.clone();
let process_lib_package_id = package_id.clone().to_process_lib(); let process_lib_package_id = package_id.clone().to_process_lib();
// default auto_update to publisher. TODO: more config here. // default auto_update to publisher
let download_from = metadata.properties.publisher; // let download_from = metadata.properties.publisher.clone();
let current_version = metadata.properties.current_version; let current_version = metadata.properties.current_version;
let code_hashes = metadata.properties.code_hashes; let code_hashes = metadata.properties.code_hashes;
// Create a HashSet of mirrors including the publisher
let mut mirrors = HashSet::new();
let download_from = if let Some(first_mirror) = metadata.properties.mirrors.first()
{
first_mirror.clone()
} else {
"randomnode111.os".to_string()
};
println!("first_download_from: {download_from}");
mirrors.extend(metadata.properties.mirrors.into_iter());
mirrors.insert(metadata.properties.publisher.clone());
let version_hash = code_hashes let version_hash = code_hashes
.iter() .iter()
.find(|(version, _)| version == &current_version) .find(|(version, _)| version == &current_version)
.map(|(_, hash)| hash.clone()) .map(|(_, hash)| hash.clone())
.ok_or_else(|| anyhow::anyhow!("auto_update: error for package_id: {}, current_version: {}, no matching hash found", process_lib_package_id.to_string(), current_version))?; // note, if this errors, full on failure I thnk no?
// and bubble this up.
.ok_or_else(|| anyhow::anyhow!("auto_update: error for package_id: {}, current_version: {}, no matching hash found", process_lib_package_id.to_string(), current_version))?;
print_to_terminal(
1,
&format!(
"auto_update: kicking off download for {:?} from {} with version {} from mirror {}",
package_id, download_from, version_hash, download_from
),
);
let download_request = LocalDownloadRequest { let download_request = LocalDownloadRequest {
package_id, package_id,
download_from, download_from: download_from.clone(),
desired_version_hash: version_hash.clone(), desired_version_hash: version_hash.clone(),
}; };
// kick off local download to ourselves. // Initialize auto-update status with mirrors
Request::to(("our", "downloads", "app-store", "sys")) let key = (process_lib_package_id.clone(), version_hash.clone());
auto_updates.insert(
key,
AutoUpdateStatus {
mirrors_left: mirrors,
mirrors_failed: Vec::new(),
active_mirror: download_from.clone(),
},
);
// kick off local download to ourselves
Request::to(("our", "downloads", "app_store", "sys"))
.body(DownloadRequests::LocalDownload(download_request)) .body(DownloadRequests::LocalDownload(download_request))
.send()?; .send()?;
auto_updates.insert((process_lib_package_id, version_hash));
} }
_ => {} _ => {}
} }
@ -445,18 +493,30 @@ fn handle_message(
Resp::Download(download_response) => { Resp::Download(download_response) => {
// get context of the response. // get context of the response.
// handled are errors or ok responses from a remote node. // handled are errors or ok responses from a remote node.
// check state, do action based on that!
if let Some(context) = message.context() { if let Some(context) = message.context() {
let download_request = serde_json::from_slice::<LocalDownloadRequest>(context)?; let download_request = serde_json::from_slice::<LocalDownloadRequest>(context)?;
match download_response { match download_response {
DownloadResponses::Err(e) => { DownloadResponses::Err(e) => {
Request::to(("our", "main", "app_store", "sys")) print_to_terminal(1, &format!("downloads: got error response: {e:?}"));
.body(DownloadCompleteRequest { let key = (
package_id: download_request.package_id.clone(), download_request.package_id.clone().to_process_lib(),
version_hash: download_request.desired_version_hash.clone(), download_request.desired_version_hash.clone(),
err: Some(e), );
})
.send()?; if let Some(metadata) = auto_updates.remove(&key) {
try_next_mirror(metadata, key, auto_updates, e);
} else {
// If not an auto-update, forward error normally
Request::to(("our", "main", "app_store", "sys"))
.body(DownloadCompleteRequest {
package_id: download_request.package_id,
version_hash: download_request.desired_version_hash,
err: Some(e),
})
.send()?;
}
} }
DownloadResponses::Success => { DownloadResponses::Success => {
// todo: maybe we do something here. // todo: maybe we do something here.
@ -477,29 +537,85 @@ fn handle_message(
return Err(anyhow::anyhow!("http-client response without context")); return Err(anyhow::anyhow!("http-client response without context"));
}; };
let download_request = serde_json::from_slice::<LocalDownloadRequest>(context)?; let download_request = serde_json::from_slice::<LocalDownloadRequest>(context)?;
if let Ok(client::HttpClientResponse::Http(client::HttpResponse { let key = (
status, .. download_request.package_id.clone().to_process_lib(),
})) = resp download_request.desired_version_hash.clone(),
{ );
if status == 200 {
if let Err(e) = handle_receive_http_download(&download_request) { // Check if this is an auto-update request
print_to_terminal( let is_auto_update = auto_updates.contains_key(&key);
1, let metadata = if is_auto_update {
&format!("error handling http-client response: {:?}", e), auto_updates.remove(&key)
); } else {
Request::to(("our", "main", "app-store", "sys")) None
.body(DownloadRequests::DownloadComplete( };
DownloadCompleteRequest {
package_id: download_request.package_id.clone(), // Handle any non-200 response or client error
version_hash: download_request.desired_version_hash.clone(), let Ok(client::HttpClientResponse::Http(resp)) = resp else {
err: Some(e), if let Some(meta) = metadata {
}, let error = if let Err(e) = resp {
)) format!("HTTP client error: {e:?}")
.send()?; } else {
"unexpected response type".to_string()
};
try_next_mirror(
meta,
key,
auto_updates,
DownloadError::HandlingError(error),
);
}
return Ok(());
};
if resp.status != 200 {
let error =
DownloadError::HandlingError(format!("HTTP status {}", resp.status));
handle_download_error(
is_auto_update,
metadata,
key,
auto_updates,
error,
&download_request,
)?;
return Ok(());
}
// Handle successful download
if let Err(e) = handle_receive_http_download(&download_request) {
print_to_terminal(1, &format!("error handling http_client response: {:?}", e));
handle_download_error(
is_auto_update,
metadata,
key,
auto_updates,
e,
&download_request,
)?;
} else if is_auto_update {
match handle_auto_update_success(key.0.clone(), key.1.clone()) {
Ok(_) => print_to_terminal(
1,
&format!(
"auto_update: successfully downloaded package {:?} version {}",
&download_request.package_id,
&download_request.desired_version_hash
),
),
Err(_) => {
if let Some(meta) = metadata {
try_next_mirror(
meta,
key,
auto_updates,
DownloadError::HandlingError(
"could not get manifest hash".to_string(),
),
);
}
} }
} }
} else {
println!("got http-client error: {resp:?}");
} }
} }
} }
@ -507,6 +623,70 @@ fn handle_message(
Ok(()) Ok(())
} }
/// Try the next available mirror for a download, recording the current mirror's failure
fn try_next_mirror(
mut metadata: AutoUpdateStatus,
key: (PackageId, String),
auto_updates: &mut AutoUpdates,
error: DownloadError,
) {
print_to_terminal(
1,
&format!(
"auto_update: got error from mirror {mirror:?} {error:?}, trying next mirror: {next_mirror:?}",
next_mirror = metadata.mirrors_left.iter().next().cloned(),
mirror = metadata.active_mirror,
error = error
),
);
// Record failure and remove from available mirrors
metadata
.mirrors_failed
.push((metadata.active_mirror.clone(), error));
metadata.mirrors_left.remove(&metadata.active_mirror);
let (package_id, version_hash) = key.clone();
match metadata.mirrors_left.iter().next().cloned() {
Some(next_mirror) => {
metadata.active_mirror = next_mirror.clone();
auto_updates.insert(key, metadata);
Request::to(("our", "downloads", "app_store", "sys"))
.body(
serde_json::to_vec(&DownloadRequests::LocalDownload(LocalDownloadRequest {
package_id: crate::kinode::process::main::PackageId::from_process_lib(
package_id,
),
download_from: next_mirror,
desired_version_hash: version_hash.clone(),
}))
.unwrap(),
)
.send()
.unwrap();
}
None => {
print_to_terminal(
1,
"auto_update: no more mirrors to try for package_id {package_id:?}",
);
// gather, and send error to main.
let node_tries = metadata.mirrors_failed;
let auto_download_error = AutoDownloadCompleteRequest::Err(AutoDownloadError {
package_id: crate::kinode::process::main::PackageId::from_process_lib(package_id),
version_hash,
tries: node_tries,
});
Request::to(("our", "main", "app_store", "sys"))
.body(auto_download_error)
.send()
.unwrap();
auto_updates.remove(&key);
}
}
}
fn handle_receive_http_download( fn handle_receive_http_download(
download_request: &LocalDownloadRequest, download_request: &LocalDownloadRequest,
) -> anyhow::Result<(), DownloadError> { ) -> anyhow::Result<(), DownloadError> {
@ -558,6 +738,46 @@ fn handle_receive_http_download(
Ok(()) Ok(())
} }
fn handle_download_error(
is_auto_update: bool,
metadata: Option<AutoUpdateStatus>,
key: (PackageId, String),
auto_updates: &mut AutoUpdates,
error: impl Into<DownloadError>,
download_request: &LocalDownloadRequest,
) -> anyhow::Result<()> {
let error = error.into();
if is_auto_update {
if let Some(meta) = metadata {
try_next_mirror(meta, key, auto_updates, error);
}
} else {
Request::to(("our", "main", "app_store", "sys"))
.body(DownloadCompleteRequest {
package_id: download_request.package_id.clone(),
version_hash: download_request.desired_version_hash.clone(),
err: Some(error),
})
.send()?;
}
Ok(())
}
/// Handle auto-update success case by getting manifest hash and sending completion message
fn handle_auto_update_success(package_id: PackageId, version_hash: String) -> anyhow::Result<()> {
let manifest_hash = get_manifest_hash(package_id.clone(), version_hash.clone())?;
Request::to(("our", "main", "app_store", "sys"))
.body(AutoDownloadCompleteRequest::Success(AutoDownloadSuccess {
package_id: crate::kinode::process::main::PackageId::from_process_lib(package_id),
version_hash,
manifest_hash,
}))
.send()
.unwrap();
Ok(())
}
fn format_entries(entries: Vec<vfs::DirEntry>, state: &State) -> Vec<Entry> { fn format_entries(entries: Vec<vfs::DirEntry>, state: &State) -> Vec<Entry> {
entries entries
.into_iter() .into_iter()

View File

@ -17,7 +17,6 @@ pub fn spawn_send_transfer(
our: &Address, our: &Address,
package_id: &PackageId, package_id: &PackageId,
version_hash: &str, version_hash: &str,
timeout: u64,
to_addr: &Address, to_addr: &Address,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let transfer_id: u64 = rand::random(); let transfer_id: u64 = rand::random();
@ -33,17 +32,14 @@ pub fn spawn_send_transfer(
return Err(anyhow::anyhow!("failed to spawn ft-worker!")); return Err(anyhow::anyhow!("failed to spawn ft-worker!"));
}; };
let req = Request::new() let req = Request::new().target((&our.node, worker_process_id)).body(
.target((&our.node, worker_process_id)) serde_json::to_vec(&DownloadRequests::RemoteDownload(RemoteDownloadRequest {
.expects_response(timeout + 1) package_id: package_id.clone(),
.body( desired_version_hash: version_hash.to_string(),
serde_json::to_vec(&DownloadRequests::RemoteDownload(RemoteDownloadRequest { worker_address: to_addr.to_string(),
package_id: package_id.clone(), }))
desired_version_hash: version_hash.to_string(), .unwrap(),
worker_address: to_addr.to_string(), );
}))
.unwrap(),
);
req.send()?; req.send()?;
Ok(()) Ok(())
} }
@ -58,7 +54,6 @@ pub fn spawn_receive_transfer(
package_id: &PackageId, package_id: &PackageId,
version_hash: &str, version_hash: &str,
from_node: &str, from_node: &str,
timeout: u64,
) -> anyhow::Result<Address> { ) -> anyhow::Result<Address> {
let transfer_id: u64 = rand::random(); let transfer_id: u64 = rand::random();
let timer_id = ProcessId::new(Some("timer"), "distro", "sys"); let timer_id = ProcessId::new(Some("timer"), "distro", "sys");
@ -75,7 +70,6 @@ pub fn spawn_receive_transfer(
let req = Request::new() let req = Request::new()
.target((&our.node, worker_process_id.clone())) .target((&our.node, worker_process_id.clone()))
.expects_response(timeout + 1)
.body( .body(
serde_json::to_vec(&DownloadRequests::LocalDownload(LocalDownloadRequest { serde_json::to_vec(&DownloadRequests::LocalDownload(LocalDownloadRequest {
package_id: package_id.clone(), package_id: package_id.clone(),

View File

@ -29,6 +29,7 @@
//! //!
//! - Hash mismatches between the received file and the expected hash are detected and reported. //! - Hash mismatches between the received file and the expected hash are detected and reported.
//! - Various I/O errors are caught and propagated. //! - Various I/O errors are caught and propagated.
//! - A 120 second killswitch is implemented to clean up dangling transfers.
//! //!
//! ## Integration with App Store: //! ## Integration with App Store:
//! //!
@ -61,6 +62,7 @@ wit_bindgen::generate!({
}); });
const CHUNK_SIZE: u64 = 262144; // 256KB const CHUNK_SIZE: u64 = 262144; // 256KB
const KILL_SWITCH_MS: u64 = 120000; // 2 minutes
call_init!(init); call_init!(init);
fn init(our: Address) { fn init(our: Address) {
@ -78,8 +80,7 @@ fn init(our: Address) {
} }
// killswitch timer, 2 minutes. sender or receiver gets killed/cleaned up. // killswitch timer, 2 minutes. sender or receiver gets killed/cleaned up.
// TODO: killswitch update bubbles up to downloads process? timer::set_timer(KILL_SWITCH_MS, None);
timer::set_timer(120000, None);
let start = std::time::Instant::now(); let start = std::time::Instant::now();
@ -105,7 +106,23 @@ fn init(our: Address) {
start.elapsed().as_millis() start.elapsed().as_millis()
), ),
), ),
Err(e) => print_to_terminal(1, &format!("ft_worker: receive error: {}", e)), Err(e) => {
print_to_terminal(1, &format!("ft_worker: receive error: {}", e));
// bubble up to parent.
// TODO: doublecheck this.
// if this fires on a basic timeout, that's bad.
Request::new()
.body(DownloadRequests::DownloadComplete(
DownloadCompleteRequest {
package_id: package_id.clone().into(),
version_hash: desired_version_hash.to_string(),
err: Some(DownloadError::HandlingError(e.to_string())),
},
))
.target(parent_process)
.send()
.unwrap();
}
} }
} }
DownloadRequests::RemoteDownload(remote_request) => { DownloadRequests::RemoteDownload(remote_request) => {
@ -187,6 +204,17 @@ fn handle_receiver(
loop { loop {
let message = await_message()?; let message = await_message()?;
if *message.source() == timer_address { if *message.source() == timer_address {
// send error message to downloads process
Request::new()
.body(DownloadRequests::DownloadComplete(
DownloadCompleteRequest {
package_id: package_id.clone().into(),
version_hash: version_hash.to_string(),
err: Some(DownloadError::Timeout),
},
))
.target(parent_process.clone())
.send()?;
return Ok(()); return Ok(());
} }
if !message.is_request() { if !message.is_request() {

View File

@ -1,11 +1,16 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { STORE_PATH, PUBLISH_PATH, MY_APPS_PATH } from '../constants/path'; import { STORE_PATH, PUBLISH_PATH, MY_APPS_PATH } from '../constants/path';
import { ConnectButton } from '@rainbow-me/rainbowkit'; import { ConnectButton } from '@rainbow-me/rainbowkit';
import { FaHome } from "react-icons/fa"; import { FaHome } from "react-icons/fa";
import NotificationBay from './NotificationBay'; import NotificationBay from './NotificationBay';
import useAppsStore from '../store';
const Header: React.FC = () => { const Header: React.FC = () => {
const location = useLocation();
const { updates } = useAppsStore();
const updateCount = Object.keys(updates || {}).length;
return ( return (
<header className="app-header"> <header className="app-header">
<div className="header-left"> <div className="header-left">
@ -15,7 +20,10 @@ const Header: React.FC = () => {
</button> </button>
<Link to={STORE_PATH} className={location.pathname === STORE_PATH ? 'active' : ''}>Apps</Link> <Link to={STORE_PATH} className={location.pathname === STORE_PATH ? 'active' : ''}>Apps</Link>
<Link to={PUBLISH_PATH} className={location.pathname === PUBLISH_PATH ? 'active' : ''}>Publish</Link> <Link to={PUBLISH_PATH} className={location.pathname === PUBLISH_PATH ? 'active' : ''}>Publish</Link>
<Link to={MY_APPS_PATH} className={location.pathname === MY_APPS_PATH ? 'active' : ''}>My Apps</Link> <Link to={MY_APPS_PATH} className={location.pathname === MY_APPS_PATH ? 'active' : ''}>
My Apps
{updateCount > 0 && <span className="update-badge">{updateCount}</span>}
</Link>
</nav> </nav>
</div> </div>
<div className="header-right"> <div className="header-right">
@ -25,4 +33,5 @@ const Header: React.FC = () => {
</header> </header>
); );
}; };
export default Header; export default Header;

View File

@ -0,0 +1,16 @@
import React from 'react';
interface TooltipProps {
content: React.ReactNode;
children?: React.ReactNode;
}
export function Tooltip({ content, children }: TooltipProps) {
return (
<div className="tooltip-container">
{children}
<span className="tooltip-icon"></span>
<div className="tooltip-content">{content}</div>
</div>
);
}

View File

@ -1,9 +1,37 @@
:root {
/* Core colors */
--orange: #ff7e33;
--dark-orange: #e56a24;
--orange-hover: #ff9900;
--red: #e53e3e;
--blue: #4299e1;
--green: #48bb78;
--gray: #718096;
/* Sophisticated neutrals */
--bg-light: #fdf6e3;
/* Solarized inspired beige */
--bg-dark: #1f1d24;
/* Deep slate with hint of purple */
--surface-light: #f5efd9;
/* Slightly deeper complementary beige */
--surface-dark: #2a2832;
/* Rich eggplant-tinged dark */
--text-light: #2d2a2e;
/* Warm charcoal */
--text-dark: #e8e6f0;
/* Cool moonlight white */
/* Border radius */
--border-radius: 8px;
}
/* Base styles */ /* Base styles */
body { body {
font-family: var(--font-family-main); font-family: var(--font-family-main);
line-height: 1.6; line-height: 1.6;
color: light-dark(var(--off-black), var(--off-white)); color: light-dark(var(--text-light), var(--text-dark));
background-color: light-dark(var(--tan), var(--tasteful-dark)); background-color: light-dark(var(--bg-light), var(--bg-dark));
} }
/* Layout */ /* Layout */
@ -35,7 +63,7 @@ a:hover {
/* Header */ /* Header */
.app-header { .app-header {
background-color: light-dark(var(--off-white), var(--off-black)); background-color: light-dark(var(--surface-light), var(--surface-dark));
padding: 1rem; padding: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
display: flex; display: flex;
@ -71,12 +99,15 @@ a:hover {
text-decoration: none; text-decoration: none;
padding: 0.5rem; padding: 0.5rem;
border-radius: var(--border-radius); border-radius: var(--border-radius);
position: relative;
display: inline-flex;
align-items: center;
} }
.header-left nav a:hover, .header-left nav a:hover,
.header-left nav a.active { .header-left nav a.active {
background-color: var(--orange); background-color: var(--orange);
color: var(--white); color: var(--text-light);
} }
/* Forms */ /* Forms */
@ -91,6 +122,9 @@ form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-bottom: 1rem; margin-bottom: 1rem;
background: light-dark(var(--surface-light), var(--surface-dark));
padding: 0.75rem;
border-radius: var(--border-radius);
} }
label { label {
@ -102,15 +136,21 @@ select {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid var(--gray); border: 1px solid var(--gray);
border-radius: var(--border-radius); border-radius: var(--border-radius);
background-color: light-dark(var(--white), var(--tasteful-dark)); background-color: light-dark(var(--surface-light), var(--surface-dark));
color: light-dark(var(--off-black), var(--off-white)); color: light-dark(var(--text-light), var(--text-dark));
} }
/* Buttons */ /* Buttons */
button { button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 40px;
font-weight: 500;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: var(--orange); background-color: var(--orange);
color: var(--white); color: var(--text-light);
border: none; border: none;
border-radius: var(--border-radius); border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
@ -125,6 +165,36 @@ button:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
button.danger {
background-color: var(--red);
}
button.danger:hover {
background-color: color-mix(in srgb, var(--red) 85%, black);
}
/*Download Button */
.download-btn {
background: var(--orange);
color: var(--text-light);
border: none;
}
.download-btn:hover {
background: var(--dark-orange);
}
/* Notification Button */
/* .notification-btn {
background: var(--surface-dark);
color: var(--text);
border: 1px solid var(--gray);
}
.notification-btn:hover {
background: var(--surface-hover);
} */
/* Tables */ /* Tables */
table { table {
width: 100%; width: 100%;
@ -151,6 +221,9 @@ td {
/* Messages */ /* Messages */
.message { .message {
display: flex;
align-items: center;
font-weight: 500;
padding: 1rem; padding: 1rem;
border-radius: var(--border-radius); border-radius: var(--border-radius);
margin-bottom: 1rem; margin-bottom: 1rem;
@ -158,17 +231,18 @@ td {
.message.error { .message.error {
background-color: var(--red); background-color: var(--red);
color: var(--white); color: var(--text-light);
} }
.message.success { .message.success {
background-color: var(--green); background: light-dark(var(--surface-light), var(--surface-dark));
color: var(--white); color: light-dark(var(--text-light), var(--text-dark));
border: 1px solid var(--green);
} }
.message.info { .message.info {
background-color: var(--blue); background-color: var(--blue);
color: var(--white); color: var(--text-light);
} }
/* Publisher Info */ /* Publisher Info */
@ -242,17 +316,24 @@ td {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: var(--red); color: var(--red);
margin-top: 0.5rem;
font-size: 0.9rem; font-size: 0.9rem;
margin-top: 0.25rem;
} }
/* App Page and Download Page shared styles */ /* Shared page styles */
.store-page,
.app-page, .app-page,
.downloads-page { .my-apps-page,
background-color: light-dark(var(--white), var(--maroon)); .downloads-page,
.publish-page {
padding: 1rem;
background: light-dark(var(--bg-light), var(--bg-dark));
margin: 0 1vw;
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: 2rem; }
width: 100%;
.app-info {
max-width: 20rem;
} }
.app-header { .app-header {
@ -268,12 +349,26 @@ td {
} }
.app-info { .app-info {
background-color: light-dark(var(--tan), var(--tasteful-dark)); background: light-dark(var(--surface-light), var(--surface-dark));
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: 1.5rem; padding: 1.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
/* Components with secondary backgrounds */
.app-header,
.app-info,
.app-description,
.form-group,
.search-bar input,
.version-selector,
.mirror-selector select,
.secondary,
.message.success {
background: light-dark(var(--surface-light), var(--surface-dark)) !important;
color: light-dark(var(--text-light), var(--text-dark));
}
/* Download Page specific styles */ /* Download Page specific styles */
.download-section { .download-section {
display: flex; display: flex;
@ -289,8 +384,8 @@ td {
padding: 0.5em; padding: 0.5em;
border: 1px solid var(--gray); border: 1px solid var(--gray);
border-radius: var(--border-radius); border-radius: var(--border-radius);
background-color: light-dark(var(--white), var(--tasteful-dark)); background-color: light-dark(var(--surface-light), var(--surface-dark));
color: light-dark(var(--off-black), var(--off-white)); color: light-dark(var(--text-light), var(--text-dark));
} }
/* Action Buttons */ /* Action Buttons */
@ -311,23 +406,23 @@ td {
.primary { .primary {
background-color: var(--orange); background-color: var(--orange);
color: var(--white); color: var(--text-light);
} }
.primary:hover:not(:disabled) { .primary:hover:not(:disabled) {
background-color: var(--dark-orange); background-color: var(--dark-orange);
color: var(--white); color: var(--text-light);
} }
.secondary { .secondary {
background-color: light-dark(var(--off-white), var(--off-black)); background-color: light-dark(var(--surface-light), var(--surface-dark));
color: var(--orange); color: var(--orange);
border: 2px solid var(--orange); border: 2px solid var(--orange);
} }
.secondary:hover:not(:disabled) { .secondary:hover:not(:disabled) {
background-color: var(--orange); background-color: var(--orange);
color: var(--white); color: var(--text-light);
} }
.action-button:disabled, .action-button:disabled,
@ -337,6 +432,21 @@ td {
cursor: not-allowed; cursor: not-allowed;
} }
.action-button.download-button {
background: var(--orange);
color: var(--text-light);
border: none;
}
.action-button.download-button:hover:not(:disabled) {
background: var(--dark-orange);
}
.action-button.download-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* App actions */ /* App actions */
.app-actions { .app-actions {
display: flex; display: flex;
@ -385,8 +495,8 @@ td {
} }
.cap-approval-content { .cap-approval-content {
background-color: light-dark(var(--white), var(--tasteful-dark)); background-color: light-dark(var(--surface-light), var(--surface-dark));
color: light-dark(var(--off-black), var(--off-white)); color: light-dark(var(--text-light), var(--text-dark));
padding: 2rem; padding: 2rem;
border-radius: 8px; border-radius: 8px;
max-width: 80%; max-width: 80%;
@ -395,8 +505,8 @@ td {
} }
.json-display { .json-display {
background-color: light-dark(var(--tan), var(--off-black)); background-color: light-dark(var(--surface-light), var(--surface-dark));
color: light-dark(var(--off-black), var(--off-white)); color: light-dark(var(--text-light), var(--text-dark));
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: 4px;
white-space: pre-wrap; white-space: pre-wrap;
@ -410,6 +520,44 @@ td {
margin-top: 1rem; margin-top: 1rem;
} }
/* Search bar */
.search-bar {
width: 100%;
margin: 1rem auto 2rem;
position: relative;
}
.search-bar input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 2.5rem;
border: 2px solid transparent;
border-radius: 2rem;
background: light-dark(var(--surface-light), var(--surface-dark));
color: light-dark(var(--text-light), var(--text-dark));
font-size: 1rem;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.search-bar input:focus {
outline: none;
border-color: var(--orange);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.search-bar svg {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--gray);
pointer-events: none;
}
.search-bar input::placeholder {
color: var(--gray);
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 48em) { @media (max-width: 48em) {
@ -442,7 +590,7 @@ td {
} }
.manifest-display { .manifest-display {
background: light-dark(var(--white), var(--tasteful-dark)); background: light-dark(var(--surface-light), var(--surface-dark));
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: 1rem; padding: 1rem;
max-width: 600px; max-width: 600px;
@ -450,7 +598,7 @@ td {
.process-manifest { .process-manifest {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
border: 1px solid light-dark(var(--gray), var(--off-black)); border: 1px solid light-dark(var(--gray), var(--surface-dark));
border-radius: var(--border-radius); border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
} }
@ -464,12 +612,12 @@ td {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: light-dark(var(--off-black), var(--off-white)); color: light-dark(var(--text-light), var(--text-dark));
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.process-header:hover { .process-header:hover {
background: light-dark(var(--tan), var(--off-black)); background-color: light-dark(var(--surface-light), var(--surface-dark));
} }
.process-name { .process-name {
@ -481,7 +629,7 @@ td {
.process-indicators { .process-indicators {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
color: light-dark(var(--gray), var(--off-white)); color: light-dark(var(--gray), var(--text-dark));
} }
.network-icon { .network-icon {
@ -498,8 +646,8 @@ td {
.process-details { .process-details {
padding: 1rem; padding: 1rem;
background: light-dark(var(--tan), var(--off-black)); background: light-dark(var(--surface-light), var(--surface-dark));
border-top: 1px solid light-dark(var(--gray), var(--off-black)); border-top: 1px solid light-dark(var(--gray), var(--surface-dark));
} }
.capability-section { .capability-section {
@ -512,13 +660,13 @@ td {
.capability-section h4 { .capability-section h4 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: light-dark(var(--off-black), var(--off-white)); color: light-dark(var(--text-light), var(--text-dark));
} }
.capability-section ul { .capability-section ul {
margin: 0; margin: 0;
padding-left: 1.5rem; padding-left: 1.5rem;
color: light-dark(var(--gray), var(--off-white)); color: light-dark(var(--gray), var(--text-dark));
} }
.capability-section li { .capability-section li {
@ -538,7 +686,7 @@ td {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem; padding: 0.5rem;
color: light-dark(var(--off-black), var(--off-white)); color: light-dark(var(--text-light), var(--text-dark));
} }
.notification-details { .notification-details {
@ -548,7 +696,7 @@ td {
width: 320px; width: 320px;
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
background-color: light-dark(var(--white), var(--tasteful-dark)); background-color: light-dark(var(--surface-light), var(--surface-dark));
border-radius: var(--border-radius); border-radius: var(--border-radius);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000; z-index: 1000;
@ -557,7 +705,7 @@ td {
.badge { .badge {
background-color: var(--orange); background-color: var(--orange);
color: var(--white); color: var(--text-light);
border-radius: 50%; border-radius: 50%;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
font-size: 0.75rem; font-size: 0.75rem;
@ -571,8 +719,8 @@ td {
padding: 1rem; padding: 1rem;
margin: 0.5rem 0; margin: 0.5rem 0;
border-radius: var(--border-radius); border-radius: var(--border-radius);
background-color: light-dark(var(--tan), var(--off-black)); background-color: light-dark(var(--surface-light), var(--surface-dark));
color: light-dark(var(--off-black), var(--off-white)); color: light-dark(var(--text-light), var(--text-dark));
} }
.notification-item.error { .notification-item.error {
@ -606,7 +754,7 @@ td {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: light-dark(var(--gray), var(--off-white)); color: light-dark(var(--gray), var(--text-dark));
padding: 0.25rem; padding: 0.25rem;
} }
@ -617,7 +765,7 @@ td {
.progress-bar { .progress-bar {
margin-top: 0.5rem; margin-top: 0.5rem;
height: 4px; height: 4px;
background-color: light-dark(var(--white), var(--off-black)); background-color: light-dark(var(--surface-light), var(--surface-dark));
border-radius: 2px; border-radius: 2px;
overflow: hidden; overflow: hidden;
} }
@ -643,8 +791,8 @@ td {
} }
.modal-content { .modal-content {
background-color: light-dark(var(--white), var(--tasteful-dark)); background-color: light-dark(var(--surface-light), var(--surface-dark));
color: light-dark(var(--off-black), var(--off-white)); color: light-dark(var(--text-light), var(--text-dark));
padding: 1.5rem; padding: 1.5rem;
border-radius: var(--border-radius); border-radius: var(--border-radius);
position: relative; position: relative;
@ -660,7 +808,7 @@ td {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: light-dark(var(--gray), var(--off-white)); color: light-dark(var(--gray), var(--text-dark));
padding: 0.25rem; padding: 0.25rem;
} }
@ -713,4 +861,430 @@ td {
100% { 100% {
opacity: 1; opacity: 1;
} }
}
/* Loading Spinner */
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
margin-right: 8px;
border: 2px solid var(--text-light);
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s linear infinite;
}
.loading-spinner.small {
width: 14px;
height: 14px;
margin-right: 6px;
border-width: 1.5px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Publish Page */
.publish-page {
padding: 1rem;
}
.publish-page h1 {
margin-bottom: 2rem;
}
.connect-wallet {
text-align: center;
padding: 2rem;
background: light-dark(var(--surface-light), var(--surface-dark));
border-radius: var(--border-radius);
margin-bottom: 2rem;
}
.publish-form {
background: light-dark(var(--surface-light), var(--surface-dark));
padding: 2rem;
border-radius: var(--border-radius);
margin-bottom: 2rem;
}
.package-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 1rem;
}
.package-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: light-dark(var(--surface-light), var(--surface-dark));
border-radius: var(--border-radius);
}
.package-list .app-name {
display: flex;
align-items: center;
gap: 1rem;
color: inherit;
text-decoration: none;
}
.package-list .app-name:hover {
color: var(--orange);
}
.package-icon {
width: 32px;
height: 32px;
border-radius: var(--border-radius);
}
.no-packages {
text-align: center;
padding: 2rem;
background: light-dark(var(--surface-light), var(--surface-dark));
border-radius: var(--border-radius);
color: var(--gray);
}
/* Update badge */
.update-badge {
background: var(--red);
color: var(--text-light);
border-radius: 50%;
padding: 0.15rem 0.4rem;
font-size: 0.75rem;
position: absolute;
top: -5px;
right: -5px;
min-width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
/* Updates section */
.updates-section {
margin-bottom: 2rem;
}
.section-title {
color: var(--orange);
font-size: 1.25rem;
margin-bottom: 1rem;
}
.updates-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.update-item {
background-color: light-dark(var(--surface-light), var(--surface-dark));
border-radius: var(--border-radius);
overflow: hidden;
border: 1px solid transparent;
}
.update-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.update-header:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.update-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 500;
}
.update-actions {
display: flex;
gap: 0.5rem;
}
.update-actions .action-button {
background: none;
border: none;
cursor: pointer;
color: var(--gray);
transition: color 0.2s;
display: flex;
align-items: center;
}
.update-actions .action-button.retry:hover {
color: var(--blue);
}
.update-actions .action-button.clear:hover {
color: var(--red);
}
.update-details {
padding: 0.75rem 1rem 1rem 2.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.version-info {
color: var(--gray);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.manifest-info {
color: var(--orange);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.error-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.error-item {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--red);
font-size: 0.9rem;
}
.error-icon {
flex-shrink: 0;
}
/* App Page Layout */
.app-page {
max-width: 80rem;
margin: 0 auto;
padding: 2rem 1rem;
}
/* Updates Section */
.updates-section {
margin-bottom: 8;
}
.update-item {
border: 1px solid transparent;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
background-color: light-dark(var(--surface-light), var(--surface-dark));
border: 1px solid light-dark(var(--gray), var(--surface-dark));
}
.update-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.update-summary {
display: flex;
align-items: center;
gap: 0.5rem;
}
.update-details {
margin-top: 1rem;
color: light-dark(var(--text-secondary), var(--text));
}
.retry-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.375rem;
font-size: 1rem;
background-color: light-dark(var(--surface-light), var(--surface-dark));
color: var(--orange);
border: 1px solid var(--orange);
transition: background-color 0.2s, color 0.2s;
}
.error-count {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
background-color: light-dark(var(--red-100), var(--red-900));
color: light-dark(var(--red-700), var(--red-200));
}
/* Navigation */
.navigation {
display: flex;
align-items: center;
gap: 4;
margin-bottom: 1.5rem;
}
.nav-button {
display: flex;
align-items: center;
gap: 2;
padding: 0.75rem 1rem;
border-radius: 0.375rem;
font-size: 1rem;
background-color: light-dark(var(--surface-light), var(--surface-dark));
color: var(--orange);
border: 1px solid var(--orange);
transition: background-color 0.2s, color 0.2s;
}
.current-path {
font-size: 1rem;
color: light-dark(var(--text-secondary), var(--text));
}
.file-explorer {
border: 1px solid light-dark(var(--gray), var(--surface-dark));
padding: 1rem;
border-radius: var(--border-radius);
background: light-dark(var(--surface-light), var(--surface-dark));
}
.file-explorer h3 {
padding: 0.75rem 1rem;
font-size: 1.125rem;
font-weight: 500;
background-color: light-dark(var(--surface-light), var(--surface-dark));
border-bottom: 1px solid light-dark(var(--gray), var(--surface-dark));
}
.downloads-table {
width: 100%;
border-radius: var(--border-radius);
overflow: hidden;
}
.downloads-table th {
padding: 0.75rem 1rem;
font-size: 1rem;
font-weight: 500;
text-align: left;
color: light-dark(var(--text-secondary), var(--text));
background-color: light-dark(var(--surface-light), var(--surface-dark));
border-bottom: 1px solid light-dark(var(--gray), var(--surface-dark));
}
.downloads-table td {
padding: 0.75rem 1rem;
font-size: 1rem;
border-bottom: 1px solid light-dark(var(--gray), var(--surface-dark));
}
.downloads-table tr.file:hover,
.downloads-table tr.directory:hover {
background-color: light-dark(var(--surface-light), var(--surface-dark));
cursor: pointer;
}
.updates-section {
background: light-dark(var(--surface-light), var(--surface-dark));
padding: 1rem;
margin-bottom: 1rem;
border-radius: var(--border-radius);
}
.tooltip-container {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
}
.tooltip-icon {
cursor: help;
color: #666;
font-size: 14px;
position: relative;
}
.tooltip-content {
position: absolute;
left: 24px;
top: -4px;
background: #333;
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
white-space: nowrap;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
min-width: max-content;
}
/* Create an invisible bridge between icon and content */
.tooltip-content::after {
content: '';
position: absolute;
left: -20px; /* Cover the gap between icon and content */
top: 0;
width: 20px;
height: 100%;
background: transparent;
}
.tooltip-container:hover .tooltip-content {
opacity: 1;
visibility: visible;
transition-delay: 0.2s;
}
.tooltip-content:hover {
opacity: 1 !important;
visibility: visible !important;
}
.tooltip-content::before {
content: '';
position: absolute;
left: -4px;
top: 8px;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-right: 4px solid #333;
}
.tooltip-content a {
color: #fff;
text-decoration: underline;
}
.tooltip-content a:hover {
text-decoration: none;
}
.wallet-status {
display: flex;
align-items: center;
gap: 4px;
} }

View File

@ -148,6 +148,12 @@ export default function AppPage() {
{latestVersion && ( {latestVersion && (
<li><span>Latest Version:</span> <span>{latestVersion}</span></li> <li><span>Latest Version:</span> <span>{latestVersion}</span></li>
)} )}
{installedApp?.pending_update_hash && (
<li className="warning">
<span>Failed Auto-Update:</span>
<span>Update to version with hash {installedApp.pending_update_hash.slice(0, 8)}... failed, approve newly requested capabilities and install it here:</span>
</li>
)}
<li><span>Publisher:</span> <span>{app.package_id.publisher_node}</span></li> <li><span>Publisher:</span> <span>{app.package_id.publisher_node}</span></li>
<li><span>License:</span> <span>{app.metadata?.properties?.license || "Not specified"}</span></li> <li><span>License:</span> <span>{app.metadata?.properties?.license || "Not specified"}</span></li>
<li> <li>

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { FaFolder, FaFile, FaChevronLeft, FaSync, FaRocket, FaSpinner, FaCheck, FaTrash } from "react-icons/fa"; import { FaFolder, FaFile, FaChevronLeft, FaSync, FaRocket, FaSpinner, FaCheck, FaTrash, FaExclamationTriangle, FaTimesCircle, FaChevronDown, FaChevronRight } from "react-icons/fa";
import { useNavigate } from "react-router-dom";
import useAppsStore from "../store"; import useAppsStore from "../store";
import { DownloadItem, PackageManifest, PackageState } from "../types/Apps"; import { DownloadItem, PackageManifestEntry, PackageState, Updates, DownloadError, UpdateInfo } from "../types/Apps";
// Core packages that cannot be uninstalled // Core packages that cannot be uninstalled
const CORE_PACKAGES = [ const CORE_PACKAGES = [
@ -16,6 +17,7 @@ const CORE_PACKAGES = [
]; ];
export default function MyAppsPage() { export default function MyAppsPage() {
const navigate = useNavigate();
const { const {
fetchDownloads, fetchDownloads,
fetchDownloadsForApp, fetchDownloadsForApp,
@ -25,16 +27,20 @@ export default function MyAppsPage() {
removeDownload, removeDownload,
fetchInstalled, fetchInstalled,
installed, installed,
uninstallApp uninstallApp,
fetchUpdates,
clearUpdates,
updates
} = useAppsStore(); } = useAppsStore();
const [currentPath, setCurrentPath] = useState<string[]>([]); const [currentPath, setCurrentPath] = useState<string[]>([]);
const [items, setItems] = useState<DownloadItem[]>([]); const [items, setItems] = useState<DownloadItem[]>([]);
const [expandedUpdates, setExpandedUpdates] = useState<Set<string>>(new Set());
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const [isUninstalling, setIsUninstalling] = useState(false); const [isUninstalling, setIsUninstalling] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showCapApproval, setShowCapApproval] = useState(false); const [showCapApproval, setShowCapApproval] = useState(false);
const [manifest, setManifest] = useState<PackageManifest | null>(null); const [manifest, setManifest] = useState<PackageManifestEntry | null>(null);
const [selectedItem, setSelectedItem] = useState<DownloadItem | null>(null); const [selectedItem, setSelectedItem] = useState<DownloadItem | null>(null);
const [showUninstallConfirm, setShowUninstallConfirm] = useState(false); const [showUninstallConfirm, setShowUninstallConfirm] = useState(false);
const [appToUninstall, setAppToUninstall] = useState<any>(null); const [appToUninstall, setAppToUninstall] = useState<any>(null);
@ -42,6 +48,7 @@ export default function MyAppsPage() {
useEffect(() => { useEffect(() => {
loadItems(); loadItems();
fetchInstalled(); fetchInstalled();
fetchUpdates();
}, [currentPath]); }, [currentPath]);
const loadItems = async () => { const loadItems = async () => {
@ -59,34 +66,132 @@ export default function MyAppsPage() {
} }
}; };
const initiateUninstall = (app: any) => { const handleClearUpdates = async (packageId: string) => {
const packageId = `${app.package_id.package_name}:${app.package_id.publisher_node}`; await clearUpdates(packageId);
if (CORE_PACKAGES.includes(packageId)) { fetchUpdates(); // Refresh updates after clearing
setError("Cannot uninstall core system packages");
return;
}
setAppToUninstall(app);
setShowUninstallConfirm(true);
}; };
const handleUninstall = async () => { const toggleUpdateExpansion = (packageId: string) => {
if (!appToUninstall) return; setExpandedUpdates(prev => {
setIsUninstalling(true); const newSet = new Set(prev);
const packageId = `${appToUninstall.package_id.package_name}:${appToUninstall.package_id.publisher_node}`; if (newSet.has(packageId)) {
try { newSet.delete(packageId);
await uninstallApp(packageId); } else {
await fetchInstalled(); newSet.add(packageId);
await loadItems(); }
setShowUninstallConfirm(false); return newSet;
setAppToUninstall(null); });
} catch (error) {
console.error('Uninstallation failed:', error);
setError(`Uninstallation failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsUninstalling(false);
}
}; };
const formatError = (error: DownloadError): string => {
if (typeof error === 'string') {
return error;
} else if ('HashMismatch' in error) {
return `Hash mismatch (expected ${error.HashMismatch.desired.slice(0, 8)}, got ${error.HashMismatch.actual.slice(0, 8)})`;
} else if ('HandlingError' in error) {
return error.HandlingError;
} else if ('Timeout' in error) {
return 'Connection timed out';
}
return 'Unknown error';
};
const renderUpdates = () => {
if (!updates || Object.keys(updates).length === 0) {
return (
<div className="updates-section">
<h2>Failed Auto Updates (0)</h2>
<p>None found, all clear!</p>
</div>
);
}
return (
<div className="updates-section">
<h2 className="section-title">Failed Auto Updates ({Object.keys(updates).length})</h2>
{Object.keys(updates).length > 0 ? (
<div className="updates-list">
{Object.entries(updates).map(([packageId, versionMap]) => {
const totalErrors = Object.values(versionMap).reduce((sum, info) =>
sum + (info.errors?.length || 0), 0);
const hasManifestChanges = Object.values(versionMap).some(info =>
info.pending_manifest_hash);
return (
<div key={packageId} className="update-item error">
<div className="update-header" onClick={() => toggleUpdateExpansion(packageId)}>
<div className="update-title">
{expandedUpdates.has(packageId) ? <FaChevronDown /> : <FaChevronRight />}
<FaExclamationTriangle className="error-badge" />
<span>{packageId}</span>
<div className="update-summary">
{totalErrors > 0 && (
<span className="error-count">{totalErrors} error{totalErrors !== 1 ? 's' : ''}</span>
)}
{hasManifestChanges && (
<span className="manifest-badge">Manifest changes pending</span>
)}
</div>
</div>
<div className="update-actions">
<button
className="action-button retry"
onClick={(e) => {
e.stopPropagation();
navigate(`/download/${packageId}`);
}}
title="Retry download"
>
<FaSync />
<span>Retry</span>
</button>
<button
className="action-button clear"
onClick={(e) => {
e.stopPropagation();
handleClearUpdates(packageId);
}}
title="Clear update info"
>
<FaTimesCircle />
</button>
</div>
</div>
{expandedUpdates.has(packageId) && Object.entries(versionMap).map(([versionHash, info]) => (
<div key={versionHash} className="update-details">
<div className="version-info">
Version: {versionHash.slice(0, 8)}...
</div>
{info.pending_manifest_hash && (
<div className="manifest-info">
<FaExclamationTriangle />
Pending manifest: {info.pending_manifest_hash.slice(0, 8)}...
</div>
)}
{info.errors && info.errors.length > 0 && (
<div className="error-list">
{info.errors.map(([source, error], idx) => (
<div key={idx} className="error-item">
<FaExclamationTriangle className="error-icon" />
<span>{source}: {formatError(error)}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
);
})}
</div>
) : (
<div className="empty-state">
No failed auto updates found.
</div>
)}
</div>
);
};
const navigateToItem = (item: DownloadItem) => { const navigateToItem = (item: DownloadItem) => {
if (item.Dir) { if (item.Dir) {
@ -173,113 +278,149 @@ export default function MyAppsPage() {
return Object.values(installed).some(app => app.package_id.package_name === packageName); return Object.values(installed).some(app => app.package_id.package_name === packageName);
}; };
const initiateUninstall = (app: any) => {
const packageId = `${app.package_id.package_name}:${app.package_id.publisher_node}`;
if (CORE_PACKAGES.includes(packageId)) {
setError("Cannot uninstall core system packages");
return;
}
setAppToUninstall(app);
setShowUninstallConfirm(true);
};
const handleUninstall = async () => {
if (!appToUninstall) return;
setIsUninstalling(true);
const packageId = `${appToUninstall.package_id.package_name}:${appToUninstall.package_id.publisher_node}`;
try {
await uninstallApp(packageId);
await fetchInstalled();
await loadItems();
setShowUninstallConfirm(false);
setAppToUninstall(null);
} catch (error) {
console.error('Uninstallation failed:', error);
setError(`Uninstallation failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsUninstalling(false);
}
};
return ( return (
<div className="downloads-page"> <div className="my-apps-page">
<h2>My Apps</h2> {error && <div className="error-message">{error}</div>}
{renderUpdates()}
{/* Installed Apps Section */} {/* Navigation */}
<div className="file-explorer"> <div className="navigation">
<h3>Installed Apps</h3> {currentPath.length > 0 && (
<table className="downloads-table"> <button onClick={() => setCurrentPath([])} className="nav-button">
<thead> <FaChevronLeft /> Back
<tr> </button>
<th>Package ID</th> )}
<th>Actions</th> <div className="current-path">
</tr> {currentPath.length === 0 ? 'Downloads' : currentPath.join('/')}
</thead> </div>
<tbody>
{Object.values(installed).map((app) => {
const packageId = `${app.package_id.package_name}:${app.package_id.publisher_node}`;
const isCore = CORE_PACKAGES.includes(packageId);
return (
<tr key={packageId}>
<td>{packageId}</td>
<td>
{isCore ? (
<span className="core-package">Core Package</span>
) : (
<button
onClick={() => initiateUninstall(app)}
disabled={isUninstalling}
>
{isUninstalling ? <FaSpinner className="fa-spin" /> : <FaTrash />}
Uninstall
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
{/* Downloads Section */} {/* Items Table */}
<div className="file-explorer"> <div className="items-table-container">
<h3>Downloads</h3> <div className="file-explorer">
<div className="path-navigation"> <h3>Installed Apps</h3>
{currentPath.length > 0 && ( <table className="downloads-table">
<button onClick={navigateUp} className="navigate-up"> <thead>
<FaChevronLeft /> Back <tr>
</button> <th>Package ID</th>
)} <th>Actions</th>
<span className="current-path">/{currentPath.join('/')}</span> </tr>
</thead>
<tbody>
{Object.values(installed).map((app) => {
const packageId = `${app.package_id.package_name}:${app.package_id.publisher_node}`;
const isCore = CORE_PACKAGES.includes(packageId);
return (
<tr key={packageId}>
<td>{packageId}</td>
<td>
{isCore ? (
<span className="core-package">Core Package</span>
) : (
<button
onClick={() => initiateUninstall(app)}
disabled={isUninstalling}
>
{isUninstalling ? <FaSpinner className="fa-spin" /> : <FaTrash />}
Uninstall
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
<table className="downloads-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Mirroring</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const isFile = !!item.File;
const name = isFile ? item.File!.name : item.Dir!.name;
const isInstalled = isFile && isAppInstalled(name);
return (
<tr key={index} onClick={() => navigateToItem(item)} className={isFile ? 'file' : 'directory'}>
<td>
{isFile ? <FaFile /> : <FaFolder />} {name}
</td>
<td>{isFile ? 'File' : 'Directory'}</td>
<td>{isFile ? `${(item.File!.size / 1024).toFixed(2)} KB` : '-'}</td>
<td>{!isFile && (item.Dir!.mirroring ? 'Yes' : 'No')}</td>
<td>
{!isFile && (
<button onClick={(e) => { e.stopPropagation(); toggleMirroring(item); }}>
<FaSync /> {item.Dir!.mirroring ? 'Stop' : 'Start'} Mirroring
</button>
)}
{isFile && !isInstalled && (
<>
<button onClick={(e) => { e.stopPropagation(); handleInstall(item); }}>
<FaRocket /> Install
</button>
<button onClick={(e) => { e.stopPropagation(); handleRemoveDownload(item); }}>
<FaTrash /> Delete
</button>
</>
)}
{isFile && isInstalled && (
<FaCheck className="installed" />
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{error && ( <div className="file-explorer">
<div className="error-message"> <h3>Downloads</h3>
{error} <div className="path-navigation">
{currentPath.length > 0 && (
<button onClick={navigateUp} className="navigate-up">
<FaChevronLeft /> Back
</button>
)}
<span className="current-path">/{currentPath.join('/')}</span>
</div>
<table className="downloads-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Mirroring</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const isFile = !!item.File;
const name = isFile ? item.File!.name : item.Dir!.name;
const isInstalled = isFile && isAppInstalled(name);
return (
<tr key={index} onClick={() => navigateToItem(item)} className={isFile ? 'file' : 'directory'}>
<td>
{isFile ? <FaFile /> : <FaFolder />} {name}
</td>
<td>{isFile ? 'File' : 'Directory'}</td>
<td>{isFile ? `${(item.File!.size / 1024).toFixed(2)} KB` : '-'}</td>
<td>{!isFile && (item.Dir!.mirroring ? 'Yes' : 'No')}</td>
<td>
{!isFile && (
<button onClick={(e) => { e.stopPropagation(); toggleMirroring(item); }}>
<FaSync /> {item.Dir!.mirroring ? 'Stop' : 'Start'} Mirroring
</button>
)}
{isFile && !isInstalled && (
<>
<button onClick={(e) => { e.stopPropagation(); handleInstall(item); }}>
<FaRocket /> Install
</button>
<button onClick={(e) => { e.stopPropagation(); handleRemoveDownload(item); }}>
<FaTrash /> Delete
</button>
</>
)}
{isFile && isInstalled && (
<FaCheck className="installed" />
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
)} </div>
{/* Uninstall Confirmation Modal */} {/* Uninstall Confirmation Modal */}
{showUninstallConfirm && appToUninstall && ( {showUninstallConfirm && appToUninstall && (
@ -318,8 +459,6 @@ export default function MyAppsPage() {
</div> </div>
)} )}
{showCapApproval && manifest && ( {showCapApproval && manifest && (
<div className="cap-approval-popup"> <div className="cap-approval-popup">
<div className="cap-approval-content"> <div className="cap-approval-content">

View File

@ -7,12 +7,13 @@ import { mechAbi, KIMAP, encodeIntoMintCall, encodeMulticalls, kimapAbi, MULTICA
import { kinohash } from '../utils/kinohash'; import { kinohash } from '../utils/kinohash';
import useAppsStore from "../store"; import useAppsStore from "../store";
import { PackageSelector } from "../components"; import { PackageSelector } from "../components";
import { Tooltip } from '../components/Tooltip';
const NAME_INVALID = "Package name must contain only valid characters (a-z, 0-9, -, and .)"; const NAME_INVALID = "Package name must contain only valid characters (a-z, 0-9, -, and .)";
export default function PublishPage() { export default function PublishPage() {
const { openConnectModal } = useConnectModal(); const { openConnectModal } = useConnectModal();
const { ourApps, fetchOurApps, downloads } = useAppsStore(); const { ourApps, fetchOurApps, downloads, fetchDownloadsForApp } = useAppsStore();
const publicClient = usePublicClient(); const publicClient = usePublicClient();
const { address, isConnected, isConnecting } = useAccount(); const { address, isConnected, isConnecting } = useAccount();
@ -23,6 +24,7 @@ export default function PublishPage() {
}); });
const [packageName, setPackageName] = useState<string>(""); const [packageName, setPackageName] = useState<string>("");
// @ts-ignore
const [publisherId, setPublisherId] = useState<string>(window.our?.node || ""); const [publisherId, setPublisherId] = useState<string>(window.our?.node || "");
const [metadataUrl, setMetadataUrl] = useState<string>(""); const [metadataUrl, setMetadataUrl] = useState<string>("");
const [metadataHash, setMetadataHash] = useState<string>(""); const [metadataHash, setMetadataHash] = useState<string>("");
@ -34,6 +36,26 @@ export default function PublishPage() {
fetchOurApps(); fetchOurApps();
}, [fetchOurApps]); }, [fetchOurApps]);
useEffect(() => {
if (packageName && publisherId) {
const id = `${packageName}:${publisherId}`;
fetchDownloadsForApp(id);
}
}, [packageName, publisherId, fetchDownloadsForApp]);
useEffect(() => {
if (isConfirmed) {
// Fetch our apps again after successful publish
fetchOurApps();
// Reset form fields
setPackageName("");
// @ts-ignore
setPublisherId(window.our?.node || "");
setMetadataUrl("");
setMetadataHash("");
}
}, [isConfirmed, fetchOurApps]);
const validatePackageName = useCallback((name: string) => { const validatePackageName = useCallback((name: string) => {
// Allow lowercase letters, numbers, hyphens, and dots // Allow lowercase letters, numbers, hyphens, and dots
const validNameRegex = /^[a-z0-9.-]+$/; const validNameRegex = /^[a-z0-9.-]+$/;
@ -69,9 +91,12 @@ export default function PublishPage() {
// Check if code_hashes exist in metadata and is an object // Check if code_hashes exist in metadata and is an object
if (metadata.properties && metadata.properties.code_hashes && typeof metadata.properties.code_hashes === 'object') { if (metadata.properties && metadata.properties.code_hashes && typeof metadata.properties.code_hashes === 'object') {
const codeHashes = metadata.properties.code_hashes; const codeHashes = metadata.properties.code_hashes;
const missingHashes = Object.entries(codeHashes).filter(([version, hash]) => console.log('Available downloads:', downloads[`${packageName}:${publisherId}`]);
!downloads[`${packageName}:${publisherId}`]?.some(d => d.File?.name === `${hash}.zip`)
); const missingHashes = Object.entries(codeHashes).filter(([version, hash]) => {
const hasDownload = downloads[`${packageName}:${publisherId}`]?.some(d => d.File?.name === `${hash}.zip`);
return !hasDownload;
});
if (missingHashes.length > 0) { if (missingHashes.length > 0) {
setMetadataError(`Missing local downloads for mirroring versions: ${missingHashes.map(([version]) => version).join(', ')}`); setMetadataError(`Missing local downloads for mirroring versions: ${missingHashes.map(([version]) => version).join(', ')}`);
@ -163,12 +188,6 @@ export default function PublishPage() {
gas: BigInt(1000000), gas: BigInt(1000000),
}); });
// Reset form fields
setPackageName("");
setPublisherId(window.our?.node || "");
setMetadataUrl("");
setMetadataHash("");
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -223,22 +242,31 @@ export default function PublishPage() {
return ( return (
<div className="publish-page"> <div className="publish-page">
<h1>Publish Package</h1> <h1>Publish Package</h1>
{Boolean(address) && ( {!address ? (
<div className="publisher-info"> <div className="wallet-status">
<span>Publishing as:</span> <button onClick={() => openConnectModal?.()}>Connect Wallet</button>
<span className="address">{address?.slice(0, 4)}...{address?.slice(-4)}</span> </div>
) : (
<div className="wallet-status">
Connected: {address.slice(0, 6)}...{address.slice(-4)}
<Tooltip content="Make sure the wallet you're connecting to publish is the same as the owner for the publisher!" />
</div> </div>
)} )}
{isConfirming ? ( {isConfirming ? (
<div className="message info">Publishing package...</div> <div className="message info">
<div className="loading-spinner"></div>
<span>Publishing package...</span>
</div>
) : !address || !isConnected ? ( ) : !address || !isConnected ? (
<> <div className="connect-wallet">
<h4>Please connect your wallet to publish a package</h4> <h4>Please connect your wallet to publish a package</h4>
<ConnectButton /> <ConnectButton />
</> </div>
) : isConnecting ? ( ) : isConnecting ? (
<div className="message info">Approve connection in your wallet</div> <div className="message info">
<div className="loading-spinner"></div>
<span>Approve connection in your wallet</span>
</div>
) : ( ) : (
<form className="publish-form" onSubmit={publishPackage}> <form className="publish-form" onSubmit={publishPackage}>
<div className="form-group"> <div className="form-group">
@ -248,33 +276,36 @@ export default function PublishPage() {
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="metadata-url">Metadata URL</label> <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<label>Metadata URL</label>
<Tooltip content={<>add a link to metadata.json here (<a href="https://raw.githubusercontent.com/kinode-dao/kit/47cdf82f70b36f2a102ddfaaeed5efa10d7ef5b9/src/new/templates/rust/ui/chat/metadata.json" target="_blank" rel="noopener noreferrer">example link</a>)</>} />
</div>
<input <input
id="metadata-url"
type="text" type="text"
required
value={metadataUrl} value={metadataUrl}
onChange={(e) => setMetadataUrl(e.target.value)} onChange={(e) => setMetadataUrl(e.target.value)}
onBlur={calculateMetadataHash} onBlur={calculateMetadataHash}
placeholder="https://github/my-org/my-repo/metadata.json"
/> />
<p className="help-text">
Metadata is a JSON file that describes your package.
</p>
{metadataError && <p className="error-message">{metadataError}</p>} {metadataError && <p className="error-message">{metadataError}</p>}
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="metadata-hash">Metadata Hash</label> <label>Metadata Hash</label>
<input <input
readOnly readOnly
id="metadata-hash"
type="text" type="text"
value={metadataHash} value={metadataHash}
placeholder="Calculated automatically from metadata URL" placeholder="Calculated automatically from metadata URL"
/> />
</div> </div>
<button type="submit" disabled={isConfirming || nameValidity !== null}> <button type="submit" disabled={isConfirming || nameValidity !== null || Boolean(metadataError)}>
{isConfirming ? 'Publishing...' : 'Publish'} {isConfirming ? (
<>
<div className="loading-spinner small"></div>
<span>Publishing...</span>
</>
) : (
'Publish'
)}
</button> </button>
</form> </form>
)} )}
@ -293,21 +324,24 @@ export default function PublishPage() {
<div className="my-packages"> <div className="my-packages">
<h2>Packages You Own</h2> <h2>Packages You Own</h2>
{Object.keys(ourApps).length > 0 ? ( {Object.keys(ourApps).length > 0 ? (
<ul> <ul className="package-list">
{Object.values(ourApps).map((app) => ( {Object.values(ourApps).map((app) => (
<li key={`${app.package_id.package_name}:${app.package_id.publisher_node}`}> <li key={`${app.package_id.package_name}:${app.package_id.publisher_node}`}>
<Link to={`/app/${app.package_id.package_name}:${app.package_id.publisher_node}`} className="app-name"> <Link to={`/app/${app.package_id.package_name}:${app.package_id.publisher_node}`} className="app-name">
{app.metadata?.name || app.package_id.package_name} {app.metadata?.image && (
<img src={app.metadata.image} alt="" className="package-icon" />
)}
<span>{app.metadata?.name || app.package_id.package_name}</span>
</Link> </Link>
<button onClick={() => unpublishPackage(app.package_id.package_name, app.package_id.publisher_node)}> <button onClick={() => unpublishPackage(app.package_id.package_name, app.package_id.publisher_node)} className="danger">
Unpublish Unpublish
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
) : ( ) : (
<p>No packages published</p> <p className="no-packages">No packages published</p>
)} )}
</div> </div>
</div> </div>

View File

@ -2,13 +2,15 @@ import React, { useState, useEffect } from "react";
import useAppsStore from "../store"; import useAppsStore from "../store";
import { AppListing } from "../types/Apps"; import { AppListing } from "../types/Apps";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { FaSearch } from "react-icons/fa";
export default function StorePage() { export default function StorePage() {
const { listings, fetchListings } = useAppsStore(); const { listings, fetchListings, fetchUpdates } = useAppsStore();
const [searchQuery, setSearchQuery] = useState<string>(""); const [searchQuery, setSearchQuery] = useState<string>("");
useEffect(() => { useEffect(() => {
fetchListings(); fetchListings();
fetchUpdates();
}, [fetchListings]); }, [fetchListings]);
// extensive temp null handling due to weird prod bug // extensive temp null handling due to weird prod bug
@ -25,12 +27,15 @@ export default function StorePage() {
return ( return (
<div className="store-page"> <div className="store-page">
<div className="store-header"> <div className="store-header">
<input <div className="search-bar">
type="text" <input
placeholder="Search apps..." type="text"
value={searchQuery} placeholder="Search apps..."
onChange={(e) => setSearchQuery(e.target.value)} value={searchQuery}
/> onChange={(e) => setSearchQuery(e.target.value)}
/>
<FaSearch />
</div>
</div> </div>
<div className="app-list"> <div className="app-list">
{!listings ? ( {!listings ? (

View File

@ -1,6 +1,6 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import { PackageState, AppListing, MirrorCheckFile, DownloadItem, HomepageApp, ManifestResponse, Notification } from '../types/Apps' import { PackageState, AppListing, MirrorCheckFile, DownloadItem, HomepageApp, ManifestResponse, Notification, UpdateInfo } from '../types/Apps'
import { HTTP_STATUS } from '../constants/http' import { HTTP_STATUS } from '../constants/http'
import KinodeClientApi from "@kinode/client-api" import KinodeClientApi from "@kinode/client-api"
import { WEBSOCKET_URL } from '../utils/ws' import { WEBSOCKET_URL } from '../utils/ws'
@ -16,6 +16,7 @@ interface AppsStore {
notifications: Notification[] notifications: Notification[]
homepageApps: HomepageApp[] homepageApps: HomepageApp[]
activeDownloads: Record<string, { downloaded: number, total: number }> activeDownloads: Record<string, { downloaded: number, total: number }>
updates: Record<string, UpdateInfo>
fetchData: (id: string) => Promise<void> fetchData: (id: string) => Promise<void>
fetchListings: () => Promise<void> fetchListings: () => Promise<void>
@ -48,6 +49,8 @@ interface AppsStore {
clearActiveDownload: (appId: string) => void clearActiveDownload: (appId: string) => void
clearAllActiveDownloads: () => void; clearAllActiveDownloads: () => void;
fetchUpdates: () => Promise<void>
clearUpdates: (packageId: string) => Promise<void>
} }
const useAppsStore = create<AppsStore>()((set, get) => ({ const useAppsStore = create<AppsStore>()((set, get) => ({
@ -58,7 +61,7 @@ const useAppsStore = create<AppsStore>()((set, get) => ({
activeDownloads: {}, activeDownloads: {},
homepageApps: [], homepageApps: [],
notifications: [], notifications: [],
updates: {},
fetchData: async (id: string) => { fetchData: async (id: string) => {
if (!id) return; if (!id) return;
@ -380,6 +383,33 @@ const useAppsStore = create<AppsStore>()((set, get) => ({
}); });
}, },
fetchUpdates: async () => {
try {
const res = await fetch(`${BASE_URL}/updates`);
if (res.status === HTTP_STATUS.OK) {
const updates = await res.json();
set({ updates });
}
} catch (error) {
console.error("Error fetching updates:", error);
}
},
clearUpdates: async (packageId: string) => {
try {
await fetch(`${BASE_URL}/updates/${packageId}/clear`, {
method: 'POST',
});
set((state) => {
const newUpdates = { ...state.updates };
delete newUpdates[packageId];
return { updates: newUpdates };
});
} catch (error) {
console.error("Error clearing updates:", error);
}
},
ws: new KinodeClientApi({ ws: new KinodeClientApi({
uri: WEBSOCKET_URL, uri: WEBSOCKET_URL,
nodeId: (window as any).our?.node, nodeId: (window as any).our?.node,
@ -419,10 +449,26 @@ const useAppsStore = create<AppsStore>()((set, get) => ({
get().removeNotification(`download-${appId}`); get().removeNotification(`download-${appId}`);
if (error) { if (error) {
const formatDownloadError = (error: any): string => {
if (typeof error === 'object' && error !== null) {
if ('HashMismatch' in error) {
const { actual, desired } = error.HashMismatch;
return `Hash mismatch: expected ${desired.slice(0, 8)}..., got ${actual.slice(0, 8)}...`;
}
// Try to serialize the error object if it's not a HashMismatch
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
return String(error);
};
get().addNotification({ get().addNotification({
id: `error-${appId}`, id: `error-${appId}`,
type: 'error', type: 'error',
message: `Download failed for ${package_id.package_name}: ${error}`, message: `Download failed for ${package_id.package_name}: ${formatDownloadError(error)}`,
timestamp: Date.now(), timestamp: Date.now(),
}); });
} else { } else {

View File

@ -94,6 +94,35 @@ export interface HomepageApp {
favorite: boolean; favorite: boolean;
} }
export interface HashMismatch {
desired: string;
actual: string;
}
export type DownloadError =
| "NoPackage"
| "NotMirroring"
| { HashMismatch: HashMismatch }
| "FileNotFound"
| "WorkerSpawnFailed"
| "HttpClientError"
| "BlobNotFound"
| "VfsError"
| { HandlingError: string }
| "Timeout"
| "InvalidManifest"
| "Offline";
export interface UpdateInfo {
errors: [string, DownloadError][]; // [url/node, error]
pending_manifest_hash: string | null;
}
export type Updates = {
[key: string]: { // package_id
[key: string]: UpdateInfo; // version_hash -> update info
};
};
export type NotificationActionType = 'click' | 'modal' | 'popup' | 'redirect'; export type NotificationActionType = 'click' | 'modal' | 'popup' | 'redirect';