Merge branch 'develop' into v0.10.0

Conflicts:
      Cargo.lock
      Cargo.toml
      kinode/Cargo.toml
      kinode/packages/app-store/ui/src/components/ManifestDisplay.tsx
      kinode/packages/app-store/ui/src/components/NotificationBay.tsx
      kinode/packages/app-store/ui/src/pages/MyAppsPage.tsx
      kinode/packages/app-store/ui/src/pages/MyDownloadsPage.tsx
      kinode/packages/app_store/ui/src/pages/MyDownloadsPage.tsx
      kinode/src/vfs.rs
      lib/Cargo.toml
This commit is contained in:
hosted-fornet 2024-11-13 11:47:18 -08:00
commit bdf7fb171b
31 changed files with 1772 additions and 224 deletions

489
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
use crate::{
kinode::process::chain::{ChainRequests, ChainResponses},
kinode::process::downloads::{
DownloadRequests, DownloadResponses, LocalDownloadRequest, RemoveFileRequest,
DownloadRequests, DownloadResponses, Entry, LocalDownloadRequest, RemoveFileRequest,
},
state::{MirrorCheck, PackageState, State},
};
@ -31,6 +31,7 @@ pub fn init_frontend(our: &Address, http_server: &mut server::HttpServer) {
"/apps/:id", // detail about an on-chain app
"/downloads/:id", // local downloads for an app
"/installed/:id", // detail about an installed app
"/manifest", // manifest of a downloaded app, id & version hash in query params
// actions
"/apps/:id/download", // download a listed app
"/apps/:id/install", // install a downloaded app
@ -190,6 +191,7 @@ fn make_widget() -> String {
/// - get all apps we've published: GET /ourapps
/// - get detail about a specific app: GET /apps/:id
/// - get detail about a specific apps downloads: GET /downloads/:id
/// - get manifest of a specific downloaded app: GET /manifest?id={id}&version_hash={version_hash}
/// - remove a downloaded app: POST /downloads/:id/remove
/// - get online/offline mirrors for a listed app: GET /mirrorcheck/:node
@ -225,8 +227,8 @@ pub fn handle_http_request(
}
}
fn get_package_id(url_params: &HashMap<String, String>) -> anyhow::Result<PackageId> {
let Some(package_id) = url_params.get("id") else {
fn get_package_id(params: &HashMap<String, String>) -> anyhow::Result<PackageId> {
let Some(package_id) = params.get("id") else {
return Err(anyhow::anyhow!("Missing id"));
};
@ -246,6 +248,7 @@ fn gen_package_info(id: &PackageId, state: &PackageState) -> serde_json::Value {
"our_version_hash": state.our_version_hash,
"verified": state.verified,
"caps_approved": state.caps_approved,
"pending_update_hash": state.pending_update_hash,
})
}
@ -258,6 +261,7 @@ fn serve_paths(
let bound_path: &str = req.bound_path(Some(&our.process.to_string()));
let url_params = req.url_params();
let query_params = req.query_params();
match bound_path {
// GET all apps
@ -362,6 +366,73 @@ fn serve_paths(
)),
}
}
"/manifest" => {
// get manifest of a downloaded app, version hash and id in query params
let Ok(package_id) = get_package_id(query_params) else {
return Ok((
StatusCode::BAD_REQUEST,
None,
format!("Missing id in query params.").into_bytes(),
));
};
let Some(version_hash) = query_params.get("version_hash") else {
return Ok((
StatusCode::BAD_REQUEST,
None,
format!("Missing version_hash in query params.").into_bytes(),
));
};
let package_id = crate::kinode::process::main::PackageId::from_process_lib(package_id);
// get the file corresponding to the version hash, extract manifest and return.
let resp = Request::to(("our", "downloads", "app_store", "sys"))
.body(serde_json::to_vec(&DownloadRequests::GetFiles(Some(
package_id.clone(),
)))?)
.send_and_await_response(5)??;
let msg = serde_json::from_slice::<DownloadResponses>(resp.body())?;
match msg {
DownloadResponses::GetFiles(files) => {
let file_name = format!("{version_hash}.zip");
let file_entry = files.into_iter().find(|entry| match entry {
Entry::File(file) => file.name == file_name,
_ => false,
});
match file_entry {
Some(Entry::File(file)) => {
let response = serde_json::json!({
"package_id": package_id,
"version_hash": version_hash,
"manifest": file.manifest,
});
return Ok((StatusCode::OK, None, serde_json::to_vec(&response)?));
}
_ => {
return Ok((
StatusCode::NOT_FOUND,
None,
format!("File with version hash {} not found", version_hash)
.into_bytes(),
));
}
}
}
DownloadResponses::Err(e) => Ok((
StatusCode::NOT_FOUND,
None,
format!("Error from downloads: {:?}", e).into_bytes(),
)),
_ => Ok((
StatusCode::INTERNAL_SERVER_ERROR,
None,
format!("Invalid response from downloads: {:?}", msg).into_bytes(),
)),
}
}
"/installed" => {
let all: Vec<serde_json::Value> = state
.packages

View File

@ -178,21 +178,30 @@ fn handle_message(
let package_id = req.download_info.package_id;
let version_hash = req.download_info.version_hash;
if let Some(package) = state.packages.get(&package_id.clone().to_process_lib()) {
if package.manifest_hash == Some(manifest_hash) {
print_to_terminal(1, "auto_install:main, manifest_hash match");
if let Err(e) =
utils::install(&package_id, None, &version_hash, state, &our.node)
{
print_to_terminal(1, &format!("error auto_installing package: {e}"));
} else {
println!(
"auto_installed update for package: {:?}",
&package_id.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
let should_auto_install = state
.packages
.get(&process_lib_package_id)
.map(|package| package.manifest_hash == Some(manifest_hash.clone()))
.unwrap_or(false);
if should_auto_install {
if let Err(e) =
utils::install(&package_id, None, &version_hash, state, &our.node)
{
if let Some(package) = state.packages.get_mut(&process_lib_package_id) {
package.pending_update_hash = Some(version_hash);
}
println!("error auto-installing package: {e}");
} else {
print_to_terminal(1, "auto_install:main, manifest_hash do not match");
println!("auto-installed update for package: {process_lib_package_id}");
}
} else {
if let Some(package) = state.packages.get_mut(&process_lib_package_id) {
package.pending_update_hash = Some(version_hash);
println!("error auto-installing package: manifest hash mismatch");
}
}
}

View File

@ -54,8 +54,19 @@ pub struct PackageState {
/// capabilities have changed. if they have changed, auto-install must fail
/// and the user must approve the new capabilities.
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
// the state to disk right now, with installed_apis and packages being populated directly
// from the filesystem, not sure I'd like to serialize the whole of this state (maybe separate out the pending one?)
// another option would be to have the download_api recheck the manifest hash? but not sure...
// arbitrary complexity here.
// alternative is main loop doing this, storing it.
/// this process's saved state
pub struct State {
/// packages we have installed
@ -122,6 +133,7 @@ impl State {
verified: true, // implicitly verified (TODO re-evaluate)
caps_approved: false, // must re-approve if you want to do something ??
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.
},
);

View File

@ -225,6 +225,7 @@ pub fn install(
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
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) {

View File

@ -59,7 +59,7 @@ const CHAIN_TIMEOUT: u64 = 60; // 60s
#[cfg(not(feature = "simulation-mode"))]
const KIMAP_ADDRESS: &'static str = kimap::KIMAP_ADDRESS; // optimism
#[cfg(feature = "simulation-mode")]
const KIMAP_ADDRESS: &str = "0xEce71a05B36CA55B895427cD9a440eEF7Cf3669D";
const KIMAP_ADDRESS: &str = "0x9CE8cCD2932DC727c70f9ae4f8C2b68E6Abed58C";
const DELAY_MS: u64 = 1_000; // 1s

View File

@ -209,19 +209,32 @@ fn handle_message(
desired_version_hash,
worker_address: our_worker.to_string(),
}))
.expects_response(60)
.context(&download_request)
.send()?;
}
DownloadRequests::RemoteDownload(download_request) => {
// this is a node requesting a download from us.
// check if we are mirroring. we should maybe implement some back and forth here.
// small handshake for started? but we do not really want to wait for that in this loop..
// might be okay. implement.
let RemoteDownloadRequest {
package_id,
desired_version_hash,
worker_address,
} = download_request;
let process_lib_package_id = package_id.clone().to_process_lib();
// check if we are mirroring, if not send back an error.
if !state.mirroring.contains(&process_lib_package_id) {
let resp = DownloadResponses::Err(DownloadError::NotMirroring);
Response::new().body(&resp).send()?;
return Ok(()); // return here, todo unify remote and local responses?
}
if !download_zip_exists(&process_lib_package_id, &desired_version_hash) {
let resp = DownloadResponses::Err(DownloadError::FileNotFound);
Response::new().body(&resp).send()?;
return Ok(()); // return here, todo unify remote and local responses?
}
let target_worker = Address::from_str(&worker_address)?;
let _ = spawn_send_transfer(
our,
@ -230,6 +243,8 @@ fn handle_message(
APP_SHARE_TIMEOUT,
&target_worker,
)?;
let resp = DownloadResponses::Success;
Response::new().body(&resp).send()?;
}
DownloadRequests::Progress(ref progress) => {
// forward progress to main:app-store:sys,
@ -428,11 +443,34 @@ fn handle_message(
} else {
match message.body().try_into()? {
Resp::Download(download_response) => {
// these are handled in line.
print_to_terminal(
1,
&format!("got a weird download response: {:?}", download_response),
);
// get context of the response.
// handled are errors or ok responses from a remote node.
if let Some(context) = message.context() {
let download_request = serde_json::from_slice::<LocalDownloadRequest>(context)?;
match download_response {
DownloadResponses::Err(e) => {
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(e),
})
.send()?;
}
DownloadResponses::Success => {
// todo: maybe we do something here.
print_to_terminal(
1,
&format!(
"downloads: got success response from remote node: {:?}",
download_request
),
);
}
_ => {}
}
}
}
Resp::HttpClient(resp) => {
let Some(context) = message.context() else {
@ -575,6 +613,22 @@ fn extract_and_write_manifest(file_contents: &[u8], manifest_path: &str) -> anyh
Ok(())
}
/// Check if a download zip exists for a given package and version hash.
/// Used to check if we can share a package or not!
fn download_zip_exists(package_id: &PackageId, version_hash: &str) -> bool {
let filename = format!(
"/app_store:sys/downloads/{}:{}/{}.zip",
package_id.package_name,
package_id.publisher(),
version_hash
);
let res = vfs::metadata(&filename, None);
match res {
Ok(meta) => meta.file_type == vfs::FileType::File,
Err(_e) => false,
}
}
fn get_manifest_hash(package_id: PackageId, version_hash: String) -> anyhow::Result<String> {
let package_dir = format!("{}/{}", "/app-store:sys/downloads", package_id.to_string());
let manifest_path = format!("{}/{}.json", package_dir, version_hash);

View File

@ -78,6 +78,7 @@ fn init(our: Address) {
}
// killswitch timer, 2 minutes. sender or receiver gets killed/cleaned up.
// TODO: killswitch update bubbles up to downloads process?
timer::set_timer(120000, None);
let start = std::time::Instant::now();
@ -167,7 +168,11 @@ fn handle_receiver(
package_id: &PackageId,
version_hash: &str,
) -> anyhow::Result<()> {
// TODO: write to a temporary location first, then check hash as we go, then rename to final location.
let timer_address = Address::from_str("our@timer:distro:sys")?;
let mut file: Option<File> = None;
let mut size: Option<u64> = None;
let mut hasher = Sha256::new();
let package_dir = vfs::open_dir(
&format!(
@ -179,16 +184,6 @@ fn handle_receiver(
None,
)?;
let timer_address = Address::from_str("our@timer:distro:sys")?;
let mut file = vfs::open_file(
&format!("{}{}.zip", &package_dir.path, version_hash),
true,
None,
)?;
let mut size: Option<u64> = None;
let mut hasher = Sha256::new();
loop {
let message = await_message()?;
if *message.source() == timer_address {
@ -200,7 +195,28 @@ fn handle_receiver(
match message.body().try_into()? {
DownloadRequests::Chunk(chunk) => {
handle_chunk(&mut file, &chunk, parent_process, &mut size, &mut hasher)?;
let bytes = if let Some(blob) = get_blob() {
blob.bytes
} else {
return Err(anyhow::anyhow!("ft_worker: got no blob in chunk request"));
};
if file.is_none() {
file = Some(vfs::open_file(
&format!("{}{}.zip", &package_dir.path, version_hash),
true,
None,
)?);
}
handle_chunk(
file.as_mut().unwrap(),
&chunk,
parent_process,
&mut size,
&mut hasher,
&bytes,
)?;
if let Some(s) = size {
if chunk.offset + chunk.length >= s {
let recieved_hash = format!("{:x}", hasher.finalize());
@ -232,7 +248,7 @@ fn handle_receiver(
let manifest_filename =
format!("{}{}.json", package_dir.path, version_hash);
let contents = file.read()?;
let contents = file.as_mut().unwrap().read()?;
extract_and_write_manifest(&contents, &manifest_filename)?;
Request::new()
@ -292,15 +308,10 @@ fn handle_chunk(
parent: &Address,
size: &mut Option<u64>,
hasher: &mut Sha256,
bytes: &[u8],
) -> anyhow::Result<()> {
let bytes = if let Some(blob) = get_blob() {
blob.bytes
} else {
return Err(anyhow::anyhow!("ft_worker: got no blob"));
};
file.write_all(&bytes)?;
hasher.update(&bytes);
file.write_all(bytes)?;
hasher.update(bytes);
if let Some(total_size) = size {
// let progress = ((chunk.offset + chunk.length) as f64 / *total_size as f64 * 100.0) as u64;

View File

@ -2,13 +2,13 @@ import React from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import Header from "./components/Header";
import { APP_DETAILS_PATH, DOWNLOAD_PATH, MY_DOWNLOADS_PATH, PUBLISH_PATH, STORE_PATH } from "./constants/path";
import { APP_DETAILS_PATH, DOWNLOAD_PATH, MY_APPS_PATH, PUBLISH_PATH, STORE_PATH } from "./constants/path";
import StorePage from "./pages/StorePage";
import AppPage from "./pages/AppPage";
import DownloadPage from "./pages/DownloadPage";
import PublishPage from "./pages/PublishPage";
import MyDownloadsPage from "./pages/MyDownloadsPage";
import MyAppsPage from "./pages/MyAppsPage";
const BASE_URL = import.meta.env.BASE_URL;
@ -22,7 +22,7 @@ function App() {
<Header />
<Routes>
<Route path={STORE_PATH} element={<StorePage />} />
<Route path={MY_DOWNLOADS_PATH} element={<MyDownloadsPage />} />
<Route path={MY_APPS_PATH} element={<MyAppsPage />} />
<Route path={`${APP_DETAILS_PATH}/:id`} element={<AppPage />} />
<Route path={PUBLISH_PATH} element={<PublishPage />} />
<Route path={`${DOWNLOAD_PATH}/:id`} element={<DownloadPage />} />

View File

@ -1,27 +1,28 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { STORE_PATH, PUBLISH_PATH, MY_DOWNLOADS_PATH } from '../constants/path';
import { STORE_PATH, PUBLISH_PATH, MY_APPS_PATH } from '../constants/path';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { FaHome } from "react-icons/fa";
import NotificationBay from './NotificationBay';
const Header: React.FC = () => {
return (
<header className="app-header">
<div className="header-left">
<nav>
<button onClick={() => window.location.href = '/'}>
<button onClick={() => window.location.href = window.location.origin.replace('//app-store-sys.', '//') + '/'} className="home-button">
<FaHome />
</button>
<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={MY_DOWNLOADS_PATH} className={location.pathname === MY_DOWNLOADS_PATH ? 'active' : ''}>My Downloads</Link>
<Link to={MY_APPS_PATH} className={location.pathname === MY_APPS_PATH ? 'active' : ''}>My Apps</Link>
</nav>
</div>
<div className="header-right">
<NotificationBay />
<ConnectButton />
</div>
</header>
);
};
export default Header;

View File

@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { ManifestResponse, PackageManifestEntry } from '../types/Apps';
import { FaChevronDown, FaChevronRight, FaGlobe, FaLock, FaShieldAlt } from 'react-icons/fa';
interface ManifestDisplayProps {
manifestResponse: ManifestResponse;
}
const capabilityMap: Record<string, string> = {
'vfs:distro:sys': 'Virtual Filesystem',
'http_client:distro:sys': 'HTTP Client',
'http_server:distro:sys': 'HTTP Server',
'eth:distro:sys': 'Ethereum RPC access',
'homepage:homepage:sys': 'Ability to add itself to homepage',
'main:app_store:sys': 'App Store',
'chain:app_store:sys': 'Chain',
'terminal:terminal:sys': 'Terminal',
};
// note: we can do some future regex magic mapping here too!
// if includes("root") return WARNING
const transformCapabilities = (capabilities: any[]) => {
return capabilities.map(cap => capabilityMap[cap] || cap);
};
const ProcessManifest: React.FC<{ manifest: PackageManifestEntry }> = ({ manifest }) => {
const [isExpanded, setIsExpanded] = useState(false);
const hasCapabilities = manifest.request_capabilities.length > 0 || manifest.grant_capabilities.length > 0;
return (
<div className="process-manifest">
<button
className="process-header"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
<span className="process-name">{manifest.process_name}</span>
<div className="process-indicators">
{manifest.request_networking && (
<FaGlobe title="Requests Network Access" className="network-icon" />
)}
{hasCapabilities && (
<FaShieldAlt title="Has Capability Requirements" className="capability-icon" />
)}
{!manifest.public && (
<FaLock title="Private Process" className="private-icon" />
)}
</div>
</button>
{isExpanded && (
<div className="process-details">
{manifest.request_capabilities.length > 0 && (
<div className="capability-section">
<h4>Requested Capabilities:</h4>
<ul>
{transformCapabilities(manifest.request_capabilities).map((cap, i) => (
<li key={i}>{cap}</li>
))}
</ul>
</div>
)}
{manifest.grant_capabilities.length > 0 && (
<div className="capability-section">
<h4>Granted Capabilities:</h4>
<ul>
{transformCapabilities(manifest.grant_capabilities).map((cap, i) => (
<li key={i}>{cap}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
};
const ManifestDisplay: React.FC<ManifestDisplayProps> = ({ manifestResponse }) => {
if (!manifestResponse) {
return <p>No manifest data available.</p>;
}
const parsedManifests: PackageManifestEntry[] = JSON.parse(manifestResponse.manifest);
return (
<div className="manifest-display">
{parsedManifests.map((manifest, index) => (
<ProcessManifest key={index} manifest={manifest} />
))}
</div>
);
};
export default ManifestDisplay;

View File

@ -0,0 +1,140 @@
import React, { ReactNode, useState } from 'react';
import { FaBell, FaChevronDown, FaChevronUp, FaTrash, FaTimes } from 'react-icons/fa';
import useAppsStore from '../store';
import { Notification, NotificationAction } from '../types/Apps';
import { useNavigate } from 'react-router-dom';
interface ModalProps {
children: ReactNode;
onClose: () => void;
}
const Modal: React.FC<ModalProps> = ({ children, onClose }) => {
return (
<div className="modal-overlay">
<div className="modal-content">
<button className="modal-close" onClick={onClose}>
<FaTimes />
</button>
{children}
</div>
</div>
);
};
const NotificationBay: React.FC = () => {
const { notifications, removeNotification } = useAppsStore();
const hasErrors = notifications.some(n => n.type === 'error');
const [isExpanded, setIsExpanded] = useState(false);
const [modalContent, setModalContent] = useState<React.ReactNode | null>(null);
const navigate = useNavigate();
const handleActionClick = (action: NotificationAction) => {
switch (action.action.type) {
case 'modal':
const content = typeof action.action.modalContent === 'function'
? action.action.modalContent()
: action.action.modalContent;
setModalContent(content);
break;
case 'click':
action.action.onClick?.();
break;
case 'redirect':
if (action.action.path) {
navigate(action.action.path);
}
break;
}
};
const handleDismiss = (notificationId: string, event: React.MouseEvent) => {
event.stopPropagation(); // Prevent event bubbling
removeNotification(notificationId);
};
const renderNotification = (notification: Notification) => {
return (
<div key={notification.id} className={`notification-item ${notification.type}`}>
{notification.renderContent ? (
notification.renderContent(notification)
) : (
<>
<div className="notification-content">
<p>{notification.message}</p>
{notification.type === 'download' && notification.metadata?.progress && (
<div className="progress-bar">
<div
className="progress"
style={{ width: `${notification.metadata.progress}%` }}
/>
</div>
)}
</div>
{notification.actions && (
<div className="notification-actions">
{notification.actions.map((action, index) => (
<button
key={index}
onClick={() => handleActionClick(action)}
className={`action-button ${action.variant || 'secondary'}`}
>
{action.icon && <action.icon />}
{action.label}
</button>
))}
</div>
)}
{!notification.persistent && (
<button
className="dismiss-button"
onClick={(e) => handleDismiss(notification.id, e)}
>
<FaTrash />
</button>
)}
</>
)}
</div>
);
};
return (
<>
<div className="notification-bay">
<button
onClick={() => setIsExpanded(!isExpanded)}
className={`notification-button ${hasErrors ? 'has-errors' : ''}`}
>
<FaBell />
{notifications.length > 0 && (
<span className={`badge ${hasErrors ? 'error-badge' : ''}`}>
{notifications.length}
</span>
)}
</button>
{isExpanded && (
<div className="notification-details">
{notifications.length === 0 ? (
<p>All clear, no notifications!</p>
) : (
notifications.map(renderNotification)
)}
</div>
)}
</div>
{modalContent && (
<Modal onClose={() => setModalContent(null)}>
{modalContent}
</Modal>
)}
</>
);
};
export default NotificationBay;

View File

@ -1,3 +1,5 @@
export { default as Header } from './Header';
export { default as MirrorSelector } from './MirrorSelector';
export { default as PackageSelector } from './PackageSelector';
export { default as PackageSelector } from './PackageSelector';
export { default as ManifestDisplay } from './ManifestDisplay';
export { default as NotificationBay } from './NotificationBay';

View File

@ -2,4 +2,4 @@ export const STORE_PATH = '/';
export const PUBLISH_PATH = '/publish';
export const APP_DETAILS_PATH = '/app';
export const DOWNLOAD_PATH = '/download';
export const MY_DOWNLOADS_PATH = '/my-downloads';
export const MY_APPS_PATH = '/my-apps';

View File

@ -49,6 +49,13 @@ a:hover {
gap: 1rem;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
/* Provides consistent spacing between NotificationBay and ConnectButton */
}
.header-left h1 {
margin: 0;
font-size: 1.5rem;
@ -136,6 +143,8 @@ td {
.app-icon {
width: 64px;
height: 64px;
min-width: 64px;
min-height: 64px;
object-fit: cover;
border-radius: var(--border-radius);
}
@ -348,6 +357,13 @@ td {
padding-bottom: 1rem;
}
.home-button {
min-width: 48px;
min-height: 48px;
width: 48px;
height: 48px;
}
.app-screenshot {
max-width: 200px;
height: auto;
@ -423,4 +439,278 @@ td {
.fa-spin {
animation: spin 1s linear infinite;
}
.manifest-display {
background: light-dark(var(--white), var(--tasteful-dark));
border-radius: var(--border-radius);
padding: 1rem;
max-width: 600px;
}
.process-manifest {
margin-bottom: 0.5rem;
border: 1px solid light-dark(var(--gray), var(--off-black));
border-radius: var(--border-radius);
overflow: hidden;
}
.process-header {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: none;
border: none;
cursor: pointer;
color: light-dark(var(--off-black), var(--off-white));
transition: background-color 0.2s;
}
.process-header:hover {
background: light-dark(var(--tan), var(--off-black));
}
.process-name {
flex: 1;
text-align: left;
font-weight: 500;
}
.process-indicators {
display: flex;
gap: 0.5rem;
color: light-dark(var(--gray), var(--off-white));
}
.network-icon {
color: var(--orange);
}
.capability-icon {
color: var(--blue);
}
.private-icon {
color: var(--gray);
}
.process-details {
padding: 1rem;
background: light-dark(var(--tan), var(--off-black));
border-top: 1px solid light-dark(var(--gray), var(--off-black));
}
.capability-section {
margin-bottom: 1rem;
}
.capability-section:last-child {
margin-bottom: 0;
}
.capability-section h4 {
margin: 0 0 0.5rem 0;
color: light-dark(var(--off-black), var(--off-white));
}
.capability-section ul {
margin: 0;
padding-left: 1.5rem;
color: light-dark(var(--gray), var(--off-white));
}
.capability-section li {
margin-bottom: 0.25rem;
}
.notification-bay {
position: relative;
margin-right: 1rem;
}
.notification-button {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
color: light-dark(var(--off-black), var(--off-white));
}
.notification-details {
position: absolute;
top: 100%;
right: 0;
width: 320px;
max-height: 400px;
overflow-y: auto;
background-color: light-dark(var(--white), var(--tasteful-dark));
border-radius: var(--border-radius);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
padding: 0.5rem;
}
.badge {
background-color: var(--orange);
color: var(--white);
border-radius: 50%;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
min-width: 1.5rem;
text-align: center;
}
.notification-item {
display: flex;
align-items: center;
padding: 1rem;
margin: 0.5rem 0;
border-radius: var(--border-radius);
background-color: light-dark(var(--tan), var(--off-black));
color: light-dark(var(--off-black), var(--off-white));
}
.notification-item.error {
background-color: light-dark(#ffe6e6, #4a2020);
}
.notification-item.success {
background-color: light-dark(#e6ffe6, #204a20);
}
.notification-item.warning {
background-color: light-dark(#fff3e6, #4a3820);
}
.notification-item.download {
background-color: light-dark(#e6f3ff, #20304a);
}
.notification-content {
flex: 1;
margin-right: 1rem;
}
.notification-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.dismiss-button {
background: none;
border: none;
cursor: pointer;
color: light-dark(var(--gray), var(--off-white));
padding: 0.25rem;
}
.dismiss-button:hover {
color: var(--orange);
}
.progress-bar {
margin-top: 0.5rem;
height: 4px;
background-color: light-dark(var(--white), var(--off-black));
border-radius: 2px;
overflow: hidden;
}
.progress {
height: 100%;
background-color: var(--orange);
transition: width 0.3s ease;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
}
.modal-content {
background-color: light-dark(var(--white), var(--tasteful-dark));
color: light-dark(var(--off-black), var(--off-white));
padding: 1.5rem;
border-radius: var(--border-radius);
position: relative;
max-width: 80%;
max-height: 80vh;
overflow-y: auto;
}
.modal-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: none;
border: none;
cursor: pointer;
color: light-dark(var(--gray), var(--off-white));
padding: 0.25rem;
}
.modal-close:hover {
color: var(--orange);
}
.notification-button.has-errors {
animation: shake 0.82s cubic-bezier(.36, .07, .19, .97) both;
}
.badge.error-badge {
background-color: var(--error-red);
animation: pulse 2s infinite;
}
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}

View File

@ -62,7 +62,7 @@ export default function AppPage() {
if (app) {
const launchUrl = getLaunchUrl(`${app.package_id.package_name}:${app.package_id.publisher_node}`);
if (launchUrl) {
window.location.href = launchUrl;
window.location.href = window.location.origin.replace('//app-store-sys.', '//') + launchUrl;
}
}
}, [app, getLaunchUrl]);

View File

@ -2,7 +2,8 @@ import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useParams } from "react-router-dom";
import { FaDownload, FaSpinner, FaChevronDown, FaChevronUp, FaRocket, FaTrash, FaPlay } from "react-icons/fa";
import useAppsStore from "../store";
import { MirrorSelector } from '../components';
import { MirrorSelector, ManifestDisplay } from '../components';
import { ManifestResponse } from "../types/Apps";
export default function DownloadPage() {
const { id } = useParams<{ id: string }>();
@ -17,7 +18,7 @@ export default function DownloadPage() {
removeDownload,
clearAllActiveDownloads,
fetchHomepageApps,
getLaunchUrl
getLaunchUrl,
} = useAppsStore();
const [showMetadata, setShowMetadata] = useState(false);
@ -26,7 +27,7 @@ export default function DownloadPage() {
const [showMyDownloads, setShowMyDownloads] = useState(false);
const [isMirrorOnline, setIsMirrorOnline] = useState<boolean | null>(null);
const [showCapApproval, setShowCapApproval] = useState(false);
const [manifest, setManifest] = useState<any>(null);
const [manifestResponse, setManifestResponse] = useState<ManifestResponse | null>(null);
const [isInstalling, setIsInstalling] = useState(false);
const [isCheckingLaunch, setIsCheckingLaunch] = useState(false);
const [launchPath, setLaunchPath] = useState<string | null>(null);
@ -151,8 +152,12 @@ export default function DownloadPage() {
const download = appDownloads.find(d => d.File && d.File.name === `${hash}.zip`);
if (download?.File?.manifest) {
try {
const manifestData = JSON.parse(download.File.manifest);
setManifest(manifestData);
const manifest_response: ManifestResponse = {
package_id: app.package_id,
version_hash: hash,
manifest: download.File.manifest
};
setManifestResponse(manifest_response);
setShowCapApproval(true);
} catch (error) {
console.error('Failed to parse manifest:', error);
@ -170,7 +175,7 @@ export default function DownloadPage() {
setLaunchPath(null);
installApp(id, versionData.hash).then(() => {
setShowCapApproval(false);
setManifest(null);
setManifestResponse(null);
fetchData(id);
});
}
@ -178,7 +183,7 @@ export default function DownloadPage() {
const handleLaunch = useCallback(() => {
if (launchPath) {
window.location.href = launchPath;
window.location.href = window.location.origin.replace('//app-store-sys.', '//') + launchPath;
}
}, [launchPath]);
@ -337,13 +342,11 @@ export default function DownloadPage() {
)}
</div>
{showCapApproval && manifest && (
{showCapApproval && manifestResponse && (
<div className="cap-approval-popup">
<div className="cap-approval-content">
<h3>Approve Capabilities</h3>
<pre className="json-display">
{JSON.stringify(manifest[0]?.request_capabilities || [], null, 2)}
</pre>
<ManifestDisplay manifestResponse={manifestResponse} />
<div className="approval-buttons">
<button onClick={() => setShowCapApproval(false)}>Cancel</button>
<button onClick={confirmInstall}>
@ -354,7 +357,6 @@ export default function DownloadPage() {
</div>
)}
<div className="app-details">
<h3>App Details</h3>
<button onClick={() => setShowMetadata(!showMetadata)}>

View File

@ -0,0 +1,341 @@
import React, { useState, useEffect } from "react";
import { FaFolder, FaFile, FaChevronLeft, FaSync, FaRocket, FaSpinner, FaCheck, FaTrash } from "react-icons/fa";
import useAppsStore from "../store";
import { DownloadItem, PackageManifest, PackageState } from "../types/Apps";
// Core packages that cannot be uninstalled
const CORE_PACKAGES = [
"app_store:sys",
"contacts:sys",
"kino_updates:sys",
"terminal:sys",
"chess:sys",
"kns_indexer:sys",
"settings:sys",
"homepage:sys"
];
export default function MyAppsPage() {
const {
fetchDownloads,
fetchDownloadsForApp,
startMirroring,
stopMirroring,
installApp,
removeDownload,
fetchInstalled,
installed,
uninstallApp
} = useAppsStore();
const [currentPath, setCurrentPath] = useState<string[]>([]);
const [items, setItems] = useState<DownloadItem[]>([]);
const [isInstalling, setIsInstalling] = useState(false);
const [isUninstalling, setIsUninstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCapApproval, setShowCapApproval] = useState(false);
const [manifest, setManifest] = useState<PackageManifest | null>(null);
const [selectedItem, setSelectedItem] = useState<DownloadItem | null>(null);
const [showUninstallConfirm, setShowUninstallConfirm] = useState(false);
const [appToUninstall, setAppToUninstall] = useState<any>(null);
useEffect(() => {
loadItems();
fetchInstalled();
}, [currentPath]);
const loadItems = async () => {
try {
let downloads: DownloadItem[];
if (currentPath.length === 0) {
downloads = await fetchDownloads();
} else {
downloads = await fetchDownloadsForApp(currentPath.join(':'));
}
setItems(downloads);
} catch (error) {
console.error("Error loading items:", error);
setError(`Error loading items: ${error instanceof Error ? error.message : String(error)}`);
}
};
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);
}
};
const navigateToItem = (item: DownloadItem) => {
if (item.Dir) {
setCurrentPath([...currentPath, item.Dir.name]);
}
};
const navigateUp = () => {
setCurrentPath(currentPath.slice(0, -1));
};
const toggleMirroring = async (item: DownloadItem) => {
if (item.Dir) {
const packageId = [...currentPath, item.Dir.name].join(':');
try {
if (item.Dir.mirroring) {
await stopMirroring(packageId);
} else {
await startMirroring(packageId);
}
await loadItems();
} catch (error) {
console.error("Error toggling mirroring:", error);
setError(`Error toggling mirroring: ${error instanceof Error ? error.message : String(error)}`);
}
}
};
const handleInstall = async (item: DownloadItem) => {
if (item.File) {
setSelectedItem(item);
try {
const manifestData = JSON.parse(item.File.manifest);
setManifest(manifestData);
setShowCapApproval(true);
} catch (error) {
console.error('Failed to parse manifest:', error);
setError(`Failed to parse manifest: ${error instanceof Error ? error.message : String(error)}`);
}
}
};
const confirmInstall = async () => {
if (!selectedItem?.File) return;
setIsInstalling(true);
setError(null);
try {
const fileName = selectedItem.File.name;
const parts = fileName.split(':');
const versionHash = parts.pop()?.replace('.zip', '');
if (!versionHash) throw new Error('Invalid file name format');
const packageId = [...currentPath, ...parts].join(':');
await installApp(packageId, versionHash);
await fetchInstalled();
setShowCapApproval(false);
await loadItems();
} catch (error) {
console.error('Installation failed:', error);
setError(`Installation failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsInstalling(false);
}
};
const handleRemoveDownload = async (item: DownloadItem) => {
if (item.File) {
try {
const packageId = currentPath.join(':');
const versionHash = item.File.name.replace('.zip', '');
await removeDownload(packageId, versionHash);
await loadItems();
} catch (error) {
console.error('Failed to remove download:', error);
setError(`Failed to remove download: ${error instanceof Error ? error.message : String(error)}`);
}
}
};
const isAppInstalled = (name: string): boolean => {
const packageName = name.replace('.zip', '');
return Object.values(installed).some(app => app.package_id.package_name === packageName);
};
return (
<div className="downloads-page">
<h2>My Apps</h2>
{/* Installed Apps Section */}
<div className="file-explorer">
<h3>Installed Apps</h3>
<table className="downloads-table">
<thead>
<tr>
<th>Package ID</th>
<th>Actions</th>
</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>
{/* Downloads Section */}
<div className="file-explorer">
<h3>Downloads</h3>
<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>
{error && (
<div className="error-message">
{error}
</div>
)}
{/* Uninstall Confirmation Modal */}
{showUninstallConfirm && appToUninstall && (
<div className="cap-approval-popup">
<div className="cap-approval-content">
<h3>Confirm Uninstall</h3>
<div className="warning-message">
Are you sure you want to uninstall this app?
</div>
<div className="package-info">
<strong>Package ID:</strong> {`${appToUninstall.package_id.package_name}:${appToUninstall.package_id.publisher_node}`}
</div>
{appToUninstall.metadata?.name && (
<div className="package-info">
<strong>Name:</strong> {appToUninstall.metadata.name}
</div>
)}
<div className="approval-buttons">
<button
onClick={() => {
setShowUninstallConfirm(false);
setAppToUninstall(null);
}}
>
Cancel
</button>
<button
onClick={handleUninstall}
disabled={isUninstalling}
className="danger"
>
{isUninstalling ? <FaSpinner className="fa-spin" /> : 'Confirm Uninstall'}
</button>
</div>
</div>
</div>
)}
{showCapApproval && manifest && (
<div className="cap-approval-popup">
<div className="cap-approval-content">
<h3>Approve Capabilities</h3>
<pre className="json-display">
{JSON.stringify(manifest[0]?.request_capabilities || [], null, 2)}
</pre>
<div className="approval-buttons">
<button onClick={() => setShowCapApproval(false)}>Cancel</button>
<button onClick={confirmInstall} disabled={isInstalling}>
{isInstalling ? <FaSpinner className="fa-spin" /> : 'Approve and Install'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -3,15 +3,41 @@ import { FaFolder, FaFile, FaChevronLeft, FaSync, FaRocket, FaSpinner, FaCheck,
import useAppsStore from "../store";
import { DownloadItem, PackageManifest, PackageState } from "../types/Apps";
export default function MyDownloadsPage() {
const { fetchDownloads, fetchDownloadsForApp, startMirroring, stopMirroring, installApp, removeDownload, fetchInstalled, installed } = useAppsStore();
// Core packages that cannot be uninstalled
const CORE_PACKAGES = [
"app_store:sys",
"contacts:sys",
"kino_updates:sys",
"terminal:sys",
"chess:sys",
"kns_indexer:sys",
"settings:sys",
"homepage:sys"
];
export default function MyAppsPage() {
const {
fetchDownloads,
fetchDownloadsForApp,
startMirroring,
stopMirroring,
installApp,
removeDownload,
fetchInstalled,
installed,
uninstallApp
} = useAppsStore();
const [currentPath, setCurrentPath] = useState<string[]>([]);
const [items, setItems] = useState<DownloadItem[]>([]);
const [isInstalling, setIsInstalling] = useState(false);
const [isUninstalling, setIsUninstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCapApproval, setShowCapApproval] = useState(false);
const [manifest, setManifest] = useState<PackageManifest | null>(null);
const [selectedItem, setSelectedItem] = useState<DownloadItem | null>(null);
const [showUninstallConfirm, setShowUninstallConfirm] = useState(false);
const [appToUninstall, setAppToUninstall] = useState<any>(null);
useEffect(() => {
loadItems();
@ -33,6 +59,35 @@ export default function MyDownloadsPage() {
}
};
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);
}
};
const navigateToItem = (item: DownloadItem) => {
if (item.Dir) {
setCurrentPath([...currentPath, item.Dir.name]);
@ -85,7 +140,6 @@ export default function MyDownloadsPage() {
if (!versionHash) throw new Error('Invalid file name format');
// Construct packageId by combining currentPath and remaining parts of the filename
const packageId = [...currentPath, ...parts].join(':');
await installApp(packageId, versionHash);
@ -121,8 +175,48 @@ export default function MyDownloadsPage() {
return (
<div className="downloads-page">
<h2>Downloads</h2>
<h2>My Apps</h2>
{/* Installed Apps Section */}
<div className="file-explorer">
<h3>Installed Apps</h3>
<table className="downloads-table">
<thead>
<tr>
<th>Package ID</th>
<th>Actions</th>
</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>
{/* Downloads Section */}
<div className="file-explorer">
<h3>Downloads</h3>
<div className="path-navigation">
{currentPath.length > 0 && (
<button onClick={navigateUp} className="navigate-up">
@ -172,7 +266,8 @@ export default function MyDownloadsPage() {
)}
{isFile && isInstalled && (
<FaCheck className="installed" />
)} </td>
)}
</td>
</tr>
);
})}
@ -186,6 +281,45 @@ export default function MyDownloadsPage() {
</div>
)}
{/* Uninstall Confirmation Modal */}
{showUninstallConfirm && appToUninstall && (
<div className="cap-approval-popup">
<div className="cap-approval-content">
<h3>Confirm Uninstall</h3>
<div className="warning-message">
Are you sure you want to uninstall this app?
</div>
<div className="package-info">
<strong>Package ID:</strong> {`${appToUninstall.package_id.package_name}:${appToUninstall.package_id.publisher_node}`}
</div>
{appToUninstall.metadata?.name && (
<div className="package-info">
<strong>Name:</strong> {appToUninstall.metadata.name}
</div>
)}
<div className="approval-buttons">
<button
onClick={() => {
setShowUninstallConfirm(false);
setAppToUninstall(null);
}}
>
Cancel
</button>
<button
onClick={handleUninstall}
disabled={isUninstalling}
className="danger"
>
{isUninstalling ? <FaSpinner className="fa-spin" /> : 'Confirm Uninstall'}
</button>
</div>
</div>
</div>
)}
{showCapApproval && manifest && (
<div className="cap-approval-popup">
<div className="cap-approval-content">

View File

@ -1,6 +1,6 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { PackageState, AppListing, MirrorCheckFile, PackageManifest, DownloadItem, HomepageApp } from '../types/Apps'
import { PackageState, AppListing, MirrorCheckFile, DownloadItem, HomepageApp, ManifestResponse, Notification } from '../types/Apps'
import { HTTP_STATUS } from '../constants/http'
import KinodeClientApi from "@kinode/client-api"
import { WEBSOCKET_URL } from '../utils/ws'
@ -13,6 +13,7 @@ interface AppsStore {
downloads: Record<string, DownloadItem[]>
ourApps: AppListing[]
ws: KinodeClientApi
notifications: Notification[]
homepageApps: HomepageApp[]
activeDownloads: Record<string, { downloaded: number, total: number }>
@ -29,11 +30,15 @@ interface AppsStore {
fetchHomepageApps: () => Promise<void>
getLaunchUrl: (id: string) => string | null
addNotification: (notification: Notification) => void;
removeNotification: (id: string) => void;
clearNotifications: () => void;
installApp: (id: string, version_hash: string) => Promise<void>
uninstallApp: (id: string) => Promise<void>
downloadApp: (id: string, version_hash: string, downloadFrom: string) => Promise<void>
removeDownload: (packageId: string, versionHash: string) => Promise<void>
getCaps: (id: string) => Promise<PackageManifest | null>
getManifest: (id: string, version_hash: string) => Promise<ManifestResponse | null>
approveCaps: (id: string) => Promise<void>
startMirroring: (id: string) => Promise<void>
stopMirroring: (id: string) => Promise<void>
@ -52,6 +57,7 @@ const useAppsStore = create<AppsStore>()((set, get) => ({
ourApps: [],
activeDownloads: {},
homepageApps: [],
notifications: [],
fetchData: async (id: string) => {
@ -282,14 +288,14 @@ const useAppsStore = create<AppsStore>()((set, get) => ({
}
},
getCaps: async (id: string) => {
getManifest: async (id: string, version_hash: string) => {
try {
const res = await fetch(`${BASE_URL}/apps/${id}/caps`);
const res = await fetch(`${BASE_URL}/manifest?id=${id}&version_hash=${version_hash}`);
if (res.status === HTTP_STATUS.OK) {
return await res.json() as PackageManifest;
return await res.json() as ManifestResponse;
}
} catch (error) {
console.error("Error getting caps:", error);
console.error("Error getting manifest:", error);
}
return null;
},
@ -355,6 +361,18 @@ const useAppsStore = create<AppsStore>()((set, get) => ({
}));
},
addNotification: (notification) => set(state => ({
notifications: [...state.notifications, notification]
})),
removeNotification: (id) => set(state => ({
notifications: state.notifications.filter(n => n.id !== id)
})),
clearNotifications: () => set({ notifications: [] }),
clearActiveDownload: (appId) => {
set((state) => {
const { [appId]: _, ...rest } = state.activeDownloads;
@ -374,10 +392,48 @@ const useAppsStore = create<AppsStore>()((set, get) => ({
const { package_id, version_hash, downloaded, total } = data.data;
const appId = `${package_id.package_name}:${package_id.publisher_node}:${version_hash}`;
get().setActiveDownload(appId, downloaded, total);
const existingNotification = get().notifications.find(
n => n.id === `download-${appId}`
);
if (existingNotification) {
get().removeNotification(`download-${appId}`);
}
get().addNotification({
id: `download-${appId}`,
type: 'download',
message: `Downloading ${package_id.package_name}`,
timestamp: Date.now(),
metadata: {
packageId: `${package_id.package_name}:${package_id.publisher_node}`,
versionHash: version_hash,
progress: Math.round((downloaded / total) * 100)
}
});
} else if (data.kind === 'complete') {
const { package_id, version_hash } = data.data;
const { package_id, version_hash, error } = data.data;
const appId = `${package_id.package_name}:${package_id.publisher_node}:${version_hash}`;
get().clearActiveDownload(appId);
get().removeNotification(`download-${appId}`);
if (error) {
get().addNotification({
id: `error-${appId}`,
type: 'error',
message: `Download failed for ${package_id.package_name}: ${error}`,
timestamp: Date.now(),
});
} else {
get().addNotification({
id: `complete-${appId}`,
type: 'success',
message: `Download complete: ${package_id.package_name}`,
timestamp: Date.now(),
});
}
get().fetchData(`${package_id.package_name}:${package_id.publisher_node}`);
}
} catch (error) {

View File

@ -1,3 +1,6 @@
import { ReactNode } from "react";
import { IconType } from "react-icons/lib";
export interface PackageId {
package_name: string;
publisher_node: string;
@ -59,9 +62,10 @@ export interface PackageState {
our_version_hash: string;
verified: boolean;
caps_approved: boolean;
pending_update_hash?: string;
}
export interface PackageManifest {
export interface PackageManifestEntry {
process_name: string
process_wasm_path: string
on_exit: string
@ -71,6 +75,12 @@ export interface PackageManifest {
public: boolean
}
export interface ManifestResponse {
package_id: PackageId;
version_hash: string;
manifest: string;
}
export interface HomepageApp {
id: string;
process: string;
@ -83,3 +93,35 @@ export interface HomepageApp {
order: number;
favorite: boolean;
}
export type NotificationActionType = 'click' | 'modal' | 'popup' | 'redirect';
export type NotificationAction = {
label: string;
icon?: IconType;
variant?: 'primary' | 'secondary' | 'danger';
action: {
type: NotificationActionType;
onClick?: () => void;
modalContent?: ReactNode | (() => ReactNode);
popupContent?: ReactNode | (() => ReactNode);
path?: string;
};
};
export type Notification = {
id: string;
type: 'error' | 'success' | 'warning' | 'info' | 'download' | 'install' | 'update';
message: string;
timestamp: number;
metadata?: {
packageId?: string;
versionHash?: string;
progress?: number;
error?: string;
};
actions?: NotificationAction[];
renderContent?: (notification: Notification) => ReactNode;
persistent?: boolean;
};

View File

@ -3,7 +3,7 @@ const BASE_URL = "/main:app-store:sys/";
if (window.our) window.our.process = BASE_URL?.replace("/", "");
export const PROXY_TARGET = `${(import.meta.env.VITE_NODE_URL || `http://localhost:8080`)}${BASE_URL}`;
export const PROXY_TARGET = `${(import.meta.env.VITE_NODE_URL || `http://localhost:8080`).replace(/\/+$/, '')}${BASE_URL}`;
// This env also has BASE_URL which should match the process + package name
export const WEBSOCKET_URL = import.meta.env.DEV

View File

@ -1,4 +1,4 @@
import { defineConfig } from 'vite'
import { defineConfig, ViteDevServer } from 'vite'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
import react from '@vitejs/plugin-react'
@ -17,10 +17,15 @@ The format is "/" + "process_name:package_name:publisher_node"
const BASE_URL = `/main:app-store:sys`;
// This is the proxy URL, it must match the node you are developing against
const PROXY_URL = (process.env.VITE_NODE_URL || 'http://127.0.0.1:8080').replace('localhost', '127.0.0.1');
const PROXY_URL = (process.env.VITE_NODE_URL || 'http://localhost:8080').replace(/\/$/, '');
const DEV_SERVER_PORT = 3000; // Hardcoded port for the dev server...
console.log('process.env.VITE_NODE_URL', process.env.VITE_NODE_URL, PROXY_URL);
const openUrl = `${PROXY_URL.replace(/:\d+$/, '')}:${DEV_SERVER_PORT}${BASE_URL}`;
console.log('Server will run at:', openUrl);
export default defineConfig({
plugins: [
nodePolyfills({
@ -37,7 +42,8 @@ export default defineConfig({
}
},
server: {
open: true,
open: openUrl,
port: DEV_SERVER_PORT,
proxy: {
[`^${BASE_URL}/our.js`]: {
target: PROXY_URL,

View File

@ -77,12 +77,7 @@ function populate_contacts(contacts) {
document.getElementById('back-button').addEventListener('click', () => {
// set page to `/` while also removing the subdomain
const url = new URL(window.location.href);
if (url.hostname.split('.')[0] === 'contacts-sys') {
url.hostname = url.hostname.split('.').slice(1).join('.');
}
url.pathname = '/';
window.location.href = url.toString();
window.location.href = window.location.origin.replace('//contacts-sys.', '//') + '/'
});
document.getElementById('add-contact').addEventListener('submit', (e) => {

View File

@ -1,4 +1,4 @@
use kinode_process_lib::{call_init, http, timer, Address, Request};
use kinode_process_lib::{call_init, http, println, timer, Address, Request};
use serde::{Deserialize, Serialize};
wit_bindgen::generate!({

View File

@ -23,7 +23,7 @@ wit_bindgen::generate!({
#[cfg(not(feature = "simulation-mode"))]
const KIMAP_ADDRESS: &'static str = kimap::KIMAP_ADDRESS; // optimism
#[cfg(feature = "simulation-mode")]
const KIMAP_ADDRESS: &'static str = "0xEce71a05B36CA55B895427cD9a440eEF7Cf3669D"; // local
const KIMAP_ADDRESS: &'static str = "0x9CE8cCD2932DC727c70f9ae4f8C2b68E6Abed58C"; // local
#[cfg(not(feature = "simulation-mode"))]
const CHAIN_ID: u64 = kimap::KIMAP_CHAIN_ID; // optimism

View File

@ -13,15 +13,15 @@ use std::str::FromStr;
use crate::{keygen, sol::*, KIMAP_ADDRESS, MULTICALL_ADDRESS};
// TODO move these into contracts registry, doublecheck optimism deployments
const FAKE_DOTDEV_TBA: &str = "0x1a5447E634aa056Fa302E48630Da8425EC15A53A";
const FAKE_DOTOS_TBA: &str = "0xF5FaB379Eb87599d7B5BaBeDDEFe6EfDEC6164b0";
const _FAKE_ZEROTH_TBA: &str = "0x02dd7FB5ca377b1a6E2960EB139aF390a24D28FA";
const FAKE_DOTDEV_TBA: &str = "0x27e913BF6dcd08E9E68530812B277224Be07890B";
const FAKE_DOTOS_TBA: &str = "0xC026fE4950c12AdACF284689d900AcC74987c555";
const _FAKE_ZEROTH_TBA: &str = "0x33b687295Cb095d9d962BA83732c67B96dffC8eA";
const KINO_ACCOUNT_IMPL: &str = "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0";
const KINO_ACCOUNT_IMPL: &str = "0x00ee0e0d00F01f6FF3aCcBA2986E07f99181b9c2";
const MULTICALL: &str = "0xcA11bde05977b3631167028862bE2a173976CA11";
const KIMAP: &str = "0xEce71a05B36CA55B895427cD9a440eEF7Cf3669D";
const KIMAP: &str = "0x9CE8cCD2932DC727c70f9ae4f8C2b68E6Abed58C";
/// Attempts to connect to a local anvil fakechain,
/// registering a name with its KiMap contract.

View File

@ -234,4 +234,4 @@
</script>
</body>
</html>
</html>

View File

@ -293,6 +293,8 @@ async fn serve(
warp::reply::with_status(warp::reply::html(cloned_login_html), StatusCode::OK)
})
.or(warp::post()
.and(warp::filters::host::optional())
.and(warp::query::<HashMap<String, String>>())
.and(warp::body::content_length_limit(1024 * 16))
.and(warp::body::json())
.and(warp::any().map(move || cloned_our.clone()))
@ -325,7 +327,12 @@ async fn serve(
/// handle non-GET requests on /login. if POST, validate password
/// and return auth token, which will be stored in a cookie.
///
/// if redirect is provided in URL, such as ?redirect=/chess:chess:sys/,
/// the browser will be redirected to that path after successful login.
async fn login_handler(
host: Option<warp::host::Authority>,
query_params: HashMap<String, String>,
info: LoginInfo,
our: Arc<String>,
encoded_keyfile: Arc<Vec<u8>>,
@ -353,11 +360,15 @@ async fn login_handler(
}
};
let mut response = warp::reply::with_status(
warp::reply::json(&base64_standard.encode(encoded_keyfile.to_vec())),
StatusCode::OK,
)
.into_response();
let mut response = if let Some(redirect) = query_params.get("redirect") {
warp::reply::with_status(warp::reply(), StatusCode::SEE_OTHER).into_response()
} else {
warp::reply::with_status(
warp::reply::json(&base64_standard.encode(encoded_keyfile.to_vec())),
StatusCode::OK,
)
.into_response()
};
let cookie = match info.subdomain.unwrap_or_default().as_str() {
"" => format!("kinode-auth_{our}={token};"),
@ -367,6 +378,25 @@ async fn login_handler(
match HeaderValue::from_str(&cookie) {
Ok(v) => {
response.headers_mut().append("set-cookie", v);
if let Some(redirect) = query_params.get("redirect") {
// get http/https from request headers
let proto = match response.headers().get("X-Forwarded-Proto") {
Some(proto) => proto.to_str().unwrap_or("http").to_string(),
None => "http".to_string(),
};
response.headers_mut().append(
"Location",
HeaderValue::from_str(&format!(
"{proto}://{}{redirect}",
host.unwrap()
))
.unwrap(),
);
response
.headers_mut()
.append("Content-Length", HeaderValue::from_str("0").unwrap());
}
Ok(response)
}
Err(e) => Ok(warp::reply::with_status(

View File

@ -343,6 +343,7 @@ async fn handle_request(
// current prepend to filepaths needs to be: /package_id/drive/path
let (package_id, drive, rest) = parse_package_and_drive(&request.path, &vfs_path)?;
// must have prepended `/` here or else it messes up caps downstream, e.g. in run-tests
let drive = format!("/{package_id}/{drive}");
let action = request.action;
let path = PathBuf::from(&request.path);

View File

@ -60,7 +60,7 @@ def build_and_move(feature, tmp_dir, architecture, os_name):
source_path = f"target/release/{binary_name}"
dest_path = os.path.join(tmp_dir, binary_name)
shutil.move(source_path, dest_path)
os.chmod(dest_path, 0o775)
os.chmod(dest_path, 0o644)
# Create a zip archive of the binary
zip_path = os.path.join(tmp_dir, zip_name)