Merge branch 'main' into main

This commit is contained in:
lynett.eth 2024-08-26 22:44:03 +01:00 committed by GitHub
commit b74b90694f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
343 changed files with 39648 additions and 74575 deletions

22
.github/workflows/build_release.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: rust tagged release in main CI
on:
push:
tags: ['v[0-9].[0-9]+.[0-9]+']
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: build and deploy kinode
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SSH_PROD_API_HOST }}
username: ${{ secrets.SSH_PROD_USER }}
key: ${{ secrets.SSH_PROD_API_ED25519KEY }}
port: ${{ secrets.SSH_PROD_PORT }}
command_timeout: 60m
script: |
curl -X PUT http://localhost:8000/monitor/build-kinode

View File

@ -13,11 +13,10 @@ jobs:
- name: build and deploy kinode
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SSH_HOST }}
host: ${{ secrets.SSH_API_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_ED25519KEY }}
key: ${{ secrets.SSH_API_ED25519KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 60m
script: |
cd ~
./build-kinode.sh
curl -X PUT http://localhost:8000/monitor/build-kinode

6
.gitignore vendored
View File

@ -15,3 +15,9 @@ wit/
.env
kinode/src/bootstrapped_processes.rs
kinode/packages/**/wasi_snapshot_preview1.wasm
kinode/packages/app_store/pkg/ui/*
kinode/packages/homepage/pkg/ui/*
kinode/src/register-ui/build/
kinode/src/register-ui/dist/
kinode/packages/docs/pkg/ui

2104
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
[package]
name = "kinode_lib"
authors = ["KinodeDAO"]
version = "0.8.0"
version = "0.9.1"
edition = "2021"
description = "A general-purpose sovereign cloud computing platform"
homepage = "https://kinode.org"
@ -15,15 +15,17 @@ lib = { path = "lib" }
members = [
"lib", "kinode",
"kinode/packages/app_store/app_store", "kinode/packages/app_store/ft_worker",
"kinode/packages/app_store/download", "kinode/packages/app_store/install", "kinode/packages/app_store/uninstall",
"kinode/packages/app_store/download", "kinode/packages/app_store/install", "kinode/packages/app_store/uninstall", "kinode/packages/app_store/downloads", "kinode/packages/app_store/chain",
"kinode/packages/chess/chess",
"kinode/packages/homepage/homepage",
"kinode/packages/kino_updates/widget",
"kinode/packages/kino_updates/blog", "kinode/packages/kino_updates/globe",
"kinode/packages/kns_indexer/kns_indexer", "kinode/packages/kns_indexer/get_block", "kinode/packages/kns_indexer/state",
"kinode/packages/settings/settings",
"kinode/packages/terminal/terminal",
"kinode/packages/terminal/alias", "kinode/packages/terminal/cat", "kinode/packages/terminal/echo", "kinode/packages/terminal/hi", "kinode/packages/terminal/kfetch", "kinode/packages/terminal/kill", "kinode/packages/terminal/m", "kinode/packages/terminal/top",
"kinode/packages/terminal/namehash_to_name", "kinode/packages/terminal/net_diagnostics", "kinode/packages/terminal/peer", "kinode/packages/terminal/peers",
"kinode/packages/terminal/alias", "kinode/packages/terminal/cat", "kinode/packages/terminal/echo",
"kinode/packages/terminal/help", "kinode/packages/terminal/hi", "kinode/packages/terminal/kfetch",
"kinode/packages/terminal/kill", "kinode/packages/terminal/m", "kinode/packages/terminal/top",
"kinode/packages/terminal/net_diagnostics", "kinode/packages/terminal/peer", "kinode/packages/terminal/peers",
"kinode/packages/tester/tester",
]
default-members = ["lib"]

View File

@ -175,7 +175,7 @@
END OF TERMS AND CONDITIONS
Copyright 2024 Unzentrum DAO
Copyright 2024 Sybil Technologies AG
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@ -37,7 +37,11 @@ rustup target add wasm32-wasi
rustup target add wasm32-wasi --toolchain nightly
cargo install cargo-wasi
# Build the runtime, along with a number of "distro" WASM modules.
# Install NPM so we can build frontends for "distro" packages.
# https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
# If you want to skip this step, run cargo build with the environment variable SKIP_BUILD_FRONTEND=true
# Build the runtime, along with a number of "distro" Wasm modules.
# The compiled binary will be at `kinode/target/debug/kinode`
# OPTIONAL: --release flag (slower build; faster runtime; binary at `kinode/target/release/kinode`)
@ -121,22 +125,41 @@ The `sys` publisher is not a real node ID, but it's also not a special case valu
- UpArrow/DownArrow or CTRL+P/CTRL+N to move up and down through command history
- CTRL+R to search history, CTRL+R again to toggle through search results, CTRL+G to cancel search
- `m <address> '<json>'`: send an inter-process message. <address> is formatted as <node>@<process_id>. <process_id> is formatted as <process_name>:<package_name>:<publisher_node>. JSON containing spaces must be wrapped in single-quotes (`''`).
- Example: `m our@eth:distro:sys "SetPublic" -a 5`
- the '-a' flag is used to expect a response with a given timeout
- `our` will always be interpolated by the system as your node's name
### Built-in terminal scripts
The terminal package contains a number of built-in scripts.
Users may also call scripts from other packages in the terminal by entering the (full) ID of the script process followed by any arguments.
In order to call a script with shorthand, a user may apply an *alias* using the terminal `alias` script, like so:
```
alias <shorthand> <full_name>
```
Subsequent use of the shorthand will then be interpolated as the process ID.
A list of the terminal scripts included in this distro:
- `alias <shorthand> <process_id>`: create an alias for a script.
- Example: `alias get_block get_block:kns_indexer:sys`
- note: all of these listed commands are just default aliases for terminal scripts.
- `cat <vfs-file-path>`: print the contents of a file in the terminal.
- Example: `cat /terminal:sys/pkg/scripts.json`
- `echo <text>`: print text to the terminal.
- Example: `echo foo`
- `help <command>`: print the help message for a command. Leave the command blank to print the help message for all commands.
- `hi <name> <string>`: send a text message to another node's command line.
- Example: `hi ben.os hello world`
- Example: `hi mothu.kino hello world`
- `kfetch`: print system information a la neofetch. No arguments.
- `kill <process-id>`: terminate a running process. This will bypass any restart behavioruse judiciously.
- Example: `kill chess:chess:sys`
- `m <address> '<json>'`: send an inter-process message. <address> is formatted as <node>@<process_id>. <process_id> is formatted as <process_name>:<package_name>:<publisher_node>. JSON containing spaces must be wrapped in single-quotes (`''`).
- Example: `m our@eth:distro:sys "SetPublic" -a 5`
- the '-a' flag is used to expect a response with a given timeout
- `our` will always be interpolated by the system as your node's name
- `net_diagnostics`: print some useful networking diagnostic data.
- `peer <name>`: print the peer's PKI info, if it exists.
- `peers`: print the peers the node currently hold connections with.
- `top <process_id>`: display kernel debugging info about a process. Leave the process ID blank to display info about all processes and get the total number of running processes.
- Example: `top net:distro:sys`
- Example: `top`
- `cat <vfs-file-path>`: print the contents of a file in the terminal
- Example: `cat /terminal:sys/pkg/scripts.json`
- `echo <text>`: print `text` to the terminal
- Example: `echo foo`
- `net_diagnostics`: print some useful networking diagnostic data
- `peers`: print the peers the node currently hold connections with
- `peer <name>`: print the peer's PKI info, if it exists
- Example: `top net:distro:sys`
- Example: `top`
## Running as a Docker container

182
css/kinode.css Normal file
View File

@ -0,0 +1,182 @@
/* CSS Reset and Base Styles */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font-family: var(--font-family-main);
}
/* Variables */
:root {
color-scheme: light dark;
--orange: #FF4F00;
--dark-orange: #cc4100;
--blue: #2B88D9;
--off-white: #fdfdfd;
--white: #ffffff;
--off-black: #0C090A;
--black: #000000;
--tan: #fdf6e3;
--ansi-red: #dc322f;
--maroon: #4f0000;
--gray: #657b83;
--tasteful-dark: #1f1f1f;
--font-family-main: 'Kode Mono', monospace;
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6,
p,
label,
li,
span {
font-family: var(--font-family-main);
color: light-dark(var(--off-black), var(--off-white));
}
p,
li {
font-size: 0.8em;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.5em;
}
h3 {
font-size: 1.3em;
}
h4 {
font-size: 1.2em;
}
h5 {
font-size: 1.1em;
}
h6 {
font-size: 1em;
}
a {
font-family: var(--font-family-main);
color: light-dark(var(--blue), var(--orange));
text-decoration: none;
}
a:hover {
color: light-dark(var(--orange), var(--dark-orange));
text-decoration: underline wavy;
}
/* Layout */
body {
line-height: 1.6;
-webkit-font-smoothing: antialiased;
background-color: light-dark(var(--tan), var(--tasteful-dark));
background-image: radial-gradient(circle at -1% -47%, #4700002b 7%, transparent 58.05%), radial-gradient(circle at 81% 210%, #d6430550 17%, transparent 77.05%);
min-width: 100vw;
min-height: 100vh;
}
/* Sections */
section {
background-color: light-dark(var(--white), var(--maroon));
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 2rem;
margin-bottom: 2rem;
transition: transform 0.3s ease;
}
section:hover {
transform: translateY(-5px);
}
/* Forms */
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
form label {
margin-bottom: 0.5rem;
font-weight: bold;
}
form input {
padding: 0.75rem;
border: 2px solid var(--orange);
border-radius: 4px;
transition: all 0.3s ease;
}
form input:focus {
outline: none;
border-color: var(--dark-orange);
box-shadow: 0 0 0 3px rgba(255, 79, 0, 0.2);
}
/* Button styles */
button {
display: inline-block;
padding: 10px 20px;
font-size: 16px;
font-weight: bold;
text-align: center;
text-decoration: none;
background-color: var(--orange);
color: white;
border: none;
border-radius: 4px;
transition: background-color 0.3s;
}
button:hover {
background-color: var(--dark-orange);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.secondary {
background-color: light-dark(var(--off-white), var(--off-black));
color: var(--orange);
border: 2px solid var(--orange);
}
button.secondary:hover {
background-color: var(--orange);
color: white;
}

View File

Before

Width:  |  Height:  |  Size: 644 B

After

Width:  |  Height:  |  Size: 644 B

View File

@ -1,7 +1,7 @@
[package]
name = "kinode"
authors = ["KinodeDAO"]
version = "0.8.0"
version = "0.9.1"
edition = "2021"
description = "A general-purpose sovereign cloud computing platform"
homepage = "https://kinode.org"
@ -14,9 +14,9 @@ path = "src/main.rs"
[build-dependencies]
anyhow = "1.0.71"
kit = { git = "https://github.com/kinode-dao/kit", rev = "d319c5b" }
rayon = "1.8.1"
sha2 = "0.10"
flate2 = "1.0"
kit = { git = "https://github.com/kinode-dao/kit", tag = "v0.6.10" }
tar = "0.4"
tokio = "1.28"
walkdir = "2.4"
zip = "0.6"
@ -26,7 +26,7 @@ simulation-mode = []
[dependencies]
aes-gcm = "0.10.3"
alloy = { git = "https://github.com/alloy-rs/alloy", rev = "05f8162", features = [
alloy = { git = "https://github.com/kinode-dao/alloy.git", rev = "e672f3e", features = [
"consensus",
"contract",
"json-rpc",
@ -34,46 +34,35 @@ alloy = { git = "https://github.com/alloy-rs/alloy", rev = "05f8162", features =
"provider-ws",
"providers",
"pubsub",
"rpc-client-ws",
"rpc",
"rpc-client",
"rpc-types-eth",
"rpc-client-ws",
"rpc-types",
"signer-wallet",
"rpc-types-eth",
"signers",
"signer-local",
] }
alloy-primitives = "0.7.5"
alloy-sol-macro = "0.7.5"
alloy-sol-types = "0.7.5"
alloy-primitives = "0.7.6"
alloy-sol-macro = "0.7.6"
alloy-sol-types = "0.7.6"
anyhow = "1.0.71"
async-trait = "0.1.71"
base64 = "0.22.0"
bincode = "1.3.3"
blake3 = "1.4.1"
bytes = "1.4.0"
chacha20poly1305 = "0.10.1"
chrono = "0.4.31"
clap = { version = "4.4", features = ["derive"] }
crossterm = { version = "0.27.0", features = ["event-stream", "bracketed-paste"] }
curve25519-dalek = "^4.1.2"
dashmap = "5.5.3"
digest = "0.10"
elliptic-curve = { version = "0.13.8", features = ["ecdh"] }
flate2 = "1.0"
futures = "0.3"
generic-array = "1.0.0"
getrandom = "0.2.10"
generic-array = "0.14.7"
hex = "0.4.3"
hkdf = "0.12.3"
hmac = "0.12"
http = "1.1.0"
jwt = "0.16"
lib = { path = "../lib" }
lazy_static = "1.4.0"
log = "0.4.20"
nohash-hasher = "0.2.0"
num-traits = "0.2"
open = "5.0.0"
open = "5.1.4"
public-ip = "0.2.2"
rand = "0.8.4"
reqwest = "0.12.4"
@ -84,8 +73,7 @@ route-recognizer = "0.3.1"
rusqlite = { version = "0.31.0", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_urlencoded = "0.7"
sha2 = "0.10"
sha2 = "0.10.8"
sha3 = "0.10.8"
# snow = { version = "0.9.5", features = ["ring-resolver"] }
# unfortunately need to use forked version for async use and in-place encryption
@ -96,7 +84,6 @@ thiserror = "1.0"
tokio = { version = "1.28", features = ["fs", "macros", "rt-multi-thread", "signal", "sync"] }
tokio-tungstenite = { version = "0.21.0", features = ["native-tls"] }
url = "2.4.1"
uuid = { version = "1.1.2", features = ["serde", "v4"] }
warp = "0.3.5"
wasi-common = "19.0.1"
wasmtime = "19.0.1"

View File

@ -1,12 +1,20 @@
use rayon::prelude::*;
use std::{
collections::HashSet,
fs::{self, File},
io::{Cursor, Read, Write},
io::{BufReader, Cursor, Read, Write},
path::{Path, PathBuf},
};
use flate2::read::GzDecoder;
use tar::Archive;
use zip::write::FileOptions;
macro_rules! p {
($($tokens: tt)*) => {
println!("cargo:warning={}", format!($($tokens)*))
}
}
/// get cargo features to compile packages with
fn get_features() -> String {
let mut features = "".to_string();
for (key, _) in std::env::vars() {
@ -16,42 +24,90 @@ fn get_features() -> String {
.to_lowercase()
.replace("_", "-");
features.push_str(&feature);
//println!("cargo:rustc-cfg=feature=\"{}\"", feature);
//println!("- {}", feature);
}
}
features
}
fn output_reruns(dir: &Path, rerun_files: &HashSet<String>) {
if rerun_files.contains(dir.to_str().unwrap()) {
// Output for all files in the directory if the directory itself is specified in rerun_files
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
println!("cargo:rerun-if-changed={}", path.display());
}
}
} else {
// Check files individually
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
// If the entry is a directory, recursively walk it
output_reruns(&path, rerun_files);
} else if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
// Check if the current file is in our list of interesting files
if rerun_files.contains(filename) {
// If so, print a `cargo:rerun-if-changed=PATH` line for it
println!("cargo:rerun-if-changed={}", path.display());
/// print `cargo:rerun-if-changed=PATH` for each path of interest
fn output_reruns(dir: &Path) {
// Check files individually
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
if let Some(dirname) = path.file_name().and_then(|n| n.to_str()) {
if dirname == "ui" || dirname == "target" {
// do not prompt a rerun if only UI/build files have changed
continue;
}
// If the entry is a directory not in rerun_files, recursively walk it
output_reruns(&path);
}
} else {
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if filename.ends_with(".zip") || filename.ends_with(".wasm") {
// do not prompt a rerun for compiled outputs
continue;
}
// any other changed file within a package subdir prompts a rerun
println!("cargo::rerun-if-changed={}", path.display());
}
}
}
}
}
fn untar_gz_file(path: &Path, dest: &Path) -> std::io::Result<()> {
// Open the .tar.gz file
let tar_gz = File::open(path)?;
let tar_gz_reader = BufReader::new(tar_gz);
// Decode the gzip layer
let tar = GzDecoder::new(tar_gz_reader);
// Create a new archive from the tar file
let mut archive = Archive::new(tar);
// Unpack the archive into the specified destination directory
archive.unpack(dest)?;
Ok(())
}
/// fetch .tar.gz of kinode book for docs app
fn get_kinode_book(packages_dir: &Path) -> anyhow::Result<()> {
p!("fetching kinode book .tar.gz");
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let releases = kit::boot_fake_node::fetch_releases("kinode-dao", "kinode-book")
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
if releases.is_empty() {
return Err(anyhow::anyhow!("couldn't retrieve kinode-book releases"));
}
let release = &releases[0];
if release.assets.is_empty() {
return Err(anyhow::anyhow!(
"most recent kinode-book release has no assets"
));
}
let release_url = format!(
"https://github.com/kinode-dao/kinode-book/releases/download/{}/{}",
release.tag_name, release.assets[0].name,
);
let book_dir = packages_dir.join("docs").join("pkg").join("ui");
fs::create_dir_all(&book_dir)?;
let book_tar_path = book_dir.join("book.tar.gz");
kit::build::download_file(&release_url, &book_tar_path)
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
untar_gz_file(&book_tar_path, &book_dir)?;
fs::remove_file(book_tar_path)?;
Ok(())
})
}
fn build_and_zip_package(
entry_path: PathBuf,
parent_pkg_path: &str,
@ -59,13 +115,27 @@ fn build_and_zip_package(
) -> anyhow::Result<(String, String, Vec<u8>)> {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
kit::build::execute(&entry_path, true, false, true, features, None, None) // TODO
.await
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
kit::build::execute(
&entry_path,
true,
false,
true,
features,
None,
None,
None,
vec![],
vec![],
false,
false,
false,
)
.await
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
let mut writer = Cursor::new(Vec::new());
let options = FileOptions::default()
.compression_method(zip::CompressionMethod::Stored)
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o755);
{
let mut zip = zip::ZipWriter::new(&mut writer);
@ -96,7 +166,7 @@ fn build_and_zip_package(
fn main() -> anyhow::Result<()> {
if std::env::var("SKIP_BUILD_SCRIPT").is_ok() {
println!("Skipping build script");
p!("skipping build script");
return Ok(());
}
@ -104,30 +174,49 @@ fn main() -> anyhow::Result<()> {
let parent_dir = pwd.parent().unwrap();
let packages_dir = pwd.join("packages");
let entries: Vec<_> = fs::read_dir(packages_dir)?
.map(|entry| entry.unwrap().path())
.collect();
if std::env::var("SKIP_BUILD_FRONTEND").is_ok() {
p!("skipping frontend builds");
} else {
// build core frontends
let core_frontends = vec![
"src/register-ui",
"packages/app_store/ui",
"packages/homepage/ui",
// chess when brought in
];
let rerun_files: HashSet<String> = HashSet::from([
"Cargo.lock".to_string(),
"Cargo.toml".to_string(),
"src/".to_string(),
]);
output_reruns(&parent_dir, &rerun_files);
// for each frontend, execute build.sh
for frontend in core_frontends {
let status = std::process::Command::new("sh")
.current_dir(pwd.join(frontend))
.arg("./build.sh")
.status()?;
if !status.success() {
return Err(anyhow::anyhow!("Failed to build frontend: {}", frontend));
}
}
}
get_kinode_book(&packages_dir)?;
output_reruns(&packages_dir);
let features = get_features();
let results: Vec<anyhow::Result<(String, String, Vec<u8>)>> = entries
.par_iter()
.filter_map(|entry_path| {
let parent_pkg_path = entry_path.join("pkg");
if !parent_pkg_path.exists() {
let results: Vec<anyhow::Result<(String, String, Vec<u8>)>> = fs::read_dir(&packages_dir)?
.filter_map(|entry| {
let entry_path = match entry {
Ok(e) => e.path(),
Err(_) => return None,
};
let child_pkg_path = entry_path.join("pkg");
if !child_pkg_path.exists() {
// don't run on, e.g., `.DS_Store`
return None;
}
Some(build_and_zip_package(
entry_path.clone(),
parent_pkg_path.to_str().unwrap(),
child_pkg_path.to_str().unwrap(),
&features,
))
})
@ -160,7 +249,11 @@ fn main() -> anyhow::Result<()> {
}
writeln!(bootstrapped_processes, "];")?;
let bootstrapped_processes_path = pwd.join("src/bootstrapped_processes.rs");
let target_dir = pwd.join("../target");
if !target_dir.exists() {
fs::create_dir_all(&target_dir)?;
}
let bootstrapped_processes_path = target_dir.join("bootstrapped_processes.rs");
fs::write(&bootstrapped_processes_path, bootstrapped_processes)?;
Ok(())

View File

@ -1,9 +1,157 @@
interface main {
interface downloads {
//
// app store API as presented by main:app_store:sys-v0
// download API as presented by download:app_store:sys-v0
//
use standard.{package-id};
use chain.{onchain-metadata};
variant download-requests {
// remote only
remote-download(remote-download-request),
chunk(chunk-request),
progress(progress-update),
size(size-update),
// local only
local-download(local-download-request),
auto-update(auto-update-request),
download-complete(download-complete-request),
get-files(option<package-id>),
remove-file(remove-file-request),
add-download(add-download-request),
start-mirroring(package-id),
stop-mirroring(package-id),
}
variant download-responses {
success,
error(download-error),
get-files(list<entry>),
}
record local-download-request {
package-id: package-id,
download-from: string,
desired-version-hash: string,
}
record auto-update-request {
package-id: package-id,
metadata: onchain-metadata,
}
record remote-download-request {
package-id: package-id,
worker-address: string,
desired-version-hash: string,
}
variant download-error {
no-package,
not-mirroring,
hash-mismatch(hash-mismatch),
file-not-found,
worker-spawn-failed,
http-client-error,
blob-not-found,
vfs-error,
}
record download-complete-request {
package-id: package-id,
version-hash: string,
error: option<download-error>,
}
record hash-mismatch {
desired: string,
actual: string,
}
record chunk-request {
package-id: package-id,
version-hash: string,
offset: u64,
length: u64,
}
variant entry {
file(file-entry),
dir(dir-entry),
}
record file-entry {
name: string,
size: u64,
manifest: string,
}
record dir-entry {
name: string,
mirroring: bool,
}
record remove-file-request {
package-id: package-id,
version-hash: string,
}
// part of new-package-request local-only flow.
record add-download-request {
package-id: package-id,
version-hash: string,
mirror: bool,
}
record progress-update {
package-id: package-id,
version-hash: string,
downloaded: u64,
total: u64,
}
record size-update {
package-id: package-id,
size: u64,
}
}
interface chain {
//
// on-chain API as presented by chain:app_store:sys-v0
//
use standard.{package-id};
variant chain-requests {
get-app(package-id),
get-apps,
get-our-apps,
start-auto-update(package-id),
stop-auto-update(package-id),
}
variant chain-responses {
get-app(option<onchain-app>),
get-apps(list<onchain-app>),
get-our-apps(list<onchain-app>),
auto-update-started,
auto-update-stopped,
error(chain-error),
}
variant chain-error {
no-package,
}
record onchain-app {
package-id: package-id,
tba: string,
metadata-uri: string,
metadata-hash: string,
metadata: option<onchain-metadata>,
auto-update: bool,
}
record onchain-metadata {
name: option<string>,
@ -25,84 +173,55 @@ interface main {
wit-version: option<u32>,
dependencies: option<list<string>>,
}
}
interface main {
//
// app store API as presented by main:app_store:sys-v0
//
use standard.{package-id};
use chain.{onchain-metadata, chain-error};
use downloads.{download-error};
variant request {
remote(remote-request),
local(local-request),
}
variant response {
remote(remote-response),
local(local-response),
}
variant remote-request {
download(remote-download-request),
}
record remote-download-request {
package-id: package-id,
desired-version-hash: option<string>,
}
variant remote-response {
download-approved,
download-denied(reason),
}
variant reason {
no-package,
not-mirroring,
hash-mismatch(hash-mismatch),
file-not-found,
worker-spawn-failed
}
record hash-mismatch {
requested: string,
have: string,
chain-error(chain-error),
download-error(download-error),
}
variant local-request {
new-package(new-package-request),
download(download-request),
install(package-id),
install(install-package-request),
uninstall(package-id),
start-mirroring(package-id),
stop-mirroring(package-id),
start-auto-update(package-id),
stop-auto-update(package-id),
rebuild-index,
apis,
get-api(package-id),
}
record new-package-request {
package-id: package-id,
metadata: onchain-metadata,
mirror: bool,
}
record download-request {
package-id: package-id,
download-from: string,
mirror: bool,
auto-update: bool,
desired-version-hash: option<string>,
}
variant local-response {
new-package-response(new-package-response),
download-response(download-response),
install-response(install-response),
uninstall-response(uninstall-response),
mirror-response(mirror-response),
auto-update-response(auto-update-response),
rebuild-index-response(rebuild-index-response),
apis-response(apis-response),
get-api-response(get-api-response),
}
record new-package-request {
package-id: package-id,
mirror: bool,
}
record install-package-request {
package-id: package-id,
metadata: option<onchain-metadata>, // if None == local sideload package.
version-hash: string,
}
enum new-package-response {
success,
no-blob,
@ -110,37 +229,14 @@ interface main {
already-exists,
}
variant download-response {
started,
bad-response,
denied(reason),
already-exists,
already-downloading,
}
enum install-response {
success,
failure, // TODO
failure,
}
enum uninstall-response {
success,
failure, // TODO
}
enum mirror-response {
success,
failure, // TODO
}
enum auto-update-response {
success,
failure, // TODO
}
enum rebuild-index-response {
success,
failure, // TODO
failure,
}
record apis-response {
@ -150,11 +246,13 @@ interface main {
// the API itself will be in response blob if success!
enum get-api-response {
success,
failure, // TODO
failure,
}
}
world app-store-sys-v0 {
import main;
import downloads;
import chain;
include process-v0;
}

View File

@ -7,11 +7,11 @@ edition = "2021"
simulation-mode = []
[dependencies]
alloy-primitives = "0.7.0"
alloy-sol-types = "0.7.0"
alloy-primitives = "0.7.6"
alloy-sol-types = "0.7.6"
anyhow = "1.0"
bincode = "1.3.3"
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.8.0" }
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.9.0" }
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,11 @@
#![feature(let_chains)]
//! App Store:
//! acts as both a local package manager and a protocol to share packages across the network.
//! packages are apps; apps are packages. we use an onchain app listing contract to determine
//! main:app_store:
//! acts as a manager for installed apps, and coordinator for http requests.
//!
//! the chain:app_store process takes care of on-chain indexing, while
//! the downloads:app_store process takes care of sharing and versioning.
//!
//! packages are apps; apps are packages. chain:app_store uses the kimap contract to determine
//! what apps are available to download and what node(s) to download them from.
//!
//! once we know that list, we can request a package from a node and download it locally.
@ -12,22 +16,19 @@
//! - given permissions (necessary to complete install)
//! - uninstalled + deleted
//! - set to automatically update if a new version is available
use crate::kinode::process::main::{
ApisResponse, AutoUpdateResponse, DownloadRequest, DownloadResponse, GetApiResponse,
HashMismatch, InstallResponse, LocalRequest, LocalResponse, MirrorResponse, NewPackageRequest,
NewPackageResponse, Reason, RebuildIndexResponse, RemoteDownloadRequest, RemoteRequest,
RemoteResponse, UninstallResponse,
use crate::kinode::process::downloads::{
DownloadCompleteRequest, DownloadResponses, ProgressUpdate,
};
use ft_worker_lib::{
spawn_receive_transfer, spawn_transfer, FTWorkerCommand, FTWorkerResult, FileTransferContext,
use crate::kinode::process::main::{
ApisResponse, GetApiResponse, InstallPackageRequest, InstallResponse, LocalRequest,
LocalResponse, NewPackageRequest, NewPackageResponse, UninstallResponse,
};
use kinode_process_lib::{
await_message, call_init, eth, get_blob, http, println, vfs, Address, LazyLoadBlob, Message,
NodeId, PackageId, Request, Response,
await_message, call_init, get_blob, http, print_to_terminal, println, vfs, Address,
LazyLoadBlob, Message, PackageId, Response,
};
use serde::{Deserialize, Serialize};
use state::{AppStoreLogError, PackageState, RequestedPackage, State};
use utils::{fetch_and_subscribe_logs, fetch_state, subscribe_to_logs};
use state::State;
wit_bindgen::generate!({
path: "target/wit",
@ -36,35 +37,11 @@ wit_bindgen::generate!({
additional_derives: [serde::Deserialize, serde::Serialize],
});
mod ft_worker_lib;
mod http_api;
pub mod state;
pub mod utils;
#[cfg(not(feature = "simulation-mode"))]
const CHAIN_ID: u64 = 10; // optimism
#[cfg(feature = "simulation-mode")]
const CHAIN_ID: u64 = 31337; // local
const CHAIN_TIMEOUT: u64 = 60; // 60s
pub const VFS_TIMEOUT: u64 = 5; // 5s
pub const APP_SHARE_TIMEOUT: u64 = 120; // 120s
#[cfg(not(feature = "simulation-mode"))]
const CONTRACT_ADDRESS: &str = "0x52185B6a6017E6f079B994452F234f7C2533787B"; // optimism
#[cfg(feature = "simulation-mode")]
const CONTRACT_ADDRESS: &str = "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318"; // local
#[cfg(not(feature = "simulation-mode"))]
const CONTRACT_FIRST_BLOCK: u64 = 118_590_088;
#[cfg(feature = "simulation-mode")]
const CONTRACT_FIRST_BLOCK: u64 = 1;
const EVENTS: [&str; 3] = [
"AppRegistered(uint256,string,bytes,string,bytes32)",
"AppMetadataUpdated(uint256,string,bytes32)",
"Transfer(address,address,uint256)",
];
const VFS_TIMEOUT: u64 = 10;
// internal types
@ -72,35 +49,26 @@ const EVENTS: [&str; 3] = [
#[serde(untagged)] // untagged as a meta-type for all incoming requests
pub enum Req {
LocalRequest(LocalRequest),
RemoteRequest(RemoteRequest),
FTWorkerCommand(FTWorkerCommand),
FTWorkerResult(FTWorkerResult),
Eth(eth::EthSubResult),
Http(http::HttpServerRequest),
Progress(ProgressUpdate),
DownloadComplete(DownloadCompleteRequest),
Http(http::server::HttpServerRequest),
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)] // untagged as a meta-type for all incoming responses
pub enum Resp {
LocalResponse(LocalResponse),
RemoteResponse(RemoteResponse),
FTWorkerResult(FTWorkerResult),
Download(DownloadResponses),
}
call_init!(init);
fn init(our: Address) {
println!("started");
http_api::init_frontend(&our);
let mut http_server = http::server::HttpServer::new(5);
http_api::init_frontend(&our, &mut http_server);
println!("indexing on contract address {}", CONTRACT_ADDRESS);
// create new provider with request-timeout of 60s
// can change, log requests can take quite a long time.
let eth_provider = eth::Provider::new(CHAIN_ID, CHAIN_TIMEOUT);
let mut state = fetch_state(our, eth_provider);
fetch_and_subscribe_logs(&mut state);
let mut state = State::load().expect("state loading failed");
loop {
match await_message() {
@ -109,7 +77,7 @@ fn init(our: Address) {
println!("got network error: {send_error}");
}
Ok(message) => {
if let Err(e) = handle_message(&mut state, &message) {
if let Err(e) = handle_message(&our, &mut state, &mut http_server, &message) {
println!("error handling message: {:?}", e);
}
}
@ -121,14 +89,19 @@ fn init(our: Address) {
/// function defined for each kind of message. check whether the source
/// of the message is allowed to send that kind of message to us.
/// finally, fire a response if expected from a request.
fn handle_message(state: &mut State, message: &Message) -> anyhow::Result<()> {
fn handle_message(
our: &Address,
state: &mut State,
http_server: &mut http::server::HttpServer,
message: &Message,
) -> anyhow::Result<()> {
if message.is_request() {
match serde_json::from_slice::<Req>(message.body())? {
Req::LocalRequest(local_request) => {
if !message.is_local(&state.our) {
return Err(anyhow::anyhow!("local request from non-local node"));
if !message.is_local(our) {
return Err(anyhow::anyhow!("request from non-local node"));
}
let (body, blob) = handle_local_request(state, local_request);
let (body, blob) = handle_local_request(our, state, local_request);
let response = Response::new().body(serde_json::to_vec(&body)?);
if let Some(blob) = blob {
response.blob(blob).send()?;
@ -136,117 +109,122 @@ fn handle_message(state: &mut State, message: &Message) -> anyhow::Result<()> {
response.send()?;
}
}
Req::RemoteRequest(remote_request) => {
let resp = handle_remote_request(state, message.source(), remote_request);
Response::new().body(serde_json::to_vec(&resp)?).send()?;
}
Req::FTWorkerResult(FTWorkerResult::ReceiveSuccess(name)) => {
handle_receive_download(state, &name)?;
}
Req::FTWorkerCommand(_) => {
spawn_receive_transfer(&state.our, message.body())?;
}
Req::FTWorkerResult(r) => {
println!("got weird ft_worker result: {r:?}");
}
Req::Eth(eth_result) => {
if !message.is_local(&state.our) || message.source().process != "eth:distro:sys" {
return Err(anyhow::anyhow!(
"eth sub event from weird addr: {}",
message.source()
));
}
if let Ok(eth::EthSub { result, .. }) = eth_result {
handle_eth_sub_event(state, result)?;
} else {
println!("got eth subscription error");
// attempt to resubscribe
subscribe_to_logs(&state.provider, utils::app_store_filter(state));
}
}
Req::Http(incoming) => {
if !message.is_local(&state.our)
|| message.source().process != "http_server:distro:sys"
{
Req::Http(server_request) => {
if !message.is_local(&our) || message.source().process != "http_server:distro:sys" {
return Err(anyhow::anyhow!("http_server from non-local node"));
}
if let http::HttpServerRequest::Http(req) = incoming {
http_api::handle_http_request(state, &req)?;
http_server.handle_request(
server_request,
|incoming| http_api::handle_http_request(our, state, &incoming),
|_channel_id, _message_type, _blob| {
// not expecting any websocket messages from FE currently
},
);
}
Req::Progress(progress) => {
if !message.is_local(&our) {
return Err(anyhow::anyhow!("http_server from non-local node"));
}
http_server.ws_push_all_channels(
"/",
http::server::WsMessageType::Text,
LazyLoadBlob {
mime: Some("application/json".to_string()),
bytes: serde_json::json!({
"kind": "progress",
"data": {
"package_id": progress.package_id,
"version_hash": progress.version_hash,
"downloaded": progress.downloaded,
"total": progress.total,
}
})
.to_string()
.as_bytes()
.to_vec(),
},
);
}
Req::DownloadComplete(req) => {
if !message.is_local(&our) {
return Err(anyhow::anyhow!("download complete from non-local node"));
}
http_server.ws_push_all_channels(
"/",
http::server::WsMessageType::Text,
LazyLoadBlob {
mime: Some("application/json".to_string()),
bytes: serde_json::json!({
"kind": "complete",
"data": {
"package_id": req.package_id,
"version_hash": req.version_hash,
"error": req.error,
}
})
.to_string()
.as_bytes()
.to_vec(),
},
);
// 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.
if let Some(context) = message.context() {
let manifest_hash = String::from_utf8(context.to_vec())?;
if let Some(package) =
state.packages.get(&req.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(
&req.package_id,
None,
&req.version_hash,
state,
&our.node,
) {
print_to_terminal(
1,
&format!("error auto_installing package: {e}"),
);
} else {
println!(
"auto_installed update for package: {:?}",
&req.package_id.to_process_lib()
);
}
} else {
print_to_terminal(1, "auto_install:main, manifest_hash do not match");
}
}
}
}
}
} else {
// the only kind of response we care to handle here!
handle_ft_worker_result(message.body(), message.context().unwrap_or(&vec![]))?;
match serde_json::from_slice::<Resp>(message.body())? {
Resp::LocalResponse(_) => {
// don't need to handle these at the moment
}
_ => {}
}
}
Ok(())
}
/// fielding requests to download packages and APIs from us
fn handle_remote_request(state: &mut State, source: &Address, request: RemoteRequest) -> Resp {
let (package_id, desired_version_hash) = match &request {
RemoteRequest::Download(RemoteDownloadRequest {
package_id,
desired_version_hash,
}) => (package_id, desired_version_hash),
};
let package_id = package_id.to_owned().to_process_lib();
let Some(package_state) = state.get_downloaded_package(&package_id) else {
return Resp::RemoteResponse(RemoteResponse::DownloadDenied(Reason::NoPackage));
};
if !package_state.mirroring {
return Resp::RemoteResponse(RemoteResponse::DownloadDenied(Reason::NotMirroring));
}
if let Some(hash) = desired_version_hash.clone() {
if package_state.our_version != hash {
return Resp::RemoteResponse(RemoteResponse::DownloadDenied(Reason::HashMismatch(
HashMismatch {
requested: hash,
have: package_state.our_version,
},
)));
}
}
let file_name = match &request {
RemoteRequest::Download(_) => {
// the file name of the zipped app
format!("/{}.zip", package_id)
}
};
// get the .zip from VFS and attach as blob to response
let Ok(Ok(_)) = Request::to(("our", "vfs", "distro", "sys"))
.body(
serde_json::to_vec(&vfs::VfsRequest {
path: format!("/{}/pkg{}", package_id, file_name),
action: vfs::VfsAction::Read,
})
.unwrap(),
)
.send_and_await_response(VFS_TIMEOUT)
else {
return Resp::RemoteResponse(RemoteResponse::DownloadDenied(Reason::FileNotFound));
};
// transfer will *inherit* the blob bytes we receive from VFS
match spawn_transfer(&state.our, &file_name, None, APP_SHARE_TIMEOUT, &source) {
Ok(()) => Resp::RemoteResponse(RemoteResponse::DownloadApproved),
Err(_e) => Resp::RemoteResponse(RemoteResponse::DownloadDenied(Reason::WorkerSpawnFailed)),
}
}
/// only `our.node` can call this
fn handle_local_request(
our: &Address,
state: &mut State,
request: LocalRequest,
) -> (LocalResponse, Option<LazyLoadBlob>) {
match request {
LocalRequest::NewPackage(NewPackageRequest {
package_id,
metadata,
mirror,
}) => {
LocalRequest::NewPackage(NewPackageRequest { package_id, mirror }) => {
let Some(blob) = get_blob() else {
return (
LocalResponse::NewPackageResponse(NewPackageResponse::NoBlob),
@ -254,39 +232,26 @@ fn handle_local_request(
);
};
(
match utils::new_package(
&package_id.to_process_lib(),
state,
metadata.to_erc721_metadata(),
mirror,
blob.bytes,
) {
match utils::new_package(package_id, mirror, blob.bytes) {
Ok(()) => LocalResponse::NewPackageResponse(NewPackageResponse::Success),
Err(_) => LocalResponse::NewPackageResponse(NewPackageResponse::InstallFailed),
},
None,
)
}
LocalRequest::Download(DownloadRequest {
LocalRequest::Install(InstallPackageRequest {
package_id,
download_from,
mirror,
auto_update,
desired_version_hash,
metadata,
version_hash,
}) => (
LocalResponse::DownloadResponse(start_download(
state,
package_id.to_process_lib(),
download_from,
mirror,
auto_update,
desired_version_hash,
)),
None,
),
LocalRequest::Install(package_id) => (
match handle_install(state, &package_id.to_process_lib()) {
Ok(()) => LocalResponse::InstallResponse(InstallResponse::Success),
match utils::install(&package_id, metadata, &version_hash, state, &our.node) {
Ok(()) => {
println!(
"successfully installed package: {:?}",
&package_id.to_process_lib()
);
LocalResponse::InstallResponse(InstallResponse::Success)
}
Err(e) => {
println!("error installing package: {e}");
LocalResponse::InstallResponse(InstallResponse::Failure)
@ -295,59 +260,34 @@ fn handle_local_request(
None,
),
LocalRequest::Uninstall(package_id) => (
match state.uninstall(&package_id.to_process_lib()) {
Ok(()) => LocalResponse::UninstallResponse(UninstallResponse::Success),
Err(_) => LocalResponse::UninstallResponse(UninstallResponse::Failure),
match utils::uninstall(state, &package_id.clone().to_process_lib()) {
Ok(()) => {
println!(
"successfully uninstalled package: {:?}",
&package_id.to_process_lib()
);
LocalResponse::UninstallResponse(UninstallResponse::Success)
}
Err(e) => {
println!(
"error uninstalling package: {:?}: {e}",
&package_id.to_process_lib()
);
LocalResponse::UninstallResponse(UninstallResponse::Failure)
}
},
None,
),
LocalRequest::StartMirroring(package_id) => (
match state.start_mirroring(&package_id.to_process_lib()) {
true => LocalResponse::MirrorResponse(MirrorResponse::Success),
false => LocalResponse::MirrorResponse(MirrorResponse::Failure),
},
None,
),
LocalRequest::StopMirroring(package_id) => (
match state.stop_mirroring(&package_id.to_process_lib()) {
true => LocalResponse::MirrorResponse(MirrorResponse::Success),
false => LocalResponse::MirrorResponse(MirrorResponse::Failure),
},
None,
),
LocalRequest::StartAutoUpdate(package_id) => (
match state.start_auto_update(&package_id.to_process_lib()) {
true => LocalResponse::AutoUpdateResponse(AutoUpdateResponse::Success),
false => LocalResponse::AutoUpdateResponse(AutoUpdateResponse::Failure),
},
None,
),
LocalRequest::StopAutoUpdate(package_id) => (
match state.stop_auto_update(&package_id.to_process_lib()) {
true => LocalResponse::AutoUpdateResponse(AutoUpdateResponse::Success),
false => LocalResponse::AutoUpdateResponse(AutoUpdateResponse::Failure),
},
None,
),
LocalRequest::RebuildIndex => (rebuild_index(state), None),
LocalRequest::Apis => (list_apis(state), None),
LocalRequest::GetApi(package_id) => get_api(state, &package_id.to_process_lib()),
}
}
pub fn get_api(state: &mut State, package_id: &PackageId) -> (LocalResponse, Option<LazyLoadBlob>) {
if !state.downloaded_apis.contains(package_id) {
if !state.installed_apis.contains(package_id) {
return (LocalResponse::GetApiResponse(GetApiResponse::Failure), None);
}
let Ok(Ok(_)) = Request::new()
.target(("our", "vfs", "distro", "sys"))
.body(
serde_json::to_vec(&vfs::VfsRequest {
path: format!("/{package_id}/pkg/api.zip"),
action: vfs::VfsAction::Read,
})
.unwrap(),
)
let Ok(Ok(_)) = utils::vfs_request(format!("/{package_id}/pkg/api.zip"), vfs::VfsAction::Read)
.send_and_await_response(VFS_TIMEOUT)
else {
return (LocalResponse::GetApiResponse(GetApiResponse::Failure), None);
@ -367,226 +307,10 @@ pub fn get_api(state: &mut State, package_id: &PackageId) -> (LocalResponse, Opt
pub fn list_apis(state: &mut State) -> LocalResponse {
LocalResponse::ApisResponse(ApisResponse {
apis: state
.downloaded_apis
.installed_apis
.clone()
.into_iter()
.map(|id| crate::kinode::process::main::PackageId::from_process_lib(id))
.collect(),
})
}
pub fn rebuild_index(state: &mut State) -> LocalResponse {
// kill our old subscription and build a new one.
let _ = state.provider.unsubscribe(1);
let eth_provider = eth::Provider::new(CHAIN_ID, CHAIN_TIMEOUT);
*state = State::new(
state.our.clone(),
eth_provider,
state.contract_address.clone(),
)
.expect("state creation failed");
fetch_and_subscribe_logs(state);
LocalResponse::RebuildIndexResponse(RebuildIndexResponse::Success)
}
pub fn start_download(
state: &mut State,
package_id: PackageId,
from: NodeId,
mirror: bool,
auto_update: bool,
desired_version_hash: Option<String>,
) -> DownloadResponse {
let download_request = RemoteDownloadRequest {
package_id: crate::kinode::process::main::PackageId::from_process_lib(package_id.clone()),
desired_version_hash: desired_version_hash.clone(),
};
if let Ok(Ok(Message::Response { body, .. })) =
Request::to((from.as_str(), state.our.process.clone()))
.body(serde_json::to_vec(&RemoteRequest::Download(download_request)).unwrap())
.send_and_await_response(VFS_TIMEOUT)
{
if let Ok(Resp::RemoteResponse(RemoteResponse::DownloadApproved)) =
serde_json::from_slice::<Resp>(&body)
{
let requested = RequestedPackage {
from,
mirror,
auto_update,
desired_version_hash,
};
state.requested_packages.insert(package_id, requested);
return DownloadResponse::Started;
}
}
DownloadResponse::BadResponse
}
fn handle_receive_download(state: &mut State, package_name: &str) -> anyhow::Result<()> {
// remove leading / and .zip from file name to get package ID
let package_name = package_name
.trim_start_matches("/")
.trim_end_matches(".zip");
let Ok(package_id) = package_name.parse::<PackageId>() else {
return Err(anyhow::anyhow!(
"bad package ID from download: {package_name}"
));
};
handle_receive_download_package(state, &package_id)
}
fn handle_receive_download_package(
state: &mut State,
package_id: &PackageId,
) -> anyhow::Result<()> {
println!("successfully received {}", package_id);
// only save the package if we actually requested it
let Some(requested_package) = state.requested_packages.remove(package_id) else {
return Err(anyhow::anyhow!("received unrequested package--rejecting!"));
};
let Some(blob) = get_blob() else {
return Err(anyhow::anyhow!("received download but found no blob"));
};
// check the version hash for this download against requested!
let download_hash = utils::generate_version_hash(&blob.bytes);
let (verified, metadata) = match requested_package.desired_version_hash {
Some(hash) => {
let Some(package_listing) = state.get_listing(package_id) else {
return Err(anyhow::anyhow!(
"downloaded package cannot be found in manager--rejecting download!"
));
};
let Some(metadata) = &package_listing.metadata else {
return Err(anyhow::anyhow!(
"downloaded package has no metadata to check validity against!"
));
};
if download_hash != hash {
return Err(anyhow::anyhow!(
"downloaded package is not desired version--rejecting download! \
download hash: {download_hash}, desired hash: {hash}"
));
} else {
(true, Some(metadata.clone()))
}
}
None => match state.get_listing(package_id) {
None => {
println!("downloaded package cannot be found onchain, proceeding with unverified download");
(true, None)
}
Some(package_listing) => {
if let Some(metadata) = &package_listing.metadata {
let latest_hash = metadata
.properties
.code_hashes
.get(&metadata.properties.current_version);
if Some(&download_hash) != latest_hash {
println!(
"downloaded package is not latest version \
download hash: {download_hash}, latest hash: {latest_hash:?} \
proceeding with unverified download"
);
}
(true, Some(metadata.clone()))
} else {
println!("downloaded package has no metadata to check validity against, proceeding with unverified download");
(true, None)
}
}
},
};
let old_manifest_hash = match state.downloaded_packages.get(package_id) {
Some(package_state) => package_state
.manifest_hash
.clone()
.unwrap_or("OLD".to_string()),
_ => "OLD".to_string(),
};
state.add_downloaded_package(
package_id,
PackageState {
mirrored_from: Some(requested_package.from),
our_version: download_hash,
installed: false,
verified,
caps_approved: false,
manifest_hash: None, // generated in the add fn
mirroring: requested_package.mirror,
auto_update: requested_package.auto_update,
metadata,
},
Some(blob.bytes),
)?;
let new_manifest_hash = match state.downloaded_packages.get(package_id) {
Some(package_state) => package_state
.manifest_hash
.clone()
.unwrap_or("NEW".to_string()),
_ => "NEW".to_string(),
};
// lastly, if auto_update is true, AND the manifest has NOT changed,
// trigger install!
if requested_package.auto_update && old_manifest_hash == new_manifest_hash {
handle_install(state, package_id)?;
}
Ok(())
}
fn handle_ft_worker_result(body: &[u8], context: &[u8]) -> anyhow::Result<()> {
if let Ok(Resp::FTWorkerResult(ft_worker_result)) = serde_json::from_slice::<Resp>(body) {
let context = serde_json::from_slice::<FileTransferContext>(context)?;
if let FTWorkerResult::SendSuccess = ft_worker_result {
println!(
"successfully shared {} in {:.4}s",
context.file_name,
std::time::SystemTime::now()
.duration_since(context.start_time)
.unwrap()
.as_secs_f64(),
);
} else {
return Err(anyhow::anyhow!("failed to share app"));
}
}
Ok(())
}
fn handle_eth_sub_event(
state: &mut State,
event: eth::SubscriptionResult,
) -> Result<(), AppStoreLogError> {
let eth::SubscriptionResult::Log(log) = event else {
return Err(AppStoreLogError::DecodeLogError);
};
state.ingest_contract_event(*log, true)
}
/// the steps to take an existing package on disk and install/start it
/// make sure you have reviewed and approved caps in manifest before calling this
pub fn handle_install(state: &mut State, package_id: &PackageId) -> anyhow::Result<()> {
// wit version will default to the latest if not specified
let metadata = state
.get_downloaded_package(package_id)
.ok_or_else(|| anyhow::anyhow!("package not found in manager"))?
.metadata;
let wit_version = match metadata {
Some(metadata) => metadata.properties.wit_version,
None => Some(0),
};
utils::install(package_id, &state.our.node, wit_version)?;
// finally set the package as installed
state.update_downloaded_package(package_id, |package_state| {
package_state.installed = true;
});
Ok(())
}

View File

@ -1,41 +1,17 @@
use crate::VFS_TIMEOUT;
use crate::{utils, DownloadRequest, LocalRequest};
use alloy_sol_types::{sol, SolEvent};
use kinode_process_lib::kernel_types::Erc721Metadata;
use kinode_process_lib::{
eth, kernel_types as kt, net, println, vfs, Address, Message, NodeId, PackageId, Request,
};
use crate::{utils, VFS_TIMEOUT};
use kinode_process_lib::{kimap, vfs, PackageId};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
sol! {
event AppRegistered(
uint256 indexed package,
string packageName,
bytes publisherName,
string metadataUrl,
bytes32 metadataHash
);
event AppMetadataUpdated(
uint256 indexed package,
string metadataUrl,
bytes32 metadataHash
);
event Transfer(
address indexed from,
address indexed to,
uint256 indexed tokenId
);
}
//
// app store types
// main:app_store types
//
#[derive(Debug, Serialize, Deserialize)]
pub enum AppStoreLogError {
NoBlockNumber,
DecodeLogError,
GetNameError,
DecodeLogError(kimap::DecodeLogError),
PackageHashMismatch,
InvalidPublisherName,
MetadataNotFound,
@ -47,7 +23,8 @@ impl std::fmt::Display for AppStoreLogError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
AppStoreLogError::NoBlockNumber => write!(f, "log with no block number"),
AppStoreLogError::DecodeLogError => write!(f, "error decoding log data"),
AppStoreLogError::GetNameError => write!(f, "no corresponding name for namehash found"),
AppStoreLogError::DecodeLogError(e) => write!(f, "error decoding log data: {e:?}"),
AppStoreLogError::PackageHashMismatch => write!(f, "mismatched package hash"),
AppStoreLogError::InvalidPublisherName => write!(f, "invalid publisher name"),
AppStoreLogError::MetadataNotFound => write!(f, "metadata not found"),
@ -59,527 +36,102 @@ impl std::fmt::Display for AppStoreLogError {
impl std::error::Error for AppStoreLogError {}
pub type PackageHash = String;
/// listing information derived from metadata hash in listing event
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PackageListing {
pub owner: String, // eth address
pub name: String,
pub publisher: NodeId,
pub metadata_url: String,
pub metadata_hash: String,
pub metadata: Option<kt::Erc721Metadata>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RequestedPackage {
pub from: NodeId,
pub mirror: bool,
pub auto_update: bool,
// if none, we're requesting the latest version onchain
pub desired_version_hash: Option<String>,
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MirrorCheck {
pub node: String,
pub is_online: bool,
pub error: Option<String>,
}
/// state of an individual package we have downloaded
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PackageState {
/// the node we last downloaded the package from
/// this is "us" if we don't know the source (usually cause it's a local install)
pub mirrored_from: Option<NodeId>,
/// the version of the package we have downloaded
pub our_version: String,
pub installed: bool,
/// the version of the package we have installed
pub our_version_hash: String,
pub verified: bool,
pub caps_approved: bool,
/// the hash of the manifest file, which is used to determine whether package
/// the hash of the manifest, which is used to determine whether package
/// capabilities have changed. if they have changed, auto-install must fail
/// and the user must approve the new capabilities.
pub manifest_hash: Option<String>,
/// are we serving this package to others?
pub mirroring: bool,
/// if we get a listing data update, will we try to download it?
pub auto_update: bool,
pub metadata: Option<kt::Erc721Metadata>,
}
/// this process's saved state
pub struct State {
/// our address, grabbed from init()
pub our: Address,
/// the eth provider we are using -- not persisted
pub provider: eth::Provider,
/// the address of the contract we are using to read package listings
pub contract_address: String,
/// the last block at which we saved the state of the listings to disk.
/// when we boot, we can read logs starting from this block and
/// rebuild latest state.
pub last_saved_block: u64,
pub package_hashes: HashMap<PackageId, PackageHash>,
/// we keep the full state of the package manager here, calculated from
/// the listings contract logs. in the future, we'll offload this and
/// only track a certain number of packages...
pub listed_packages: HashMap<PackageHash, PackageListing>,
/// we keep the full state of the packages we have downloaded here.
/// in order to keep this synchronized with our filesystem, we will
/// ingest apps on disk if we have to rebuild our state. this is also
/// updated every time we download, create, or uninstall a package.
pub downloaded_packages: HashMap<PackageId, PackageState>,
/// packages we have installed
pub packages: HashMap<PackageId, PackageState>,
/// the APIs we have
pub downloaded_apis: HashSet<PackageId>,
/// the packages we have outstanding requests to download (not persisted)
pub requested_packages: HashMap<PackageId, RequestedPackage>,
/// the APIs we have outstanding requests to download (not persisted)
pub requested_apis: HashMap<PackageId, RequestedPackage>,
}
#[derive(Deserialize)]
pub struct SerializedState {
pub contract_address: String,
pub last_saved_block: u64,
pub package_hashes: HashMap<PackageId, PackageHash>,
pub listed_packages: HashMap<PackageHash, PackageListing>,
pub downloaded_packages: HashMap<PackageId, PackageState>,
pub downloaded_apis: HashSet<PackageId>,
}
impl Serialize for State {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("State", 6)?;
state.serialize_field("contract_address", &self.contract_address)?;
state.serialize_field("last_saved_block", &self.last_saved_block)?;
state.serialize_field("package_hashes", &self.package_hashes)?;
state.serialize_field("listed_packages", &self.listed_packages)?;
state.serialize_field("downloaded_packages", &self.downloaded_packages)?;
state.serialize_field("downloaded_apis", &self.downloaded_apis)?;
state.end()
}
pub installed_apis: HashSet<PackageId>,
}
impl State {
pub fn from_serialized(our: Address, provider: eth::Provider, s: SerializedState) -> Self {
State {
our,
provider,
contract_address: s.contract_address,
last_saved_block: s.last_saved_block,
package_hashes: s.package_hashes,
listed_packages: s.listed_packages,
downloaded_packages: s.downloaded_packages,
downloaded_apis: s.downloaded_apis,
requested_packages: HashMap::new(),
requested_apis: HashMap::new(),
}
}
/// To create a new state, we populate the downloaded_packages map
/// To load state, we populate the downloaded_packages map
/// with all packages parseable from our filesystem.
pub fn new(
our: Address,
provider: eth::Provider,
contract_address: String,
) -> anyhow::Result<Self> {
pub fn load() -> anyhow::Result<Self> {
let mut state = State {
our,
provider,
contract_address,
last_saved_block: crate::CONTRACT_FIRST_BLOCK,
package_hashes: HashMap::new(),
listed_packages: HashMap::new(),
downloaded_packages: HashMap::new(),
downloaded_apis: HashSet::new(),
requested_packages: HashMap::new(),
requested_apis: HashMap::new(),
packages: HashMap::new(),
installed_apis: HashSet::new(),
};
state.populate_packages_from_filesystem()?;
Ok(state)
}
pub fn get_listing(&self, package_id: &PackageId) -> Option<&PackageListing> {
self.listed_packages
.get(self.package_hashes.get(package_id)?)
}
fn get_listing_with_hash_mut(
&mut self,
package_hash: &PackageHash,
) -> Option<&mut PackageListing> {
self.listed_packages.get_mut(package_hash)
}
pub fn get_downloaded_package(&self, package_id: &PackageId) -> Option<PackageState> {
self.downloaded_packages.get(package_id).cloned()
}
pub fn add_downloaded_package(
&mut self,
package_id: &PackageId,
mut package_state: PackageState,
package_bytes: Option<Vec<u8>>,
) -> anyhow::Result<()> {
if let Some(package_bytes) = package_bytes {
let manifest_hash = utils::create_package_drive(package_id, package_bytes)?;
package_state.manifest_hash = Some(manifest_hash);
}
if utils::extract_api(package_id)? {
self.downloaded_apis.insert(package_id.to_owned());
}
self.downloaded_packages
.insert(package_id.to_owned(), package_state);
kinode_process_lib::set_state(&serde_json::to_vec(self)?);
Ok(())
}
/// returns True if the package was found and updated, False otherwise
pub fn update_downloaded_package(
&mut self,
package_id: &PackageId,
fn_: impl FnOnce(&mut PackageState),
) -> bool {
let res = self
.downloaded_packages
.get_mut(package_id)
.map(|package_state| {
fn_(package_state);
true
})
.unwrap_or(false);
kinode_process_lib::set_state(&serde_json::to_vec(self).unwrap());
res
}
pub fn start_mirroring(&mut self, package_id: &PackageId) -> bool {
self.update_downloaded_package(package_id, |package_state| {
package_state.mirroring = true;
})
}
pub fn stop_mirroring(&mut self, package_id: &PackageId) -> bool {
self.update_downloaded_package(package_id, |package_state| {
package_state.mirroring = false;
})
}
pub fn start_auto_update(&mut self, package_id: &PackageId) -> bool {
self.update_downloaded_package(package_id, |package_state| {
package_state.auto_update = true;
})
}
pub fn stop_auto_update(&mut self, package_id: &PackageId) -> bool {
self.update_downloaded_package(package_id, |package_state| {
package_state.auto_update = false;
})
}
/// saves state
pub fn populate_packages_from_filesystem(&mut self) -> anyhow::Result<()> {
let Message::Response { body, .. } = Request::to(("our", "vfs", "distro", "sys"))
.body(serde_json::to_vec(&vfs::VfsRequest {
path: "/".to_string(),
action: vfs::VfsAction::ReadDir,
})?)
.send_and_await_response(VFS_TIMEOUT)??
// call VFS and ask for all directories in our root drive
// (we have root VFS capability so this is allowed)
// we will interpret any that are package dirs and ingest them
let vfs::VfsResponse::ReadDir(entries) = serde_json::from_slice::<vfs::VfsResponse>(
utils::vfs_request("/", vfs::VfsAction::ReadDir)
.send_and_await_response(VFS_TIMEOUT)??
.body(),
)?
else {
return Err(anyhow::anyhow!("vfs: bad response"));
};
let response = serde_json::from_slice::<vfs::VfsResponse>(&body)?;
let vfs::VfsResponse::ReadDir(entries) = response else {
return Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response));
return Err(anyhow::anyhow!("vfs: unexpected response to ReadDir"));
};
for entry in entries {
// ignore non-dirs
if entry.file_type != vfs::FileType::Directory {
continue;
}
// ignore non-package dirs
let Ok(package_id) = entry.path.parse::<PackageId>() else {
continue;
};
if entry.file_type == vfs::FileType::Directory {
let zip_file = vfs::File {
path: format!("/{}/pkg/{}.zip", package_id, package_id),
timeout: 5,
};
let Ok(zip_file_bytes) = zip_file.read() else {
continue;
};
// generate entry from this data
// for the version hash, take the SHA-256 hash of the zip file
let our_version = utils::generate_version_hash(&zip_file_bytes);
let manifest_file = vfs::File {
path: format!("/{}/pkg/manifest.json", package_id),
timeout: 5,
};
let manifest_bytes = manifest_file.read()?;
// the user will need to turn mirroring and auto-update back on if they
// have to reset the state of their app store for some reason. the apps
// themselves will remain on disk unless explicitly deleted.
self.add_downloaded_package(
&package_id,
PackageState {
mirrored_from: None,
our_version,
installed: true,
verified: true, // implicitly verified (TODO re-evaluate)
caps_approved: false, // must re-approve if you want to do something
manifest_hash: Some(utils::generate_metadata_hash(&manifest_bytes)),
mirroring: false,
auto_update: false,
metadata: None,
},
None,
)?;
if let Ok(Ok(_)) = Request::new()
.target(("our", "vfs", "distro", "sys"))
.body(
serde_json::to_vec(&vfs::VfsRequest {
path: format!("/{package_id}/pkg/api"),
action: vfs::VfsAction::Metadata,
})
.unwrap(),
)
.send_and_await_response(VFS_TIMEOUT)
{
self.downloaded_apis.insert(package_id.to_owned());
}
}
}
Ok(())
}
pub fn uninstall(&mut self, package_id: &PackageId) -> anyhow::Result<()> {
utils::uninstall(package_id)?;
self.downloaded_packages.remove(package_id);
kinode_process_lib::set_state(&serde_json::to_vec(self)?);
println!("uninstalled {package_id}");
Ok(())
}
/// saves state
///
/// only saves the onchain data in our package listings --
/// in order to fetch metadata and trigger auto-update for all packages,
/// call [`State::update_listings`], or call this with `true` as the third argument.
pub fn ingest_contract_event(
&mut self,
log: eth::Log,
update_listings: bool,
) -> Result<(), AppStoreLogError> {
let block_number: u64 = log.block_number.ok_or(AppStoreLogError::NoBlockNumber)?;
match log.topics()[0] {
AppRegistered::SIGNATURE_HASH => {
let app = AppRegistered::decode_log_data(log.data(), false)
.map_err(|_| AppStoreLogError::DecodeLogError)?;
let package_name = app.packageName;
let publisher_dnswire = app.publisherName;
let metadata_url = app.metadataUrl;
let metadata_hash = app.metadataHash;
let package_hash = log.topics()[1].to_string();
let metadata_hash = metadata_hash.to_string();
kinode_process_lib::print_to_terminal(
1,
&format!("new package {package_name} registered onchain"),
);
if utils::generate_package_hash(&package_name, &publisher_dnswire) != package_hash {
return Err(AppStoreLogError::PackageHashMismatch);
}
let Ok(publisher_name) = net::dnswire_decode(&publisher_dnswire) else {
return Err(AppStoreLogError::InvalidPublisherName);
};
let metadata = if update_listings {
let metadata =
utils::fetch_metadata_from_url(&metadata_url, &metadata_hash, 5)?;
if metadata.properties.publisher != publisher_name {
return Err(AppStoreLogError::PublisherNameMismatch);
}
Some(metadata)
} else {
None
};
self.package_hashes.insert(
PackageId::new(&package_name, &publisher_name),
package_hash.clone(),
);
match self.listed_packages.entry(package_hash) {
std::collections::hash_map::Entry::Occupied(mut listing) => {
let listing = listing.get_mut();
listing.name = package_name;
listing.publisher = publisher_name;
listing.metadata_url = metadata_url;
listing.metadata_hash = metadata_hash;
listing.metadata = metadata;
}
std::collections::hash_map::Entry::Vacant(listing) => {
listing.insert(PackageListing {
owner: "".to_string(),
name: package_name,
publisher: publisher_name,
metadata_url,
metadata_hash,
metadata,
});
}
};
}
AppMetadataUpdated::SIGNATURE_HASH => {
let upd = AppMetadataUpdated::decode_log_data(log.data(), false)
.map_err(|_| AppStoreLogError::DecodeLogError)?;
let metadata_url = upd.metadataUrl;
let metadata_hash = upd.metadataHash;
let package_hash = log.topics()[1].to_string();
let metadata_hash = metadata_hash.to_string();
let Some(current_listing) =
self.get_listing_with_hash_mut(&package_hash.to_string())
else {
// package not found, so we can't update it
// this will never happen if we're ingesting logs in order
return Ok(());
};
let metadata = if update_listings {
Some(utils::fetch_metadata_from_url(
&metadata_url,
&metadata_hash,
5,
)?)
} else {
None
};
current_listing.metadata_url = metadata_url;
current_listing.metadata_hash = metadata_hash;
if update_listings {
current_listing.metadata = metadata.clone();
let package_id =
PackageId::new(&current_listing.name, &current_listing.publisher);
if let Some(package_state) = self.downloaded_packages.get(&package_id) {
auto_update(&self.our, package_id, &metadata.unwrap(), &package_state);
}
} else {
current_listing.metadata = metadata;
}
}
Transfer::SIGNATURE_HASH => {
let from = alloy_primitives::Address::from_word(log.topics()[1]);
let to = alloy_primitives::Address::from_word(log.topics()[2]);
let package_hash = log.topics()[3].to_string();
if from == alloy_primitives::Address::ZERO {
// this is a new package, set the owner
match self.listed_packages.entry(package_hash) {
std::collections::hash_map::Entry::Occupied(mut listing) => {
let listing = listing.get_mut();
listing.owner = to.to_string();
}
std::collections::hash_map::Entry::Vacant(listing) => {
listing.insert(PackageListing {
owner: to.to_string(),
name: "".to_string(),
publisher: "".to_string(),
metadata_url: "".to_string(),
metadata_hash: "".to_string(),
metadata: None,
});
}
};
} else if to == alloy_primitives::Address::ZERO {
// this is a package deletion
if let Some(old) = self.listed_packages.remove(&package_hash) {
self.package_hashes
.remove(&PackageId::new(&old.name, &old.publisher));
}
} else {
let Some(listing) = self.get_listing_with_hash_mut(&package_hash) else {
// package not found, so we can't update it
// this will never happen if we're ingesting logs in order
return Ok(());
};
listing.owner = to.to_string();
}
}
_ => {}
}
self.last_saved_block = block_number;
if update_listings {
kinode_process_lib::set_state(&serde_json::to_vec(self).unwrap());
}
Ok(())
}
/// iterate through all package listings and try to fetch metadata.
/// this is done after ingesting a bunch of logs to remove fetches
/// of stale metadata.
pub fn update_listings(&mut self) {
for (_package_hash, listing) in self.listed_packages.iter_mut() {
if listing.metadata.is_none() {
if let Ok(metadata) =
utils::fetch_metadata_from_url(&listing.metadata_url, &listing.metadata_hash, 5)
{
let package_id = PackageId::new(&listing.name, &listing.publisher);
if let Some(package_state) = self.downloaded_packages.get(&package_id) {
auto_update(&self.our, package_id, &metadata, &package_state);
}
listing.metadata = Some(metadata);
}
}
}
kinode_process_lib::set_state(&serde_json::to_vec(self).unwrap());
}
}
/// if we have this app installed, and we have auto_update set to true,
/// we should try to download new version from the mirrored_from node
/// and install it if successful.
fn auto_update(
our: &Address,
package_id: PackageId,
metadata: &Erc721Metadata,
package_state: &PackageState,
) {
if package_state.auto_update {
let latest_version_hash = metadata
.properties
.code_hashes
.get(&metadata.properties.current_version);
if let Some(mirrored_from) = &package_state.mirrored_from
&& Some(&package_state.our_version) != latest_version_hash
{
println!(
"auto-updating package {package_id} from {} to {} using mirror {mirrored_from}",
metadata
.properties
.code_hashes
.get(&package_state.our_version)
.unwrap_or(&package_state.our_version),
metadata.properties.current_version,
// grab package .zip if it exists
let zip_file = vfs::File {
path: format!("/{package_id}/pkg/{package_id}.zip"),
timeout: 5,
};
let Ok(zip_file_bytes) = zip_file.read() else {
continue;
};
// generate entry from this data
// for the version hash, take the SHA-256 hash of the zip file
let our_version_hash = utils::sha_256_hash(&zip_file_bytes);
let manifest_file = vfs::File {
path: format!("/{package_id}/pkg/manifest.json"),
timeout: 5,
};
let manifest_bytes = manifest_file.read()?;
let manifest_hash = utils::keccak_256_hash(&manifest_bytes);
self.packages.insert(
package_id.clone(),
PackageState {
our_version_hash,
verified: true, // implicitly verified (TODO re-evaluate)
caps_approved: false, // must re-approve if you want to do something ??
manifest_hash: Some(manifest_hash),
},
);
Request::to(our)
.body(
serde_json::to_vec(&LocalRequest::Download(DownloadRequest {
package_id: crate::kinode::process::main::PackageId::from_process_lib(
package_id,
),
download_from: mirrored_from.clone(),
mirror: package_state.mirroring,
auto_update: package_state.auto_update,
desired_version_hash: None,
}))
.unwrap(),
)
.send()
.unwrap();
if let Ok(Ok(_)) =
utils::vfs_request(format!("/{package_id}/pkg/api"), vfs::VfsAction::Metadata)
.send_and_await_response(VFS_TIMEOUT)
{
self.installed_apis.insert(package_id);
}
}
Ok(())
}
}

View File

@ -1,13 +1,17 @@
use {
crate::kinode::process::main::OnchainMetadata,
crate::state::{AppStoreLogError, PackageState, SerializedState, State},
crate::{CONTRACT_ADDRESS, EVENTS, VFS_TIMEOUT},
kinode_process_lib::{
eth, get_blob, get_state, http, kernel_types as kt, println, vfs, Address, LazyLoadBlob,
PackageId, ProcessId, Request,
crate::{
kinode::process::{
chain::{ChainRequests, ChainResponses, OnchainMetadata},
downloads::{AddDownloadRequest, DownloadRequests, DownloadResponses},
},
state::{PackageState, State},
VFS_TIMEOUT,
},
std::collections::HashSet,
std::str::FromStr,
kinode_process_lib::{
get_blob, kernel_types as kt, println, vfs, Address, LazyLoadBlob, PackageId, ProcessId,
Request,
},
std::collections::{HashMap, HashSet},
};
// quite annoyingly, we must convert from our gen'd version of PackageId
@ -28,158 +32,32 @@ impl crate::kinode::process::main::PackageId {
}
}
// less annoying but still bad
impl OnchainMetadata {
pub fn to_erc721_metadata(self) -> kt::Erc721Metadata {
use kt::Erc721Properties;
kt::Erc721Metadata {
name: self.name,
description: self.description,
image: self.image,
external_url: self.external_url,
animation_url: self.animation_url,
properties: Erc721Properties {
package_name: self.properties.package_name,
publisher: self.properties.publisher,
current_version: self.properties.current_version,
mirrors: self.properties.mirrors,
code_hashes: self.properties.code_hashes.into_iter().collect(),
license: self.properties.license,
screenshots: self.properties.screenshots,
wit_version: self.properties.wit_version,
dependencies: self.properties.dependencies,
},
}
}
}
/// fetch state from disk or create a new one if that fails
pub fn fetch_state(our: Address, provider: eth::Provider) -> State {
if let Some(state_bytes) = get_state() {
match serde_json::from_slice::<SerializedState>(&state_bytes) {
Ok(state) => {
if state.contract_address == CONTRACT_ADDRESS {
return State::from_serialized(our, provider, state);
} else {
println!(
"state contract address mismatch! expected {}, got {}",
CONTRACT_ADDRESS, state.contract_address
);
}
}
Err(e) => println!("failed to deserialize saved state: {e}"),
}
}
State::new(our, provider, CONTRACT_ADDRESS.to_string()).expect("state creation failed")
}
pub fn app_store_filter(state: &State) -> eth::Filter {
eth::Filter::new()
.address(eth::Address::from_str(&state.contract_address).unwrap())
.from_block(state.last_saved_block)
.events(EVENTS)
}
/// create a filter to fetch app store event logs from chain and subscribe to new events
pub fn fetch_and_subscribe_logs(state: &mut State) {
let filter = app_store_filter(state);
// get past logs, subscribe to new ones.
for log in fetch_logs(&state.provider, &filter) {
if let Err(e) = state.ingest_contract_event(log, false) {
println!("error ingesting log: {e:?}");
};
}
state.update_listings();
subscribe_to_logs(&state.provider, filter);
}
/// subscribe to logs from the chain with a given filter
pub fn subscribe_to_logs(eth_provider: &eth::Provider, filter: eth::Filter) {
loop {
match eth_provider.subscribe(1, filter.clone()) {
Ok(()) => break,
Err(_) => {
println!("failed to subscribe to chain! trying again in 5s...");
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
}
}
println!("subscribed to logs successfully");
}
/// fetch logs from the chain with a given filter
fn fetch_logs(eth_provider: &eth::Provider, filter: &eth::Filter) -> Vec<eth::Log> {
loop {
match eth_provider.get_logs(filter) {
Ok(res) => return res,
Err(_) => {
println!("failed to fetch logs! trying again in 5s...");
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
}
}
}
/// fetch metadata from url and verify it matches metadata_hash
pub fn fetch_metadata_from_url(
metadata_url: &str,
metadata_hash: &str,
timeout: u64,
) -> Result<kt::Erc721Metadata, AppStoreLogError> {
if let Ok(url) = url::Url::parse(metadata_url) {
if let Ok(_) =
http::send_request_await_response(http::Method::GET, url, None, timeout, vec![])
{
if let Some(body) = get_blob() {
let hash = generate_metadata_hash(&body.bytes);
if &hash == metadata_hash {
return Ok(serde_json::from_slice::<kt::Erc721Metadata>(&body.bytes)
.map_err(|_| AppStoreLogError::MetadataNotFound)?);
} else {
return Err(AppStoreLogError::MetadataHashMismatch);
}
}
}
}
Err(AppStoreLogError::MetadataNotFound)
}
/// generate a Keccak-256 hash of the metadata bytes
pub fn generate_metadata_hash(metadata: &[u8]) -> String {
/// generate a Keccak-256 hash string (with 0x prefix) of the metadata bytes
pub fn keccak_256_hash(bytes: &[u8]) -> String {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(metadata);
hasher.update(bytes);
format!("0x{:x}", hasher.finalize())
}
/// generate a Keccak-256 hash of the package name and publisher (match onchain)
pub fn generate_package_hash(name: &str, publisher_dnswire: &[u8]) -> String {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update([name.as_bytes(), publisher_dnswire].concat());
let hash = hasher.finalize();
format!("0x{:x}", hash)
}
/// generate a SHA-256 hash of the zip bytes to act as a version hash
pub fn generate_version_hash(zip_bytes: &[u8]) -> String {
pub fn sha_256_hash(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(zip_bytes);
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
/// note: this can only be called in the install process,
/// manifest.json for an arbitrary download can be found with GetFiles
pub fn fetch_package_manifest(
package_id: &PackageId,
) -> anyhow::Result<Vec<kt::PackageManifestEntry>> {
Request::to(("our", "vfs", "distro", "sys"))
.body(serde_json::to_vec(&vfs::VfsRequest {
path: format!("/{package_id}/pkg/manifest.json"),
action: vfs::VfsAction::Read,
})?)
.send_and_await_response(VFS_TIMEOUT)??;
vfs_request(
format!("/{package_id}/pkg/manifest.json"),
vfs::VfsAction::Read,
)
.send_and_await_response(VFS_TIMEOUT)??;
let Some(blob) = get_blob() else {
return Err(anyhow::anyhow!("no blob"));
};
@ -188,52 +66,66 @@ pub fn fetch_package_manifest(
)?)
}
pub fn fetch_package_metadata(
package_id: &crate::kinode::process::main::PackageId,
) -> anyhow::Result<OnchainMetadata> {
let resp = Request::to(("our", "chain", "app_store", "sys"))
.body(serde_json::to_vec(&ChainRequests::GetApp(package_id.clone())).unwrap())
.send_and_await_response(5)??;
let resp = serde_json::from_slice::<ChainResponses>(&resp.body())?;
let app = match resp {
ChainResponses::GetApp(Some(app)) => app,
_ => {
return Err(anyhow::anyhow!(
"No app data found in response from chain:app_store:sys"
))
}
};
let metadata = match app.metadata {
Some(metadata) => metadata,
None => {
return Err(anyhow::anyhow!(
"No metadata found in response from chain:app_store:sys"
))
}
};
Ok(metadata)
}
pub fn new_package(
package_id: &PackageId,
state: &mut State,
metadata: kt::Erc721Metadata,
package_id: crate::kinode::process::main::PackageId,
mirror: bool,
bytes: Vec<u8>,
) -> anyhow::Result<()> {
// set the version hash for this new local package
let our_version = generate_version_hash(&bytes);
let version_hash = sha_256_hash(&bytes);
let package_state = PackageState {
mirrored_from: Some(state.our.node.clone()),
our_version,
installed: false,
verified: true, // side loaded 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: None, // generated in the add fn
mirroring: mirror,
auto_update: false, // can't auto-update a local package
metadata: Some(metadata),
};
let Ok(()) = state.add_downloaded_package(&package_id, package_state, Some(bytes)) else {
return Err(anyhow::anyhow!("failed to add package"));
};
let resp = Request::to(("our", "downloads", "app_store", "sys"))
.body(serde_json::to_vec(&DownloadRequests::AddDownload(
AddDownloadRequest {
package_id: package_id.clone(),
version_hash: version_hash.clone(),
mirror,
},
))?)
.blob_bytes(bytes)
.send_and_await_response(5)??;
let drive_path = format!("/{package_id}/pkg");
let result = Request::new()
.target(("our", "vfs", "distro", "sys"))
.body(
serde_json::to_vec(&vfs::VfsRequest {
path: format!("{}/api", drive_path),
action: vfs::VfsAction::Metadata,
})
.unwrap(),
)
.send_and_await_response(VFS_TIMEOUT);
if let Ok(Ok(_)) = result {
state.downloaded_apis.insert(package_id.to_owned());
};
let download_resp = serde_json::from_slice::<DownloadResponses>(&resp.body())?;
match download_resp {
DownloadResponses::Error(e) => {
return Err(anyhow::anyhow!("failed to add download: {:?}", e));
}
_ => {}
}
Ok(())
}
/// create a new package drive in VFS and add the package zip to it.
/// if an `api.zip` is present, unzip and stow in `/api`.
/// returns a string representing the manifest hash of the package
/// and a bool returning whether or not an api was found and unzipped.
/// returns a string representing the manfifest hash.
pub fn create_package_drive(
package_id: &PackageId,
package_bytes: Vec<u8>,
@ -246,44 +138,32 @@ pub fn create_package_drive(
// create a new drive for this package in VFS
// this is possible because we have root access
Request::to(("our", "vfs", "distro", "sys"))
.body(serde_json::to_vec(&vfs::VfsRequest {
path: drive_name.clone(),
action: vfs::VfsAction::CreateDrive,
})?)
vfs_request(drive_name.clone(), vfs::VfsAction::CreateDrive)
.send_and_await_response(VFS_TIMEOUT)??;
// DELETE the /pkg folder in the package drive
// in order to replace with the fresh one
Request::to(("our", "vfs", "distro", "sys"))
.body(serde_json::to_vec(&vfs::VfsRequest {
path: drive_name.clone(),
action: vfs::VfsAction::RemoveDirAll,
})?)
vfs_request(drive_name.clone(), vfs::VfsAction::RemoveDirAll)
.send_and_await_response(VFS_TIMEOUT)??;
// convert the zip to a new package drive
let response = Request::to(("our", "vfs", "distro", "sys"))
.body(serde_json::to_vec(&vfs::VfsRequest {
path: drive_name.clone(),
action: vfs::VfsAction::AddZip,
})?)
.blob(blob.clone())
.send_and_await_response(VFS_TIMEOUT)??;
let vfs::VfsResponse::Ok = serde_json::from_slice::<vfs::VfsResponse>(response.body())? else {
let vfs::VfsResponse::Ok = serde_json::from_slice::<vfs::VfsResponse>(
vfs_request(drive_name.clone(), vfs::VfsAction::AddZip)
.blob(blob.clone())
.send_and_await_response(VFS_TIMEOUT)??
.body(),
)?
else {
return Err(anyhow::anyhow!(
"cannot add NewPackage: do not have capability to access vfs"
));
};
// be careful, this is technically a duplicate.. but..
// save the zip file itself in VFS for sharing with other nodes
// call it <package_id>.zip
let zip_path = format!("{}/{}.zip", drive_name, package_id);
Request::to(("our", "vfs", "distro", "sys"))
.body(serde_json::to_vec(&vfs::VfsRequest {
path: zip_path,
action: vfs::VfsAction::Write,
})?)
vfs_request(zip_path, vfs::VfsAction::Write)
.blob(blob)
.send_and_await_response(VFS_TIMEOUT)??;
@ -292,35 +172,34 @@ pub fn create_package_drive(
timeout: VFS_TIMEOUT,
};
let manifest_bytes = manifest_file.read()?;
Ok(generate_metadata_hash(&manifest_bytes))
let manifest_hash = keccak_256_hash(&manifest_bytes);
Ok(manifest_hash)
}
pub fn extract_api(package_id: &PackageId) -> anyhow::Result<bool> {
// get `pkg/api.zip` if it exists
let api_response = Request::to(("our", "vfs", "distro", "sys"))
.body(serde_json::to_vec(&vfs::VfsRequest {
path: format!("/{package_id}/pkg/api.zip"),
action: vfs::VfsAction::Read,
})?)
.send_and_await_response(VFS_TIMEOUT)??;
if let Ok(vfs::VfsResponse::Read) = serde_json::from_slice(api_response.body()) {
if let vfs::VfsResponse::Read = serde_json::from_slice(
vfs_request(format!("/{package_id}/pkg/api.zip"), vfs::VfsAction::Read)
.send_and_await_response(VFS_TIMEOUT)??
.body(),
)? {
// unzip api.zip into /api
// blob inherited from Read request
let response = Request::to(("our", "vfs", "distro", "sys"))
.body(serde_json::to_vec(&vfs::VfsRequest {
path: format!("/{package_id}/pkg/api"),
action: vfs::VfsAction::AddZip,
})?)
.inherit(true)
.send_and_await_response(VFS_TIMEOUT)??;
if let Ok(vfs::VfsResponse::Ok) = serde_json::from_slice(response.body()) {
if let vfs::VfsResponse::Ok = serde_json::from_slice(
vfs_request(format!("/{package_id}/pkg/api"), vfs::VfsAction::AddZip)
.inherit(true)
.send_and_await_response(VFS_TIMEOUT)??
.body(),
)? {
return Ok(true);
}
}
Ok(false)
}
/// given a package id, interact with VFS and kernel to get manifest,
/// given a `PackageId`, interact with VFS and kernel to get {package_hash}.zip,
/// unzip the manifest and pkg,
/// grant the capabilities in manifest, then initialize and start
/// the processes in manifest.
///
@ -329,13 +208,49 @@ pub fn extract_api(package_id: &PackageId) -> anyhow::Result<bool> {
/// note also that each capability will only be granted if we, the process
/// using this function, own that capability ourselves.
pub fn install(
package_id: &PackageId,
package_id: &crate::kinode::process::main::PackageId,
metadata: Option<OnchainMetadata>,
version_hash: &str,
state: &mut State,
our_node: &str,
wit_version: Option<u32>,
) -> anyhow::Result<()> {
let process_package_id = package_id.clone().to_process_lib();
let file = vfs::open_file(
&format!("/app_store:sys/downloads/{process_package_id}/{version_hash}.zip"),
false,
Some(VFS_TIMEOUT),
)?;
let bytes = file.read()?;
let manifest_hash = create_package_drive(&process_package_id, bytes)?;
let package_state = PackageState {
our_version_hash: version_hash.to_string(),
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),
};
if let Ok(extracted) = extract_api(&process_package_id) {
if extracted {
state.installed_apis.insert(process_package_id.clone());
}
}
state
.packages
.insert(process_package_id.clone(), package_state);
// get the package manifest
let drive_path = format!("/{package_id}/pkg");
let manifest = fetch_package_manifest(package_id)?;
let drive_path = format!("/{process_package_id}/pkg");
let manifest = fetch_package_manifest(&process_package_id)?;
// get wit version from metadata if local or chain if remote.
let metadata = if let Some(metadata) = metadata {
metadata
} else {
fetch_package_metadata(&package_id)?
};
let wit_version = metadata.properties.wit_version;
// first, for each process in manifest, initialize it
// then, once all have been initialized, grant them requested caps
@ -347,90 +262,43 @@ pub fn install(
format!("/{}", entry.process_wasm_path)
};
let wasm_path = format!("{}{}", drive_path, wasm_path);
let process_id = format!("{}:{}", entry.process_name, package_id);
let Ok(parsed_new_process_id) = process_id.parse::<ProcessId>() else {
let process_id = format!("{}:{}", entry.process_name, process_package_id);
let Ok(process_id) = process_id.parse::<ProcessId>() else {
return Err(anyhow::anyhow!("invalid process id!"));
};
// kill process if it already exists
Request::to(("our", "kernel", "distro", "sys"))
.body(serde_json::to_vec(&kt::KernelCommand::KillProcess(
parsed_new_process_id.clone(),
))?)
.send()?;
kernel_request(kt::KernelCommand::KillProcess(process_id.clone())).send()?;
if let Ok(vfs::VfsResponse::Err(_)) = serde_json::from_slice(
Request::to(("our", "vfs", "distro", "sys"))
.body(serde_json::to_vec(&vfs::VfsRequest {
path: wasm_path.clone(),
action: vfs::VfsAction::Read,
})?)
// read wasm file from VFS, bytes of which will be stored in blob
if let Ok(vfs::VfsResponse::Err(e)) = serde_json::from_slice(
vfs_request(&wasm_path, vfs::VfsAction::Read)
.send_and_await_response(VFS_TIMEOUT)??
.body(),
) {
return Err(anyhow::anyhow!("failed to read process file"));
return Err(anyhow::anyhow!("failed to read process file: {e}"));
};
// use inherited blob to initialize process in kernel
let Ok(kt::KernelResponse::InitializedProcess) = serde_json::from_slice(
Request::new()
.target(("our", "kernel", "distro", "sys"))
.body(serde_json::to_vec(&kt::KernelCommand::InitializeProcess {
id: parsed_new_process_id.clone(),
wasm_bytes_handle: wasm_path,
wit_version,
on_exit: entry.on_exit.clone(),
initial_capabilities: HashSet::new(),
public: entry.public,
})?)
.inherit(true)
.send_and_await_response(VFS_TIMEOUT)??
.body(),
kernel_request(kt::KernelCommand::InitializeProcess {
id: process_id.clone(),
wasm_bytes_handle: wasm_path,
wit_version,
on_exit: entry.on_exit.clone(),
initial_capabilities: HashSet::new(),
public: entry.public,
})
.inherit(true)
.send_and_await_response(VFS_TIMEOUT)??
.body(),
) else {
return Err(anyhow::anyhow!("failed to initialize process"));
};
// build initial caps
let mut requested_capabilities: Vec<kt::Capability> = vec![];
for value in &entry.request_capabilities {
match value {
serde_json::Value::String(process_name) => {
if let Ok(parsed_process_id) = process_name.parse::<ProcessId>() {
requested_capabilities.push(kt::Capability {
issuer: Address {
node: our_node.to_string(),
process: parsed_process_id.clone(),
},
params: "\"messaging\"".into(),
});
} else {
println!("{process_id} manifest requested invalid cap: {value}");
}
}
serde_json::Value::Object(map) => {
if let Some(process_name) = map.get("process") {
if let Ok(parsed_process_id) = process_name
.as_str()
.unwrap_or_default()
.parse::<ProcessId>()
{
if let Some(params) = map.get("params") {
requested_capabilities.push(kt::Capability {
issuer: Address {
node: our_node.to_string(),
process: parsed_process_id.clone(),
},
params: params.to_string(),
});
} else {
println!("{process_id} manifest requested invalid cap: {value}");
}
}
}
}
val => {
println!("{process_id} manifest requested invalid cap: {val}");
continue;
}
}
}
// build initial caps from manifest
let mut requested_capabilities: Vec<kt::Capability> =
parse_capabilities(our_node, &entry.request_capabilities);
if entry.request_networking {
requested_capabilities.push(kt::Capability {
@ -457,42 +325,38 @@ pub fn install(
.to_string(),
});
Request::new()
.target(("our", "kernel", "distro", "sys"))
.body(serde_json::to_vec(&kt::KernelCommand::GrantCapabilities {
target: parsed_new_process_id.clone(),
capabilities: requested_capabilities,
})?)
.send()?;
kernel_request(kt::KernelCommand::GrantCapabilities {
target: process_id.clone(),
capabilities: requested_capabilities,
})
.send()?;
}
// THEN, *after* all processes have been initialized, grant caps in manifest
// this is done after initialization so that processes within a package
// can grant capabilities to one another in the manifest.
for entry in &manifest {
let process_id = format!("{}:{}", entry.process_name, package_id);
let Ok(parsed_new_process_id) = process_id.parse::<ProcessId>() else {
return Err(anyhow::anyhow!("invalid process id!"));
};
let process_id = ProcessId::new(
Some(&entry.process_name),
process_package_id.package(),
process_package_id.publisher(),
);
for value in &entry.grant_capabilities {
match value {
serde_json::Value::String(process_name) => {
if let Ok(parsed_process_id) = process_name.parse::<ProcessId>() {
Request::to(("our", "kernel", "distro", "sys"))
.body(
serde_json::to_vec(&kt::KernelCommand::GrantCapabilities {
target: parsed_process_id,
capabilities: vec![kt::Capability {
issuer: Address {
node: our_node.to_string(),
process: parsed_new_process_id.clone(),
},
params: "\"messaging\"".into(),
}],
})
.unwrap(),
)
.send()?;
kernel_request(kt::KernelCommand::GrantCapabilities {
target: parsed_process_id,
capabilities: vec![kt::Capability {
issuer: Address {
node: our_node.to_string(),
process: process_id.clone(),
},
params: "\"messaging\"".into(),
}],
})
.send()?;
} else {
println!("{process_id} manifest tried to grant invalid cap: {value}");
}
@ -505,20 +369,17 @@ pub fn install(
.parse::<ProcessId>()
{
if let Some(params) = map.get("params") {
Request::to(("our", "kernel", "distro", "sys"))
.body(serde_json::to_vec(
&kt::KernelCommand::GrantCapabilities {
target: parsed_process_id,
capabilities: vec![kt::Capability {
issuer: Address {
node: our_node.to_string(),
process: parsed_new_process_id.clone(),
},
params: params.to_string(),
}],
kernel_request(kt::KernelCommand::GrantCapabilities {
target: parsed_process_id,
capabilities: vec![kt::Capability {
issuer: Address {
node: our_node.to_string(),
process: process_id.clone(),
},
)?)
.send()?;
params: params.to_string(),
}],
})
.send()?;
}
}
} else {
@ -533,10 +394,7 @@ pub fn install(
}
let Ok(kt::KernelResponse::StartedProcess) = serde_json::from_slice(
Request::to(("our", "kernel", "distro", "sys"))
.body(serde_json::to_vec(&kt::KernelCommand::RunProcess(
parsed_new_process_id,
))?)
kernel_request(kt::KernelCommand::RunProcess(process_id))
.send_and_await_response(VFS_TIMEOUT)??
.body(),
) else {
@ -546,41 +404,124 @@ pub fn install(
Ok(())
}
pub fn uninstall(package_id: &PackageId) -> anyhow::Result<()> {
let drive_path = format!("/{package_id}/pkg");
Request::new()
.target(("our", "vfs", "distro", "sys"))
.body(serde_json::to_vec(&vfs::VfsRequest {
path: format!("{}/manifest.json", drive_path),
action: vfs::VfsAction::Read,
})?)
.send_and_await_response(VFS_TIMEOUT)??;
let Some(blob) = get_blob() else {
return Err(anyhow::anyhow!("no blob"));
};
let manifest = String::from_utf8(blob.bytes)?;
let manifest = serde_json::from_str::<Vec<kt::PackageManifestEntry>>(&manifest)?;
// reading from the package manifest, kill every process
for entry in &manifest {
let process_id = format!("{}:{}", entry.process_name, package_id);
let Ok(parsed_new_process_id) = process_id.parse::<ProcessId>() else {
continue;
};
Request::new()
.target(("our", "kernel", "distro", "sys"))
.body(serde_json::to_vec(&kt::KernelCommand::KillProcess(
parsed_new_process_id,
))?)
.send()?;
/// given a `PackageId`, read its manifest, kill all processes declared in it,
/// then remove its drive in the virtual filesystem.
pub fn uninstall(state: &mut State, package_id: &PackageId) -> anyhow::Result<()> {
if !state.packages.contains_key(package_id) {
return Err(anyhow::anyhow!("package not found"));
}
// the drive corresponding to the package we will be removing
let drive_path = format!("/{package_id}/pkg");
// get manifest.json from drive
vfs_request(
format!("{}/manifest.json", drive_path),
vfs::VfsAction::Read,
)
.send_and_await_response(VFS_TIMEOUT)??;
let Some(blob) = get_blob() else {
return Err(anyhow::anyhow!(
"couldn't find manifest.json for uninstall!"
));
};
let manifest = serde_json::from_slice::<Vec<kt::PackageManifestEntry>>(&blob.bytes)?;
// reading from the package manifest, kill every process named
for entry in &manifest {
kernel_request(kt::KernelCommand::KillProcess(ProcessId::new(
Some(&entry.process_name),
package_id.package(),
package_id.publisher(),
)))
.send()?;
}
// then, delete the drive
Request::new()
.target(("our", "vfs", "distro", "sys"))
.body(serde_json::to_vec(&vfs::VfsRequest {
path: drive_path,
action: vfs::VfsAction::RemoveDirAll,
})?)
vfs_request(drive_path, vfs::VfsAction::RemoveDirAll)
.send_and_await_response(VFS_TIMEOUT)??;
// Remove the package from the state
state.packages.remove(package_id);
// If this package had an API, remove it from installed_apis
state.installed_apis.remove(package_id);
Ok(())
}
pub fn _extract_caps_hashes(manifest_bytes: &[u8]) -> anyhow::Result<HashMap<String, String>> {
let manifest = serde_json::from_slice::<Vec<kt::PackageManifestEntry>>(manifest_bytes)?;
let mut caps_hashes = HashMap::new();
for process in &manifest {
let caps_bytes = serde_json::to_vec(&process.request_capabilities)?;
let caps_hash = keccak_256_hash(&caps_bytes);
caps_hashes.insert(process.process_name.clone(), caps_hash);
}
Ok(caps_hashes)
}
fn parse_capabilities(our_node: &str, caps: &Vec<serde_json::Value>) -> Vec<kt::Capability> {
let mut requested_capabilities: Vec<kt::Capability> = vec![];
for value in caps {
match value {
serde_json::Value::String(process_name) => {
if let Ok(parsed_process_id) = process_name.parse::<ProcessId>() {
requested_capabilities.push(kt::Capability {
issuer: Address {
node: our_node.to_string(),
process: parsed_process_id.clone(),
},
params: "\"messaging\"".into(),
});
} else {
println!("manifest requested invalid cap: {value}");
}
}
serde_json::Value::Object(map) => {
if let Some(process_name) = map.get("process") {
if let Ok(parsed_process_id) = process_name
.as_str()
.unwrap_or_default()
.parse::<ProcessId>()
{
if let Some(params) = map.get("params") {
requested_capabilities.push(kt::Capability {
issuer: Address {
node: our_node.to_string(),
process: parsed_process_id.clone(),
},
params: params.to_string(),
});
} else {
println!("manifest requested invalid cap: {value}");
}
}
}
}
val => {
println!("manifest requested invalid cap: {val}");
continue;
}
}
}
requested_capabilities
}
fn kernel_request(command: kt::KernelCommand) -> Request {
Request::to(("our", "kernel", "distro", "sys"))
.body(serde_json::to_vec(&command).expect("failed to serialize KernelCommand"))
}
pub fn vfs_request<T>(path: T, action: vfs::VfsAction) -> Request
where
T: Into<String>,
{
Request::to(("our", "vfs", "distro", "sys")).body(
serde_json::to_vec(&vfs::VfsRequest {
path: path.into(),
action,
})
.expect("failed to serialize VfsRequest"),
)
}

View File

@ -0,0 +1,29 @@
[package]
name = "chain"
version = "0.5.0"
edition = "2021"
[features]
simulation-mode = []
[dependencies]
alloy-primitives = "0.7.6"
alloy-sol-types = "0.7.6"
anyhow = "1.0"
bincode = "1.3.3"
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", branch = "develop" }
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10.8"
sha3 = "0.10.8"
url = "2.4.1"
urlencoding = "2.1.0"
wit-bindgen = "0.24.0"
zip = { version = "1.1.1", default-features = false }
[lib]
crate-type = ["cdylib"]
[package.metadata.component]
package = "kinode:process"

View File

@ -0,0 +1,497 @@
#![feature(let_chains)]
//! chain:app_store:sys
//! manages indexing relevant packages and their versions from the kimap.
//! keeps eth subscriptions open, keeps data updated.
//!
use crate::kinode::process::chain::{
ChainError, ChainRequests, OnchainApp, OnchainMetadata, OnchainProperties,
};
use crate::kinode::process::downloads::{AutoUpdateRequest, DownloadRequests};
use alloy_primitives::keccak256;
use alloy_sol_types::SolEvent;
use kinode::process::chain::ChainResponses;
use kinode_process_lib::{
await_message, call_init, eth, get_blob, get_state, http, kernel_types as kt, kimap,
print_to_terminal, println, timer, Address, Message, PackageId, Request, Response,
};
use std::{
collections::{HashMap, HashSet},
str::FromStr,
};
use serde::{Deserialize, Serialize};
wit_bindgen::generate!({
path: "target/wit",
generate_unused_types: true,
world: "app-store-sys-v0",
additional_derives: [serde::Deserialize, serde::Serialize],
});
#[cfg(not(feature = "simulation-mode"))]
const CHAIN_ID: u64 = kimap::KIMAP_CHAIN_ID;
#[cfg(feature = "simulation-mode")]
const CHAIN_ID: u64 = 31337; // local
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 = "0xcA92476B2483aBD5D82AEBF0b56701Bb2e9be658";
const DELAY_MS: u64 = 1_000; // 1s
#[derive(Debug, Serialize, Deserialize)]
pub struct State {
/// the kimap helper we are using
pub kimap: kimap::Kimap,
/// the last block at which we saved the state of the listings to disk.
/// when we boot, we can read logs starting from this block and
/// rebuild latest state.
pub last_saved_block: u64,
/// onchain listings
pub listings: HashMap<PackageId, PackageListing>,
/// set of packages that we have published
pub published: HashSet<PackageId>,
}
/// listing information derived from metadata hash in listing event
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PackageListing {
pub tba: eth::Address,
pub metadata_uri: String,
pub metadata_hash: String,
// should this even be optional?
// relegate to only valid apps maybe?
pub metadata: Option<kt::Erc721Metadata>,
pub auto_update: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)] // untagged as a meta-type for all incoming requests
pub enum Req {
Eth(eth::EthSubResult),
Request(ChainRequests),
}
call_init!(init);
fn init(our: Address) {
println!(
"chain started, indexing on contract address {}",
KIMAP_ADDRESS
);
// create new provider with request-timeout of 60s
// can change, log requests can take quite a long time.
let eth_provider: eth::Provider = eth::Provider::new(CHAIN_ID, CHAIN_TIMEOUT);
let mut state = fetch_state(eth_provider);
fetch_and_subscribe_logs(&our, &mut state);
loop {
match await_message() {
Err(send_error) => {
print_to_terminal(1, &format!("got network error: {send_error}"));
}
Ok(message) => {
if let Err(e) = handle_message(&our, &mut state, &message) {
print_to_terminal(1, &format!("error handling message: {:?}", e));
}
}
}
}
}
fn handle_message(our: &Address, state: &mut State, message: &Message) -> anyhow::Result<()> {
if !message.is_request() {
if message.is_local(&our) && message.source().process == "timer:distro:sys" {
// handling of ETH RPC subscriptions delayed by DELAY_MS
// to allow kns to have a chance to process block: handle now
let Some(context) = message.context() else {
return Err(anyhow::anyhow!("foo"));
};
let log = serde_json::from_slice(context)?;
handle_eth_log(our, state, log)?;
return Ok(());
}
} else {
let req: Req = serde_json::from_slice(message.body())?;
match req {
Req::Eth(eth_result) => {
if !message.is_local(our) || message.source().process != "eth:distro:sys" {
return Err(anyhow::anyhow!(
"eth sub event from unexpected address: {}",
message.source()
));
}
if let Ok(eth::EthSub { result, .. }) = eth_result {
if let eth::SubscriptionResult::Log(ref log) = result {
// delay handling of ETH RPC subscriptions by DELAY_MS
// to allow kns to have a chance to process block
timer::set_timer(DELAY_MS, Some(serde_json::to_vec(log)?));
}
} else {
// attempt to resubscribe
state
.kimap
.provider
.subscribe_loop(1, app_store_filter(state));
}
}
Req::Request(chains) => {
handle_local_request(state, chains)?;
}
}
}
Ok(())
}
fn handle_local_request(state: &mut State, req: ChainRequests) -> anyhow::Result<()> {
match req {
ChainRequests::GetApp(package_id) => {
let onchain_app = state
.listings
.get(&package_id.clone().to_process_lib())
.map(|app| OnchainApp {
package_id: package_id,
tba: app.tba.to_string(),
metadata_uri: app.metadata_uri.clone(),
metadata_hash: app.metadata_hash.clone(),
metadata: app.metadata.as_ref().map(|m| m.clone().into()),
auto_update: app.auto_update,
});
let response = ChainResponses::GetApp(onchain_app);
Response::new()
.body(serde_json::to_vec(&response)?)
.send()?;
}
ChainRequests::GetApps => {
let apps: Vec<OnchainApp> = state
.listings
.iter()
.map(|(id, listing)| listing.to_onchain_app(id))
.collect();
let response = ChainResponses::GetApps(apps);
Response::new()
.body(serde_json::to_vec(&response)?)
.send()?;
}
ChainRequests::GetOurApps => {
let apps: Vec<OnchainApp> = state
.published
.iter()
.filter_map(|id| {
state
.listings
.get(id)
.map(|listing| listing.to_onchain_app(id))
})
.collect();
let response = ChainResponses::GetOurApps(apps);
Response::new()
.body(serde_json::to_vec(&response)?)
.send()?;
}
ChainRequests::StartAutoUpdate(package_id) => {
if let Some(listing) = state.listings.get_mut(&package_id.to_process_lib()) {
listing.auto_update = true;
let response = ChainResponses::AutoUpdateStarted;
Response::new()
.body(serde_json::to_vec(&response)?)
.send()?;
} else {
let error_response = ChainResponses::Error(ChainError::NoPackage);
Response::new()
.body(serde_json::to_vec(&error_response)?)
.send()?;
}
}
ChainRequests::StopAutoUpdate(package_id) => {
if let Some(listing) = state.listings.get_mut(&package_id.to_process_lib()) {
listing.auto_update = false;
let response = ChainResponses::AutoUpdateStopped;
Response::new()
.body(serde_json::to_vec(&response)?)
.send()?;
} else {
let error_response = ChainResponses::Error(ChainError::NoPackage);
Response::new()
.body(serde_json::to_vec(&error_response)?)
.send()?;
}
}
}
Ok(())
}
fn handle_eth_log(our: &Address, state: &mut State, log: eth::Log) -> anyhow::Result<()> {
let block_number: u64 = log.block_number.ok_or(anyhow::anyhow!("blocknumbaerror"))?;
let note: kimap::Note =
kimap::decode_note_log(&log).map_err(|e| anyhow::anyhow!("decodelogerror: {e:?}"))?;
let package_id = note
.parent_path
.split_once('.')
.ok_or(anyhow::anyhow!("invalid publisher name"))
.and_then(|(package, publisher)| {
if package.is_empty() || publisher.is_empty() {
Err(anyhow::anyhow!("invalid publisher name"))
} else {
Ok(PackageId::new(&package, &publisher))
}
})?;
// the app store exclusively looks for ~metadata-uri postings: if one is
// observed, we then *query* for ~metadata-hash to verify the content
// at the URI.
let metadata_uri = String::from_utf8_lossy(&note.data).to_string();
let is_our_package = &package_id.publisher() == &our.node();
let (tba, metadata_hash) = {
// generate ~metadata-hash full-path
let hash_note = format!("~metadata-hash.{}", note.parent_path);
// owner can change which we don't track (yet?) so don't save, need to get when desired
let (tba, _owner, data) = match state.kimap.get(&hash_note) {
Ok(gr) => Ok(gr),
Err(e) => match e {
eth::EthError::RpcError(_) => {
// retry on RpcError after DELAY_MS sleep
// sleep here rather than with, e.g., a message to
// `timer:distro:sys` so that events are processed in
// order of receipt
std::thread::sleep(std::time::Duration::from_millis(DELAY_MS));
state.kimap.get(&hash_note)
}
_ => Err(e),
},
}
.map_err(|e| {
println!("Couldn't find {hash_note}: {e:?}");
anyhow::anyhow!("metadata hash mismatch")
})?;
match data {
None => {
// if ~metadata-uri is also empty, this is an unpublish action!
if metadata_uri.is_empty() {
state.published.remove(&package_id);
state.listings.remove(&package_id);
return Ok(());
}
return Err(anyhow::anyhow!("metadata hash not found"));
}
Some(hash_note) => (tba, String::from_utf8_lossy(&hash_note).to_string()),
}
};
// fetch metadata from the URI (currently only handling HTTP(S) URLs!)
// assert that the metadata hash matches the fetched data
let metadata = fetch_metadata_from_url(&metadata_uri, &metadata_hash, 30)?;
match state.listings.entry(package_id.clone()) {
std::collections::hash_map::Entry::Occupied(mut listing) => {
let listing = listing.get_mut();
listing.metadata_uri = metadata_uri;
listing.tba = tba;
listing.metadata_hash = metadata_hash;
listing.metadata = Some(metadata.clone());
}
std::collections::hash_map::Entry::Vacant(listing) => {
listing.insert(PackageListing {
tba,
metadata_uri,
metadata_hash,
metadata: Some(metadata.clone()),
auto_update: false,
});
}
}
if is_our_package {
state.published.insert(package_id.clone());
}
state.last_saved_block = block_number;
// if auto_update is enabled, send a message to downloads to kick off the update.
if let Some(listing) = state.listings.get(&package_id) {
if listing.auto_update {
print_to_terminal(1, &format!("kicking off auto-update for: {}", package_id));
let request = DownloadRequests::AutoUpdate(AutoUpdateRequest {
package_id: crate::kinode::process::main::PackageId::from_process_lib(package_id),
metadata: metadata.into(),
});
Request::to(("our", "downloads", "app_store", "sys"))
.body(serde_json::to_vec(&request)?)
.send()?;
}
}
Ok(())
}
/// create the filter used for app store getLogs and subscription.
/// the app store exclusively looks for ~metadata-uri postings: if one is
/// observed, we then *query* for ~metadata-hash to verify the content
/// at the URI.
///
/// this means that ~metadata-hash should be *posted before or at the same time* as ~metadata-uri!
pub fn app_store_filter(state: &State) -> eth::Filter {
let notes = vec![keccak256("~metadata-uri")];
eth::Filter::new()
.address(*state.kimap.address())
.events([kimap::contract::Note::SIGNATURE])
.topic3(notes)
}
/// create a filter to fetch app store event logs from chain and subscribe to new events
pub fn fetch_and_subscribe_logs(our: &Address, state: &mut State) {
let filter = app_store_filter(state);
// get past logs, subscribe to new ones.
// subscribe first so we don't miss any logs
println!("subscribing...");
state.kimap.provider.subscribe_loop(1, filter.clone());
for log in fetch_logs(
&state.kimap.provider,
&filter.from_block(state.last_saved_block),
) {
if let Err(e) = handle_eth_log(our, state, log) {
print_to_terminal(1, &format!("error ingesting log: {e}"));
};
}
}
/// fetch logs from the chain with a given filter
fn fetch_logs(eth_provider: &eth::Provider, filter: &eth::Filter) -> Vec<eth::Log> {
loop {
match eth_provider.get_logs(filter) {
Ok(res) => return res,
Err(_) => {
println!("failed to fetch logs! trying again in 5s...");
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
}
}
}
/// fetch metadata from url and verify it matches metadata_hash
pub fn fetch_metadata_from_url(
metadata_url: &str,
metadata_hash: &str,
timeout: u64,
) -> Result<kt::Erc721Metadata, anyhow::Error> {
if let Ok(url) = url::Url::parse(metadata_url) {
if let Ok(_) =
http::client::send_request_await_response(http::Method::GET, url, None, timeout, vec![])
{
if let Some(body) = get_blob() {
let hash = keccak_256_hash(&body.bytes);
if &hash == metadata_hash {
return Ok(serde_json::from_slice::<kt::Erc721Metadata>(&body.bytes)
.map_err(|_| anyhow::anyhow!("metadata not found"))?);
} else {
return Err(anyhow::anyhow!("metadata hash mismatch"));
}
}
}
}
Err(anyhow::anyhow!("metadata not found"))
}
/// generate a Keccak-256 hash string (with 0x prefix) of the metadata bytes
pub fn keccak_256_hash(bytes: &[u8]) -> String {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(bytes);
format!("0x{:x}", hasher.finalize())
}
/// fetch state from disk or create a new one if that fails
pub fn fetch_state(provider: eth::Provider) -> State {
if let Some(state_bytes) = get_state() {
match serde_json::from_slice::<State>(&state_bytes) {
Ok(state) => {
if state.kimap.address().to_string() == KIMAP_ADDRESS {
return state;
} else {
println!(
"state contract address mismatch. rebuilding state! expected {}, got {}",
KIMAP_ADDRESS,
state.kimap.address().to_string()
);
}
}
Err(e) => println!("failed to deserialize saved state, rebuilding: {e}"),
}
}
State {
kimap: kimap::Kimap::new(provider, eth::Address::from_str(KIMAP_ADDRESS).unwrap()),
last_saved_block: 0,
listings: HashMap::new(),
published: HashSet::new(),
}
}
// quite annoyingly, we must convert from our gen'd version of PackageId
// to the process_lib's gen'd version. this is in order to access custom
// Impls that we want to use
impl crate::kinode::process::main::PackageId {
pub fn to_process_lib(self) -> PackageId {
PackageId {
package_name: self.package_name,
publisher_node: self.publisher_node,
}
}
pub fn from_process_lib(package_id: PackageId) -> Self {
Self {
package_name: package_id.package_name,
publisher_node: package_id.publisher_node,
}
}
}
impl PackageListing {
pub fn to_onchain_app(&self, package_id: &PackageId) -> OnchainApp {
OnchainApp {
package_id: crate::kinode::process::main::PackageId::from_process_lib(
package_id.clone(),
),
tba: self.tba.to_string(),
metadata_uri: self.metadata_uri.clone(),
metadata_hash: self.metadata_hash.clone(),
metadata: self.metadata.as_ref().map(|m| m.clone().into()),
auto_update: self.auto_update,
}
}
}
impl From<kt::Erc721Metadata> for OnchainMetadata {
fn from(erc: kt::Erc721Metadata) -> Self {
OnchainMetadata {
name: erc.name,
description: erc.description,
image: erc.image,
external_url: erc.external_url,
animation_url: erc.animation_url,
properties: OnchainProperties {
package_name: erc.properties.package_name,
publisher: erc.properties.publisher,
current_version: erc.properties.current_version,
mirrors: erc.properties.mirrors,
code_hashes: erc.properties.code_hashes.into_iter().collect(),
license: erc.properties.license,
screenshots: erc.properties.screenshots,
wit_version: erc.properties.wit_version,
dependencies: erc.properties.dependencies,
},
}
}
}

View File

@ -8,7 +8,7 @@ simulation-mode = []
[dependencies]
anyhow = "1.0"
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.8.0" }
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.9.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wit-bindgen = "0.24.0"

View File

@ -1,7 +1,7 @@
use crate::kinode::process::main::{DownloadResponse, LocalRequest, LocalResponse};
use kinode::process::main::DownloadRequest;
use crate::kinode::process::downloads::{DownloadRequests, DownloadResponses};
use kinode::process::downloads::LocalDownloadRequest;
use kinode_process_lib::{
await_next_message_body, call_init, println, Address, Message, NodeId, PackageId, Request,
await_next_message_body, call_init, println, Address, Message, PackageId, Request,
};
wit_bindgen::generate!({
@ -19,14 +19,15 @@ fn init(our: Address) {
};
let args = String::from_utf8(body).unwrap_or_default();
let Some((arg1, arg2)) = args.split_once(" ") else {
println!("download: 2 arguments required, the node id to download from and the package id of the app");
println!("example: download my-friend.os app:publisher.os");
let parts: Vec<&str> = args.split_whitespace().collect();
if parts.len() != 3 {
println!("download: 3 arguments required, the node id to download from, the package id of the app, and the version hash");
println!("example: download my-friend.os app:publisher.os f5d374ab50e66888a7c2332b22d0f909f2e3115040725cfab98dcae488916990");
return;
};
}
let (arg1, arg2, arg3) = (parts[0], parts[1], parts[2]);
let download_from: NodeId = arg1.to_string();
let download_from: String = arg1.to_string();
let Ok(package_id) = arg2.parse::<PackageId>() else {
println!("download: invalid package id, make sure to include package name and publisher");
@ -34,38 +35,38 @@ fn init(our: Address) {
return;
};
let version_hash: String = arg3.to_string();
let Ok(Ok(Message::Response { body, .. })) =
Request::to((our.node(), ("main", "app_store", "sys")))
Request::to((our.node(), ("downloads", "app_store", "sys")))
.body(
serde_json::to_vec(&LocalRequest::Download(DownloadRequest {
serde_json::to_vec(&DownloadRequests::LocalDownload(LocalDownloadRequest {
package_id: crate::kinode::process::main::PackageId {
package_name: package_id.package_name.clone(),
publisher_node: package_id.publisher_node.clone(),
},
download_from: download_from.clone(),
mirror: true,
auto_update: true,
desired_version_hash: None,
desired_version_hash: version_hash.clone(),
}))
.unwrap(),
.expect("Failed to serialize LocalDownloadRequest"),
)
.send_and_await_response(5)
.send_and_await_response(10)
else {
println!("download: failed to get a response from app_store..!");
return;
};
let Ok(response) = serde_json::from_slice::<LocalResponse>(&body) else {
let Ok(response) = serde_json::from_slice::<DownloadResponses>(&body) else {
println!("download: failed to parse response from app_store..!");
return;
};
match response {
LocalResponse::DownloadResponse(DownloadResponse::Started) => {
println!("started downloading package {package_id} from {download_from}");
DownloadResponses::Error(_e) => {
println!("download: error");
}
LocalResponse::DownloadResponse(_) => {
println!("failed to download package {package_id} from {download_from}");
DownloadResponses::Success => {
println!("download: success");
}
_ => {
println!("download: unexpected response from app_store..!");

View File

@ -0,0 +1,26 @@
[package]
name = "downloads"
version = "0.5.0"
edition = "2021"
[features]
simulation-mode = []
[dependencies]
anyhow = "1.0"
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", branch = "develop" }
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10.8"
sha3 = "0.10.8"
url = "2.4.1"
urlencoding = "2.1.0"
wit-bindgen = "0.24.0"
zip = { version = "1.1.4", default-features = false, features = ["deflate"] }
[lib]
crate-type = ["cdylib"]
[package.metadata.component]
package = "kinode:process"

View File

@ -0,0 +1,589 @@
#![feature(let_chains)]
//! downloads:app_store:sys
//! manages downloading and sharing of versioned packages.
//!
use crate::kinode::process::downloads::{
AutoUpdateRequest, DirEntry, DownloadCompleteRequest, DownloadError, DownloadRequests,
DownloadResponses, Entry, FileEntry, HashMismatch, LocalDownloadRequest, RemoteDownloadRequest,
RemoveFileRequest,
};
use std::{collections::HashSet, io::Read, str::FromStr};
use ft_worker_lib::{spawn_receive_transfer, spawn_send_transfer};
use kinode_process_lib::{
await_message, call_init, get_blob, get_state,
http::client,
print_to_terminal, println, set_state,
vfs::{self, Directory, File},
Address, Message, PackageId, ProcessId, Request, Response,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
wit_bindgen::generate!({
path: "target/wit",
generate_unused_types: true,
world: "app-store-sys-v0",
additional_derives: [serde::Deserialize, serde::Serialize],
});
mod ft_worker_lib;
pub const VFS_TIMEOUT: u64 = 5; // 5s
pub const APP_SHARE_TIMEOUT: u64 = 120; // 120s
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)] // untagged as a meta-type for all incoming responses
pub enum Resp {
Download(DownloadResponses),
HttpClient(Result<client::HttpClientResponse, client::HttpClientError>),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct State {
// persisted metadata about which packages we are mirroring
mirroring: HashSet<PackageId>,
// note, pending auto_updates are not persisted.
}
impl State {
fn load() -> Self {
match get_state() {
Some(blob) => match serde_json::from_slice::<State>(&blob) {
Ok(state) => state,
Err(_) => State {
mirroring: HashSet::new(),
},
},
None => State {
mirroring: HashSet::new(),
},
}
}
}
call_init!(init);
fn init(our: Address) {
println!("downloads: started");
// mirroring metadata is separate from vfs downloads state.
let mut state = State::load();
// /app_store:sys/downloads/
vfs::create_drive(our.package_id(), "downloads", None)
.expect("could not create /downloads drive");
let mut downloads =
open_or_create_dir("/app_store:sys/downloads").expect("could not open downloads");
let mut tmp = open_or_create_dir("/app_store:sys/downloads/tmp").expect("could not open tmp");
let mut auto_updates: HashSet<(PackageId, String)> = HashSet::new();
loop {
match await_message() {
Err(send_error) => {
print_to_terminal(1, &format!("got network error: {send_error}"));
}
Ok(message) => {
if let Err(e) = handle_message(
&our,
&mut state,
&message,
&mut downloads,
&mut tmp,
&mut auto_updates,
) {
print_to_terminal(1, &format!("error handling message: {:?}", e));
}
}
}
}
}
/// message router: parse into our Req and Resp types, then pass to
/// function defined for each kind of message. check whether the source
/// of the message is allowed to send that kind of message to us.
/// finally, fire a response if expected from a request.
fn handle_message(
our: &Address,
state: &mut State,
message: &Message,
downloads: &mut Directory,
_tmp: &mut Directory,
auto_updates: &mut HashSet<(PackageId, String)>,
) -> anyhow::Result<()> {
if message.is_request() {
match serde_json::from_slice::<DownloadRequests>(message.body())? {
DownloadRequests::LocalDownload(download_request) => {
// we want to download a package.
if !message.is_local(our) {
return Err(anyhow::anyhow!("not local"));
}
let LocalDownloadRequest {
package_id,
download_from,
desired_version_hash,
} = download_request.clone();
if download_from.starts_with("http") {
// use http_client to GET it
Request::to(("our", "http_client", "distro", "sys"))
.body(
serde_json::to_vec(&client::HttpClientAction::Http(
client::OutgoingHttpRequest {
method: "GET".to_string(),
version: None,
url: download_from.clone(),
headers: std::collections::HashMap::new(),
},
))
.unwrap(),
)
.context(serde_json::to_vec(&download_request)?)
.expects_response(60)
.send()?;
return Ok(());
}
// go download from the node or url
// spawn a worker, and send a downlaod to the node.
let our_worker = spawn_receive_transfer(
our,
&package_id,
&desired_version_hash,
&download_from,
APP_SHARE_TIMEOUT,
)?;
Request::to((&download_from, "downloads", "app_store", "sys"))
.body(serde_json::to_vec(&DownloadRequests::RemoteDownload(
RemoteDownloadRequest {
package_id,
desired_version_hash,
worker_address: our_worker.to_string(),
},
))?)
.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 target_worker = Address::from_str(&worker_address)?;
let _ = spawn_send_transfer(
our,
&package_id,
&desired_version_hash,
APP_SHARE_TIMEOUT,
&target_worker,
)?;
}
DownloadRequests::Progress(progress) => {
// forward progress to main:app_store:sys,
// pushed to UI via websockets
let _ = Request::to(("our", "main", "app_store", "sys"))
.body(serde_json::to_vec(&progress)?)
.send();
}
DownloadRequests::DownloadComplete(req) => {
if !message.is_local(our) {
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 context = if auto_updates.remove(&(
req.package_id.clone().to_process_lib(),
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.as_bytes().to_vec()),
Err(e) => {
print_to_terminal(
1,
&format!("auto_update: error getting manifest hash: {:?}", e),
);
None
}
}
} else {
None
};
// pushed to UI via websockets
let mut request = Request::to(("our", "main", "app_store", "sys"))
.body(serde_json::to_vec(&req)?);
if let Some(ctx) = context {
request = request.context(ctx);
}
request.send()?;
}
DownloadRequests::GetFiles(maybe_id) => {
// if not local, throw to the boonies.
// note, can also implement a discovery protocol here in the future
if !message.is_local(our) {
return Err(anyhow::anyhow!("got non local get_files"));
}
let files = match maybe_id {
Some(id) => {
let package_path =
format!("{}/{}", downloads.path, id.to_process_lib().to_string());
let dir = vfs::open_dir(&package_path, false, None)?;
let dir = dir.read()?;
format_entries(dir, state)
}
None => {
let dir = downloads.read()?;
format_entries(dir, state)
}
};
let resp = DownloadResponses::GetFiles(files);
Response::new().body(serde_json::to_string(&resp)?).send()?;
}
DownloadRequests::RemoveFile(remove_req) => {
if !message.is_local(our) {
return Err(anyhow::anyhow!("not local"));
}
let RemoveFileRequest {
package_id,
version_hash,
} = remove_req;
let package_dir = format!(
"{}/{}",
downloads.path,
package_id.to_process_lib().to_string()
);
let zip_path = format!("{}/{}.zip", package_dir, version_hash);
let _ = vfs::remove_file(&zip_path, None);
let manifest_path = format!("{}/{}.json", package_dir, version_hash);
let _ = vfs::remove_file(&manifest_path, None);
Response::new()
.body(serde_json::to_vec(&Resp::Download(
DownloadResponses::Success,
))?)
.send()?;
}
DownloadRequests::AddDownload(add_req) => {
if !message.is_local(our) {
return Err(anyhow::anyhow!("not local"));
}
let Some(blob) = get_blob() else {
return Err(anyhow::anyhow!("could not get blob"));
};
let bytes = blob.bytes;
let package_dir = format!(
"{}/{}",
downloads.path,
add_req.package_id.clone().to_process_lib().to_string()
);
let _ = open_or_create_dir(&package_dir)?;
// Write the zip file
let zip_path = format!("{}/{}.zip", package_dir, add_req.version_hash);
let file = vfs::create_file(&zip_path, None)?;
file.write(bytes.as_slice())?;
// Extract and write the manifest
let manifest_path = format!("{}/{}.json", package_dir, add_req.version_hash);
extract_and_write_manifest(&bytes, &manifest_path)?;
// add mirrors if applicable and save:
if add_req.mirror {
state.mirroring.insert(add_req.package_id.to_process_lib());
set_state(&serde_json::to_vec(&state)?);
}
Response::new()
.body(serde_json::to_vec(&Resp::Download(
DownloadResponses::Success,
))?)
.send()?;
}
DownloadRequests::StartMirroring(package_id) => {
let package_id = package_id.to_process_lib();
state.mirroring.insert(package_id);
set_state(&serde_json::to_vec(&state)?);
Response::new()
.body(serde_json::to_vec(&Resp::Download(
DownloadResponses::Success,
))?)
.send()?;
}
DownloadRequests::StopMirroring(package_id) => {
let package_id = package_id.to_process_lib();
state.mirroring.remove(&package_id);
set_state(&serde_json::to_vec(&state)?);
Response::new()
.body(serde_json::to_vec(&Resp::Download(
DownloadResponses::Success,
))?)
.send()?;
}
DownloadRequests::AutoUpdate(auto_update_request) => {
if !message.is_local(&our)
&& message.source().process != ProcessId::new(Some("chain"), "app_store", "sys")
{
return Err(anyhow::anyhow!(
"got auto-update from non local chain source"
));
}
let AutoUpdateRequest {
package_id,
metadata,
} = auto_update_request.clone();
let process_lib_package_id = package_id.clone().to_process_lib();
// default auto_update to publisher. TODO: more config here.
let download_from = metadata.properties.publisher;
let current_version = metadata.properties.current_version;
let code_hashes = metadata.properties.code_hashes;
let version_hash = code_hashes
.iter()
.find(|(version, _)| version == &current_version)
.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))?;
let download_request = LocalDownloadRequest {
package_id,
download_from,
desired_version_hash: version_hash.clone(),
};
// kick off local download to ourselves.
Request::to(("our", "downloads", "app_store", "sys"))
.body(serde_json::to_vec(&DownloadRequests::LocalDownload(
download_request,
))?)
.send()?;
auto_updates.insert((process_lib_package_id, version_hash));
}
_ => {}
}
} else {
match serde_json::from_slice::<Resp>(message.body())? {
Resp::Download(download_response) => {
// these are handled in line.
print_to_terminal(
1,
&format!("got a weird download response: {:?}", download_response),
);
}
Resp::HttpClient(resp) => {
let Some(context) = message.context() else {
return Err(anyhow::anyhow!("http_client response without context"));
};
let download_request = serde_json::from_slice::<LocalDownloadRequest>(context)?;
if let Ok(client::HttpClientResponse::Http(client::HttpResponse {
status, ..
})) = resp
{
if status == 200 {
if let Err(e) = handle_receive_http_download(&download_request) {
print_to_terminal(
1,
&format!("error handling http_client response: {:?}", e),
);
Request::to(("our", "main", "app_store", "sys"))
.body(serde_json::to_vec(&DownloadRequests::DownloadComplete(
DownloadCompleteRequest {
package_id: download_request.package_id.clone(),
version_hash: download_request.desired_version_hash.clone(),
error: Some(e),
},
))?)
.send()?;
}
}
} else {
println!("got http_client error: {resp:?}");
}
}
}
}
Ok(())
}
fn handle_receive_http_download(
download_request: &LocalDownloadRequest,
) -> anyhow::Result<(), DownloadError> {
let package_id = download_request.package_id.clone().to_process_lib();
let version_hash = download_request.desired_version_hash.clone();
print_to_terminal(
1,
&format!(
"Received HTTP download for: {}, with version hash: {}",
package_id.to_string(),
version_hash
),
);
let bytes = get_blob().ok_or(DownloadError::BlobNotFound)?.bytes;
let package_dir = format!("{}/{}", "/app_store:sys/downloads", package_id.to_string());
let _ = open_or_create_dir(&package_dir).map_err(|_| DownloadError::VfsError)?;
let calculated_hash = format!("{:x}", Sha256::digest(&bytes));
if calculated_hash != version_hash {
return Err(DownloadError::HashMismatch(HashMismatch {
desired: version_hash,
actual: calculated_hash,
}));
}
// Write the zip file
let zip_path = format!("{}/{}.zip", package_dir, version_hash);
let file = vfs::create_file(&zip_path, None).map_err(|_| DownloadError::VfsError)?;
file.write(bytes.as_slice())
.map_err(|_| DownloadError::VfsError)?;
// Write the manifest file
// Extract and write the manifest
let manifest_path = format!("{}/{}.json", package_dir, version_hash);
extract_and_write_manifest(&bytes, &manifest_path).map_err(|_| DownloadError::VfsError)?;
Request::to(("our", "main", "app_store", "sys"))
.body(
serde_json::to_vec(&DownloadCompleteRequest {
package_id: download_request.package_id.clone(),
version_hash,
error: None,
})
.unwrap(),
)
.send()
.unwrap();
Ok(())
}
fn format_entries(entries: Vec<vfs::DirEntry>, state: &State) -> Vec<Entry> {
entries
.into_iter()
.filter_map(|entry| {
let name = entry.path.split('/').last()?.to_string();
let is_file = entry.file_type == vfs::FileType::File;
if is_file && name.ends_with(".zip") {
let size = vfs::metadata(&entry.path, None)
.map(|meta| meta.len)
.unwrap_or(0);
let json_path = entry.path.replace(".zip", ".json");
let manifest = vfs::open_file(&json_path, false, None)
.and_then(|file| file.read_to_string())
.unwrap_or_default();
Some(Entry::File(FileEntry {
name,
size,
manifest,
}))
} else if !is_file {
let mirroring = state.mirroring.iter().any(|pid| {
pid.package_name == name
|| format!("{}:{}", pid.package_name, pid.publisher_node) == name
});
Some(Entry::Dir(DirEntry { name, mirroring }))
} else {
None // Skip non-zip files
}
})
.collect()
}
fn extract_and_write_manifest(file_contents: &[u8], manifest_path: &str) -> anyhow::Result<()> {
let reader = std::io::Cursor::new(file_contents);
let mut archive = zip::ZipArchive::new(reader)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
if file.name() == "manifest.json" {
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let manifest_file = open_or_create_file(&manifest_path)?;
manifest_file.write(contents.as_bytes())?;
print_to_terminal(1, &format!("Extracted and wrote manifest.json"));
break;
}
}
Ok(())
}
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);
let manifest_file = vfs::open_file(&manifest_path, false, None)?;
let manifest_bytes = manifest_file.read()?;
let manifest_hash = keccak_256_hash(&manifest_bytes);
Ok(manifest_hash)
}
/// helper function for vfs files, open if exists, if not create
fn open_or_create_file(path: &str) -> anyhow::Result<File> {
match vfs::open_file(path, false, None) {
Ok(file) => Ok(file),
Err(_) => match vfs::open_file(path, true, None) {
Ok(file) => Ok(file),
Err(_) => Err(anyhow::anyhow!("could not create file")),
},
}
}
/// helper function for vfs directories, open if exists, if not create
fn open_or_create_dir(path: &str) -> anyhow::Result<Directory> {
match vfs::open_dir(path, true, None) {
Ok(dir) => Ok(dir),
Err(_) => match vfs::open_dir(path, false, None) {
Ok(dir) => Ok(dir),
Err(_) => Err(anyhow::anyhow!("could not create dir")),
},
}
}
/// generate a Keccak-256 hash string (with 0x prefix) of the metadata bytes
pub fn keccak_256_hash(bytes: &[u8]) -> String {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(bytes);
format!("0x{:x}", hasher.finalize())
}
// quite annoyingly, we must convert from our gen'd version of PackageId
// to the process_lib's gen'd version. this is in order to access custom
// Impls that we want to use
impl crate::kinode::process::main::PackageId {
pub fn to_process_lib(self) -> PackageId {
PackageId {
package_name: self.package_name,
publisher_node: self.publisher_node,
}
}
pub fn from_process_lib(package_id: PackageId) -> Self {
Self {
package_name: package_id.package_name,
publisher_node: package_id.publisher_node,
}
}
}

View File

@ -9,11 +9,13 @@ simulation-mode = []
[dependencies]
anyhow = "1.0"
bincode = "1.3.3"
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.8.0" }
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.9.0" }
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10.8"
wit-bindgen = "0.24.0"
zip = { version = "1.1.4", default-features = false, features = ["deflate"] }
[lib]
crate-type = ["cdylib"]

View File

@ -1,135 +1,76 @@
use crate::kinode::process::downloads::{
DownloadRequests, LocalDownloadRequest, PackageId, RemoteDownloadRequest,
};
use kinode_process_lib::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct FileTransferContext {
pub file_name: String,
pub file_size: Option<u64>,
pub start_time: std::time::SystemTime,
}
/// sent as first Request to a newly spawned worker
/// the Receive command will be sent out to target
/// in order to prompt them to spawn a worker
#[derive(Debug, Serialize, Deserialize)]
pub enum FTWorkerCommand {
/// make sure to attach file itself as blob
Send {
target: Address,
file_name: String,
timeout: u64,
},
Receive {
transfer_id: u64,
file_name: String,
file_size: u64,
total_chunks: u64,
timeout: u64,
},
}
/// sent as Response by worker to its parent
#[derive(Debug, Serialize, Deserialize)]
pub enum FTWorkerResult {
SendSuccess,
/// string is name of file. bytes in blob
ReceiveSuccess(String),
Err(TransferError),
}
/// the possible errors that can be returned to the parent inside `FTWorkerResult`
#[derive(Debug, Serialize, Deserialize)]
pub enum TransferError {
TargetOffline,
TargetTimeout,
TargetRejected,
SourceFailed,
}
/// A helper function to spawn a worker and initialize a file transfer.
/// The outcome will be sent as an [`FTWorkerResult`] to the caller process.
///
/// if `file_bytes` is None, expects to inherit blob!
#[allow(dead_code)]
pub fn spawn_transfer(
pub fn spawn_send_transfer(
our: &Address,
file_name: &str,
file_bytes: Option<Vec<u8>>,
package_id: &PackageId,
version_hash: &str,
timeout: u64,
to_addr: &Address,
) -> anyhow::Result<()> {
let transfer_id: u64 = rand::random();
// spawn a worker and tell it to send the file
let Ok(worker_process_id) = spawn(
Some(&transfer_id.to_string()),
&format!("{}/pkg/ft_worker.wasm", our.package_id()),
OnExit::None, // can set message-on-panic here
OnExit::None,
our_capabilities(),
vec![],
false, // not public
false,
) else {
return Err(anyhow::anyhow!("failed to spawn ft_worker!"));
};
// tell the worker what to do
let blob_or_inherit = match file_bytes {
Some(bytes) => Some(LazyLoadBlob { mime: None, bytes }),
None => None,
};
let mut req = Request::new()
.target((our.node.as_ref(), worker_process_id))
.inherit(!blob_or_inherit.is_some())
.expects_response(timeout + 1) // don't call with 2^64 lol
let req = Request::new()
.target((&our.node, worker_process_id))
.expects_response(timeout + 1)
.body(
serde_json::to_vec(&FTWorkerCommand::Send {
target: to_addr.clone(),
file_name: file_name.into(),
timeout,
})
serde_json::to_vec(&DownloadRequests::RemoteDownload(RemoteDownloadRequest {
package_id: package_id.clone(),
desired_version_hash: version_hash.to_string(),
worker_address: to_addr.to_string(),
}))
.unwrap(),
)
.context(
serde_json::to_vec(&FileTransferContext {
file_name: file_name.into(),
file_size: match &blob_or_inherit {
Some(p) => Some(p.bytes.len() as u64),
None => None, // TODO
},
start_time: std::time::SystemTime::now(),
})
);
req.send()?;
Ok(())
}
#[allow(dead_code)]
pub fn spawn_receive_transfer(
our: &Address,
package_id: &PackageId,
version_hash: &str,
from_node: &str,
timeout: u64,
) -> anyhow::Result<Address> {
let transfer_id: u64 = rand::random();
let Ok(worker_process_id) = spawn(
Some(&transfer_id.to_string()),
&format!("{}/pkg/ft_worker.wasm", our.package_id()),
OnExit::None,
our_capabilities(),
vec![],
false,
) else {
return Err(anyhow::anyhow!("failed to spawn ft_worker!"));
};
let req = Request::new()
.target((&our.node, worker_process_id.clone()))
.expects_response(timeout + 1)
.body(
serde_json::to_vec(&DownloadRequests::LocalDownload(LocalDownloadRequest {
package_id: package_id.clone(),
desired_version_hash: version_hash.to_string(),
download_from: from_node.to_string(),
}))
.unwrap(),
);
if let Some(blob) = blob_or_inherit {
req = req.blob(blob);
}
req.send()
}
/// A helper function to allow a process to easily handle an incoming transfer
/// from an ft_worker. Call this when you get the initial [`FTWorkerCommand::Receive`]
/// and let it do the rest. The outcome will be sent as an [`FTWorkerResult`] inside
/// a Response to the caller.
#[allow(dead_code)]
pub fn spawn_receive_transfer(our: &Address, body: &[u8]) -> anyhow::Result<()> {
let Ok(FTWorkerCommand::Receive { transfer_id, .. }) = serde_json::from_slice(body) else {
return Err(anyhow::anyhow!(
"spawn_receive_transfer: got malformed request"
));
};
let Ok(worker_process_id) = spawn(
Some(&transfer_id.to_string()),
&format!("{}/pkg/ft_worker.wasm", our.package_id()),
OnExit::None, // can set message-on-panic here
our_capabilities(),
vec![],
false, // not public
) else {
return Err(anyhow::anyhow!("failed to spawn ft_worker!"));
};
// forward receive command to worker
Request::new()
.target((our.node.as_ref(), worker_process_id))
.inherit(true)
.body(body)
.send()
req.send()?;
Ok(Address::new(&our.node, worker_process_id))
}

View File

@ -1,21 +1,26 @@
use kinode_process_lib::println;
use crate::kinode::process::downloads::{
ChunkRequest, DownloadCompleteRequest, DownloadError, DownloadRequests, HashMismatch,
LocalDownloadRequest, ProgressUpdate, RemoteDownloadRequest, SizeUpdate,
};
use kinode_process_lib::*;
use serde::{Deserialize, Serialize};
use kinode_process_lib::{
print_to_terminal, println, timer,
vfs::{open_dir, open_file, Directory, File, SeekFrom},
};
use sha2::{Digest, Sha256};
use std::io::Read;
use std::str::FromStr;
mod ft_worker_lib;
use ft_worker_lib::*;
pub mod ft_worker_lib;
wit_bindgen::generate!({
path: "target/wit",
world: "process-v0",
generate_unused_types: true,
world: "app-store-sys-v0",
additional_derives: [serde::Deserialize, serde::Serialize],
});
/// internal worker protocol
#[derive(Debug, Serialize, Deserialize)]
pub enum FTWorkerProtocol {
Ready,
Finished,
}
const CHUNK_SIZE: u64 = 262144; // 256KB
call_init!(init);
fn init(our: Address) {
@ -28,150 +33,321 @@ fn init(our: Address) {
panic!("ft_worker: got bad init message");
};
let command = serde_json::from_slice::<FTWorkerCommand>(&body)
.expect("ft_worker: got unparseable init message");
let Some(result) = (match command {
FTWorkerCommand::Send {
target,
file_name,
timeout,
} => Some(handle_send(&our, &target, &file_name, timeout)),
FTWorkerCommand::Receive {
file_name,
total_chunks,
timeout,
..
} => handle_receive(parent_process, &file_name, total_chunks, timeout),
}) else {
return;
};
Response::new()
.body(serde_json::to_vec(&result).unwrap())
.send()
.unwrap();
// job is done
}
fn handle_send(our: &Address, target: &Address, file_name: &str, timeout: u64) -> FTWorkerResult {
let transfer_id: u64 = our.process().parse().unwrap();
let Some(blob) = get_blob() else {
println!("ft_worker: wasn't given blob!");
return FTWorkerResult::Err(TransferError::SourceFailed);
};
let file_bytes = blob.bytes;
let mut file_size = file_bytes.len() as u64;
let mut offset: u64 = 0;
let chunk_size: u64 = 1048576; // 1MB, can be changed
let total_chunks = (file_size as f64 / chunk_size as f64).ceil() as u64;
// send a file to another worker
// start by telling target to expect a file,
// then upon reciving affirmative response,
// send contents in chunks and wait for
// acknowledgement.
let Ok(Ok(response)) = Request::to(target.clone())
.body(
serde_json::to_vec(&FTWorkerCommand::Receive {
transfer_id,
file_name: file_name.to_string(),
file_size,
total_chunks,
timeout,
})
.unwrap(),
)
.send_and_await_response(timeout)
else {
return FTWorkerResult::Err(TransferError::TargetOffline);
};
let opp_worker = response.source();
let Ok(FTWorkerProtocol::Ready) = serde_json::from_slice(&response.body()) else {
return FTWorkerResult::Err(TransferError::TargetRejected);
};
// send file in chunks
loop {
if file_size < chunk_size {
// this is the last chunk, so we should expect a Finished response
let _ = Request::to(opp_worker.clone())
.body(vec![])
.blob(LazyLoadBlob {
mime: None,
bytes: file_bytes[offset as usize..offset as usize + file_size as usize]
.to_vec(),
})
.expects_response(timeout)
.send();
break;
}
let _ = Request::to(opp_worker.clone())
.body(vec![])
.blob(LazyLoadBlob {
mime: None,
bytes: file_bytes[offset as usize..offset as usize + chunk_size as usize].to_vec(),
})
.send();
file_size -= chunk_size;
offset += chunk_size;
if parent_process.node() != our.node() {
panic!("ft_worker: got bad init message source");
}
// killswitch timer, 2 minutes. sender or receiver gets killed/cleaned up.
timer::set_timer(120000, None);
let start = std::time::Instant::now();
let req: DownloadRequests =
serde_json::from_slice(&body).expect("ft_worker: got unparseable init message");
match req {
DownloadRequests::LocalDownload(local_request) => {
let LocalDownloadRequest {
package_id,
desired_version_hash,
..
} = local_request;
match handle_receiver(
&parent_process,
&package_id.to_process_lib(),
&desired_version_hash,
) {
Ok(_) => print_to_terminal(
1,
&format!(
"ft_worker: receive downloaded package in {}ms",
start.elapsed().as_millis()
),
),
Err(e) => print_to_terminal(1, &format!("ft_worker: receive error: {}", e)),
}
}
DownloadRequests::RemoteDownload(remote_request) => {
let RemoteDownloadRequest {
package_id,
desired_version_hash,
worker_address,
} = remote_request;
match handle_sender(
&worker_address,
&package_id.to_process_lib(),
&desired_version_hash,
) {
Ok(_) => print_to_terminal(
1,
&format!(
"ft_worker: sent package to {} in {}ms",
worker_address,
start.elapsed().as_millis()
),
),
Err(e) => print_to_terminal(1, &format!("ft_worker: send error: {}", e)),
}
}
_ => println!("ft_worker: got unexpected message"),
}
// now wait for Finished response
let Ok(Message::Response { body, .. }) = await_message() else {
return FTWorkerResult::Err(TransferError::TargetRejected);
};
let Ok(FTWorkerProtocol::Finished) = serde_json::from_slice(&body) else {
return FTWorkerResult::Err(TransferError::TargetRejected);
};
// return success to parent
return FTWorkerResult::SendSuccess;
}
fn handle_receive(
parent_process: Address,
file_name: &str,
total_chunks: u64,
timeout: u64,
) -> Option<FTWorkerResult> {
// send Ready response to counterparty
Response::new()
.body(serde_json::to_vec(&FTWorkerProtocol::Ready).unwrap())
.send()
.unwrap();
// receive a file from a worker, then send it to parent
// all messages will be chunks of file. when we receive the
// last chunk, send a Finished message to sender and Success to parent.
let mut file_bytes = Vec::new();
let mut chunks_received = 0;
let start_time = std::time::Instant::now();
fn handle_sender(worker: &str, package_id: &PackageId, version_hash: &str) -> anyhow::Result<()> {
let target_worker = Address::from_str(worker)?;
let filename = format!(
"/app_store:sys/downloads/{}:{}/{}.zip",
package_id.package_name, package_id.publisher_node, version_hash
);
let mut file = open_file(&filename, false, None)?;
let size = file.metadata()?.len;
let num_chunks = (size as f64 / CHUNK_SIZE as f64).ceil() as u64;
Request::new()
.body(serde_json::to_vec(&DownloadRequests::Size(SizeUpdate {
package_id: package_id.clone().into(),
size,
}))?)
.target(target_worker.clone())
.send()?;
file.seek(SeekFrom::Start(0))?;
for i in 0..num_chunks {
send_chunk(&mut file, i, size, &target_worker, package_id, version_hash)?;
}
Ok(())
}
fn handle_receiver(
parent_process: &Address,
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 package_dir = open_or_create_dir(&format!(
"/app_store:sys/downloads/{}:{}/",
package_id.package_name,
package_id.publisher(),
))?;
let timer_address = Address::from_str("our@timer:distro:sys")?;
let mut file = open_or_create_file(&format!("{}{}.zip", &package_dir.path, version_hash))?;
let mut size: Option<u64> = None;
let mut hasher = Sha256::new();
loop {
let Ok(Message::Request { .. }) = await_message() else {
return Some(FTWorkerResult::Err(TransferError::SourceFailed));
};
if start_time.elapsed().as_secs() > timeout {
return Some(FTWorkerResult::Err(TransferError::SourceFailed));
let message = await_message()?;
if *message.source() == timer_address {
return Ok(());
}
let Some(blob) = get_blob() else {
return Some(FTWorkerResult::Err(TransferError::SourceFailed));
let Message::Request { body, .. } = message else {
return Err(anyhow::anyhow!("ft_worker: got bad message"));
};
chunks_received += 1;
file_bytes.extend(blob.bytes);
if chunks_received == total_chunks {
let req: DownloadRequests = serde_json::from_slice(&body)?;
match req {
DownloadRequests::Chunk(chunk) => {
handle_chunk(&mut file, &chunk, parent_process, &mut size, &mut hasher)?;
if let Some(s) = size {
if chunk.offset + chunk.length >= s {
let recieved_hash = format!("{:x}", hasher.finalize());
if recieved_hash != version_hash {
print_to_terminal(
1,
&format!(
"ft_worker: {} hash mismatch: desired: {} != actual: {}",
package_id.to_string(),
version_hash,
recieved_hash
),
);
let req = DownloadCompleteRequest {
package_id: package_id.clone().into(),
version_hash: version_hash.to_string(),
error: Some(DownloadError::HashMismatch(HashMismatch {
desired: version_hash.to_string(),
actual: recieved_hash,
})),
};
Request::new()
.body(serde_json::to_vec(&DownloadRequests::DownloadComplete(
req,
))?)
.target(parent_process.clone())
.send()?;
}
let manifest_filename =
format!("{}{}.json", package_dir.path, version_hash);
let contents = file.read()?;
extract_and_write_manifest(&contents, &manifest_filename)?;
Request::new()
.body(serde_json::to_vec(&DownloadRequests::DownloadComplete(
DownloadCompleteRequest {
package_id: package_id.clone().into(),
version_hash: version_hash.to_string(),
error: None,
},
))?)
.target(parent_process.clone())
.send()?;
return Ok(());
}
}
}
DownloadRequests::Size(update) => {
size = Some(update.size);
}
_ => println!("ft_worker: got unexpected message"),
}
}
}
fn send_chunk(
file: &mut File,
chunk_index: u64,
total_size: u64,
target: &Address,
package_id: &PackageId,
version_hash: &str,
) -> anyhow::Result<()> {
let offset = chunk_index * CHUNK_SIZE;
let length = CHUNK_SIZE.min(total_size - offset);
let mut buffer = vec![0; length as usize];
// this extra seek might be unnecessary. fix multireads per process in vfs
file.seek(SeekFrom::Start(offset))?;
file.read_at(&mut buffer)?;
Request::new()
.body(serde_json::to_vec(&DownloadRequests::Chunk(
ChunkRequest {
package_id: package_id.clone().into(),
version_hash: version_hash.to_string(),
offset,
length,
},
))?)
.target(target.clone())
.blob_bytes(buffer)
.send()?;
Ok(())
}
fn handle_chunk(
file: &mut File,
chunk: &ChunkRequest,
parent: &Address,
size: &mut Option<u64>,
hasher: &mut Sha256,
) -> 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);
if let Some(total_size) = size {
// let progress = ((chunk.offset + chunk.length) as f64 / *total_size as f64 * 100.0) as u64;
Request::new()
.body(serde_json::to_vec(&DownloadRequests::Progress(
ProgressUpdate {
package_id: chunk.package_id.clone(),
downloaded: chunk.offset + chunk.length,
total: *total_size,
version_hash: chunk.version_hash.clone(),
},
))?)
.target(parent.clone())
.send()?;
}
Ok(())
}
fn extract_and_write_manifest(file_contents: &[u8], manifest_path: &str) -> anyhow::Result<()> {
let reader = std::io::Cursor::new(file_contents);
let mut archive = zip::ZipArchive::new(reader)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
if file.name() == "manifest.json" {
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let manifest_file = open_or_create_file(&manifest_path)?;
manifest_file.write(contents.as_bytes())?;
print_to_terminal(1, "Extracted and wrote manifest.json");
break;
}
}
// send Finished message to sender
Response::new()
.body(serde_json::to_vec(&FTWorkerProtocol::Finished).unwrap())
.send()
.unwrap();
// send Success message to parent
Request::to(parent_process)
.body(serde_json::to_vec(&FTWorkerResult::ReceiveSuccess(file_name.to_string())).unwrap())
.blob(LazyLoadBlob {
mime: None,
bytes: file_bytes,
})
.send()
.unwrap();
None
Ok(())
}
/// helper function for vfs files, open if exists, if not create
fn open_or_create_file(path: &str) -> anyhow::Result<File> {
match open_file(path, false, None) {
Ok(file) => Ok(file),
Err(_) => match open_file(path, true, None) {
Ok(file) => Ok(file),
Err(_) => Err(anyhow::anyhow!("could not create file")),
},
}
}
/// helper function for vfs directories, open if exists, if not create
fn open_or_create_dir(path: &str) -> anyhow::Result<Directory> {
match open_dir(path, true, None) {
Ok(dir) => Ok(dir),
Err(_) => match open_dir(path, false, None) {
Ok(dir) => Ok(dir),
Err(_) => Err(anyhow::anyhow!("could not create dir")),
},
}
}
impl crate::kinode::process::main::PackageId {
pub fn to_process_lib(&self) -> kinode_process_lib::PackageId {
kinode_process_lib::PackageId::new(&self.package_name, &self.publisher_node)
}
pub fn from_process_lib(package_id: &kinode_process_lib::PackageId) -> Self {
Self {
package_name: package_id.package_name.clone(),
publisher_node: package_id.publisher_node.clone(),
}
}
}
// Conversion from wit PackageId to process_lib's PackageId
impl From<crate::kinode::process::downloads::PackageId> for kinode_process_lib::PackageId {
fn from(package_id: crate::kinode::process::downloads::PackageId) -> Self {
kinode_process_lib::PackageId::new(&package_id.package_name, &package_id.publisher_node)
}
}
// Conversion from process_lib's PackageId to wit PackageId
impl From<kinode_process_lib::PackageId> for crate::kinode::process::downloads::PackageId {
fn from(package_id: kinode_process_lib::PackageId) -> Self {
Self {
package_name: package_id.package_name,
publisher_node: package_id.publisher_node,
}
}
}

View File

@ -8,7 +8,7 @@ simulation-mode = []
[dependencies]
anyhow = "1.0"
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.8.0" }
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.9.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wit-bindgen = "0.24.0"

View File

@ -1,4 +1,6 @@
use crate::kinode::process::main::{InstallResponse, LocalRequest, LocalResponse};
use crate::kinode::process::main::{
InstallPackageRequest, InstallResponse, LocalRequest, LocalResponse,
};
use kinode_process_lib::{
await_next_message_body, call_init, println, Address, Message, PackageId, Request,
};
@ -18,28 +20,35 @@ fn init(our: Address) {
};
let arg = String::from_utf8(body).unwrap_or_default();
let args: Vec<&str> = arg.split_whitespace().collect();
if arg.is_empty() {
println!("install: 1 argument required, the package id of the app");
println!("example: install app:publisher.os");
if args.len() != 2 {
println!(
"install: 2 arguments required, the package id of the app and desired version_hash"
);
println!("example: install app:publisher.os f5d374ab50e66888a7c2332b22d0f909f2e3115040725cfab98dcae488916990");
return;
};
}
let Ok(package_id) = arg.parse::<PackageId>() else {
let Ok(package_id) = args[0].parse::<PackageId>() else {
println!("install: invalid package id, make sure to include package name and publisher");
println!("example: app_name:publisher_name");
return;
};
let version_hash = args[1].to_string();
let Ok(Ok(Message::Response { body, .. })) =
Request::to((our.node(), ("main", "app_store", "sys")))
.body(
serde_json::to_vec(&LocalRequest::Install(
crate::kinode::process::main::PackageId {
serde_json::to_vec(&LocalRequest::Install(InstallPackageRequest {
package_id: crate::kinode::process::main::PackageId {
package_name: package_id.package_name.clone(),
publisher_node: package_id.publisher_node.clone(),
},
))
version_hash,
metadata: None,
}))
.unwrap(),
)
.send_and_await_response(5)

View File

@ -1,4 +1,59 @@
[
{
"process_name": "downloads",
"process_wasm_path": "/downloads.wasm",
"on_exit": "Restart",
"request_networking": true,
"request_capabilities": [
"http_client:distro:sys",
"http_server:distro:sys",
"main:app_store:sys",
"chain:app_store:sys",
"vfs:distro:sys",
{
"process": "vfs:distro:sys",
"params": {
"root": true
}
}
],
"grant_capabilities": [
"http_server:distro:sys",
"vfs:distro:sys",
"http_client:distro:sys"
],
"public": false
},
{
"process_name": "chain",
"process_wasm_path": "/chain.wasm",
"on_exit": "Restart",
"request_networking": true,
"request_capabilities": [
"main:app_store:sys",
"downloads:app_store:sys",
"vfs:distro:sys",
"kns_indexer:kns_indexer:sys",
"eth:distro:sys",
"http_server:distro:sys",
"http_client:distro:sys",
{
"process": "vfs:distro:sys",
"params": {
"root": true
}
}
],
"grant_capabilities": [
"http_server:distro:sys",
"kns_indexer:kns_indexer:sys",
"vfs:distro:sys",
"http_client:distro:sys",
"eth:distro:sys",
"timer:distro:sys"
],
"public": false
},
{
"process_name": "main",
"process_wasm_path": "/app_store.wasm",
@ -11,6 +66,8 @@
"http_server:distro:sys",
"http_client:distro:sys",
"net:distro:sys",
"downloads:app_store:sys",
"chain:app_store:sys",
"vfs:distro:sys",
"kernel:distro:sys",
"eth:distro:sys",
@ -33,6 +90,7 @@
],
"grant_capabilities": [
"eth:distro:sys",
"net:distro:sys",
"http_client:distro:sys",
"http_server:distro:sys",
"kns_indexer:kns_indexer:sys",
@ -41,4 +99,4 @@
],
"public": false
}
]
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,25 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<!-- This sets window.our.node -->
<script src="/our.js"></script>
<title>Package Store</title>
<meta charset="utf-8" />
<meta http-equiv="pragma" content="no-cache" />
<meta http-equiv="cache-control" content="no-cache" />
<link rel="icon"
href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzc5IiBoZWlnaHQ9IjUxNCIgdmlld0JveD0iMCAwIDc3OSA1MTQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICA8c3R5bGU+CiAgICAgICAgQG1lZGlhIChwcmVmZXJzLWNvbG9yLXNjaGVtZTogZGFyaykgewogICAgICAgICAgICBzdmcgeyBmaWxsOiB3aGl0ZTsgfQogICAgICAgIH0KICAgICAgICBAbWVkaWEgKHByZWZlcnMtY29sb3Itc2NoZW1lOiBsaWdodCkgewogICAgICAgICAgICBzdmcgeyBmaWxsOiBibGFjazsgfQogICAgICAgIH0KICAgIDwvc3R5bGU+CiAgICA8cGF0aCBkPSJNNzUzLjA5MiA1LjkxOTMyQzc1Ni41NTcgNS4wOTk3NiA3NTUuOTYyIC0wLjAwMDEyMjA3IDc1Mi40MDEgLTAuMDAwMTIyMDdINDI2LjAwMUM0MjQuNzU1IC0wLjAwMDEyMjA3IDQyMy42MzkgMC43NzAyNyA0MjMuMTk3IDEuOTM1MzVMMjM2Ljk2OCA0OTIuNkMyMzUuNzI5IDQ5NS44NjUgMjQwLjEyMyA0OTguMjU1IDI0Mi4xOTEgNDk1LjQ0MUw1NjkuMzU3IDUwLjExMzJDNTY5Ljc3OCA0OS41MzkyIDU3MC4zOTEgNDkuMTMzOSA1NzEuMDg0IDQ4Ljk3TDc1My4wOTIgNS45MTkzMloiLz4KICAgIDxwYXRoIGQ9Ik0xMS45NjY1IDQwLjIyODhDOS4xMDk0OSAzOC43NzcgMTAuMjEzNSAzNC40NTgzIDEzLjQxNjcgMzQuNTU1N0w0MDQuMjczIDQ2LjQzNjdDNDA2LjMzNCA0Ni40OTkzIDQwNy43MTkgNDguNTc0OSA0MDYuOTg2IDUwLjUwMjNMMzQ3LjQzOCAyMDYuOTgxQzM0Ni44MDQgMjA4LjY0NyAzNDQuODY1IDIwOS4zOTYgMzQzLjI3NSAyMDguNTg4TDExLjk2NjUgNDAuMjI4OFoiLz4KPC9zdmc+Cg==">
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1.00001, viewport-fit=cover" />
<script type="module" crossorigin src="/main:app_store:sys/assets/index-I5kjLT9f.js"></script>
<link rel="stylesheet" crossorigin href="/main:app_store:sys/assets/index-fGthT1qI.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -1,18 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

View File

@ -1 +1 @@
npm run build:copy && cd ~/kinode && cargo +nightly build -p kinode && cd kinode/packages/app_store/ui
npm install && npm run build:copy

View File

@ -1,22 +0,0 @@
{
"name": "Chat Template",
"subtitle": "The chat template from kit",
"description": "The kit chat template is the default app when starting a new kit project. This app is the basic version of that, packaged for the app store.",
"image": "https://st4.depositphotos.com/7662228/30134/v/450/depositphotos_301343880-stock-illustration-best-chat-speech-bubble-icon.jpg",
"version": "0.1.2",
"license": "MIT",
"website": "https://kinode.org",
"screenshots": [
"https://pongo-uploads.s3.us-east-2.amazonaws.com/Screenshot+2024-01-30+at+10.01.46+PM.png",
"https://pongo-uploads.s3.us-east-2.amazonaws.com/Screenshot+2024-01-30+at+10.01.52+PM.png"
],
"mirrors": [
"odinsbadeye.os"
],
"versions": [
"a2c584bf63a730efdc79ec0a3c93bc97eba4e8745c633e3abe090b4f7e270e92",
"c13f7ae39fa7f652164cfc1db305cd864cc1dc5f33827a2d74f7dde70ef36662",
"09d24205d8e1f3634448e881db200b88ad691bbdaabbccb885b225147ba4a93e",
"733be24324802a35944a73f355595f781de65d9d6e393bdabe879edcb77dfb62"
]
}

View File

@ -5,10 +5,11 @@
<!-- This sets window.our.node -->
<script src="/our.js"></script>
<title>Package Store</title>
<title>App Store</title>
<meta charset="utf-8" />
<meta http-equiv="pragma" content="no-cache" />
<meta http-equiv="cache-control" content="no-cache" />
<link rel="stylesheet" href="/kinode.css">
<link rel="icon"
href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzc5IiBoZWlnaHQ9IjUxNCIgdmlld0JveD0iMCAwIDc3OSA1MTQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICA8c3R5bGU+CiAgICAgICAgQG1lZGlhIChwcmVmZXJzLWNvbG9yLXNjaGVtZTogZGFyaykgewogICAgICAgICAgICBzdmcgeyBmaWxsOiB3aGl0ZTsgfQogICAgICAgIH0KICAgICAgICBAbWVkaWEgKHByZWZlcnMtY29sb3Itc2NoZW1lOiBsaWdodCkgewogICAgICAgICAgICBzdmcgeyBmaWxsOiBibGFjazsgfQogICAgICAgIH0KICAgIDwvc3R5bGU+CiAgICA8cGF0aCBkPSJNNzUzLjA5MiA1LjkxOTMyQzc1Ni41NTcgNS4wOTk3NiA3NTUuOTYyIC0wLjAwMDEyMjA3IDc1Mi40MDEgLTAuMDAwMTIyMDdINDI2LjAwMUM0MjQuNzU1IC0wLjAwMDEyMjA3IDQyMy42MzkgMC43NzAyNyA0MjMuMTk3IDEuOTM1MzVMMjM2Ljk2OCA0OTIuNkMyMzUuNzI5IDQ5NS44NjUgMjQwLjEyMyA0OTguMjU1IDI0Mi4xOTEgNDk1LjQ0MUw1NjkuMzU3IDUwLjExMzJDNTY5Ljc3OCA0OS41MzkyIDU3MC4zOTEgNDkuMTMzOSA1NzEuMDg0IDQ4Ljk3TDc1My4wOTIgNS45MTkzMloiLz4KICAgIDxwYXRoIGQ9Ik0xMS45NjY1IDQwLjIyODhDOS4xMDk0OSAzOC43NzcgMTAuMjEzNSAzNC40NTgzIDEzLjQxNjcgMzQuNTU1N0w0MDQuMjczIDQ2LjQzNjdDNDA2LjMzNCA0Ni40OTkzIDQwNy43MTkgNDguNTc0OSA0MDYuOTg2IDUwLjUwMjNMMzQ3LjQzOCAyMDYuOTgxQzM0Ni44MDQgMjA4LjY0NyAzNDQuODY1IDIwOS4zOTYgMzQzLjI3NSAyMDguNTg4TDExLjk2NjUgNDAuMjI4OFoiLz4KPC9zdmc+Cg==">
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />

File diff suppressed because it is too large Load Diff

View File

@ -8,38 +8,27 @@
"start": "vite --port 3000",
"build": "tsc && vite build",
"copy": "mkdir -p ../pkg/ui && rm -rf ../pkg/ui/* && cp -r dist/* ../pkg/ui/",
"build:copy": "npm run tc && npm run build && npm run copy",
"build:copy": "npm run build && npm run copy",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"tc": "typechain --target ethers-v5 --out-dir src/abis/types/ \"./src/abis/**/*.json\""
"preview": "vite preview"
},
"dependencies": {
"@ethersproject/hash": "^5.7.0",
"@kinode/client-api": "^0.1.0",
"@metamask/jazzicon": "^2.0.0",
"@rainbow-me/rainbowkit": "^2.1.2",
"@szhsin/react-menu": "^4.1.0",
"@web3-react/coinbase-wallet": "^8.2.3",
"@web3-react/core": "^8.2.2",
"@web3-react/gnosis-safe": "^8.2.4",
"@web3-react/injected-connector": "^6.0.7",
"@web3-react/metamask": "^8.2.3",
"@web3-react/network": "^8.2.3",
"@web3-react/types": "^8.2.2",
"@web3-react/walletconnect": "^8.2.3",
"@web3-react/walletconnect-connector": "^6.2.13",
"@web3-react/walletconnect-v2": "^8.5.1",
"classnames": "^2.5.1",
"ethers": "^5.7.2",
"@tanstack/react-query": "^5.45.1",
"idna-uts46-hx": "^6.0.4",
"js-sha3": "^0.9.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.0.1",
"react-router-dom": "^6.21.3",
"tailwindcss": "^3.4.3",
"unocss": "^0.59.0-beta.1",
"viem": "^2.15.1",
"wagmi": "^2.10.3",
"zustand": "^4.4.7"
},
"devDependencies": {
"@typechain/ethers-v5": "^11.1.1",
"@types/node": "^20.10.4",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
@ -52,6 +41,7 @@
"http-proxy-middleware": "^2.0.6",
"typechain": "^8.3.1",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vite-plugin-node-polyfills": "^0.22.0"
}
}

View File

@ -1,4 +0,0 @@
<svg width="779" height="514" viewBox="0 0 779 514" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M753.092 5.91932C756.557 5.09976 755.962 -0.00012207 752.401 -0.00012207H426.001C424.755 -0.00012207 423.639 0.77027 423.197 1.93535L236.968 492.6C235.729 495.865 240.123 498.255 242.191 495.441L569.357 50.1132C569.778 49.5392 570.391 49.1339 571.084 48.97L753.092 5.91932Z" fill="#FFF5D9"/>
<path d="M11.9665 40.2288C9.10949 38.777 10.2135 34.4583 13.4167 34.5557L404.273 46.4367C406.334 46.4993 407.719 48.5749 406.986 50.5023L347.438 206.981C346.804 208.647 344.865 209.396 343.275 208.588L11.9665 40.2288Z" fill="#FFF5D9"/>
</svg>

Before

Width:  |  Height:  |  Size: 644 B

View File

@ -1,127 +1,34 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import { Web3ReactProvider, Web3ReactHooks } from '@web3-react/core';
import type { MetaMask } from '@web3-react/metamask'
import { PackageStore, PackageStore__factory } from "./abis/types";
import Header from "./components/Header";
import { APP_DETAILS_PATH, DOWNLOAD_PATH, MY_DOWNLOADS_PATH, PUBLISH_PATH, STORE_PATH } from "./constants/path";
import StorePage from "./pages/StorePage";
import MyAppsPage from "./pages/MyAppsPage";
import AppPage from "./pages/AppPage";
import { APP_DETAILS_PATH, MY_APPS_PATH, PUBLISH_PATH, STORE_PATH } from "./constants/path";
import { ChainId, PACKAGE_STORE_ADDRESSES } from "./constants/chain";
import DownloadPage from "./pages/DownloadPage";
import PublishPage from "./pages/PublishPage";
import { hooks as metaMaskHooks, metaMask } from './utils/metamask'
import MyDownloadsPage from "./pages/MyDownloadsPage";
const connectors: [MetaMask, Web3ReactHooks][] = [
[metaMask, metaMaskHooks],
]
declare global {
interface ImportMeta {
env: {
VITE_OPTIMISM_RPC_URL: string;
VITE_SEPOLIA_RPC_URL: string;
BASE_URL: string;
VITE_NODE_URL?: string;
DEV: boolean;
};
}
interface Window {
our: {
node: string;
process: string;
};
}
}
const {
useProvider,
} = metaMaskHooks;
const RPC_URL = import.meta.env.VITE_OPTIMISM_RPC_URL;
const BASE_URL = import.meta.env.BASE_URL;
if (window.our) window.our.process = BASE_URL?.replace("/", "");
const PROXY_TARGET = `${import.meta.env.VITE_NODE_URL || "http://localhost:8080"
}${BASE_URL}`;
// This env also has BASE_URL which should match the process + package name
const WEBSOCKET_URL = import.meta.env.DEV // eslint-disable-line
? `${PROXY_TARGET.replace("http", "ws")}`
: undefined;
function App() {
const provider = useProvider();
const [nodeConnected, setNodeConnected] = useState(true); // eslint-disable-line
const [packageAbi, setPackageAbi] = useState<PackageStore | undefined>(undefined);
useEffect(() => {
if (!provider) return;
const updatePackageAbi = async () => {
const network = await provider.getNetwork();
if (network.chainId === ChainId.OPTIMISM) {
setPackageAbi(PackageStore__factory.connect(
PACKAGE_STORE_ADDRESSES[ChainId.OPTIMISM],
provider.getSigner())
);
}
};
updatePackageAbi();
}, [provider])
useEffect(() => {
// if (window.our?.node && window.our?.process) {
// const api = new KinodeClientApi({
// uri: WEBSOCKET_URL,
// nodeId: window.our.node,
// processId: window.our.process,
// onOpen: (_event, _api) => {
// console.log("Connected to Kinode");
// // api.send({ data: "Hello World" });
// },
// onMessage: (json, _api) => {
// console.log('UNEXPECTED WEBSOCKET MESSAGE', json)
// },
// });
// setApi(api);
// } else {
// setNodeConnected(false);
// }
}, []);
if (!nodeConnected) {
return (
<div className="flex flex-col c">
<h2 style={{ color: "red" }}>Node not connected</h2>
<h4>
You need to start a node at {PROXY_TARGET} before you can use this UI
in development.
</h4>
</div>
);
}
const props = { provider, packageAbi };
return (
<div className="flex flex-col c h-screen w-screen max-h-screen max-w-screen overflow-x-hidden special-appstore-background">
<Web3ReactProvider connectors={connectors}>
<Router basename={BASE_URL}>
<Routes>
<Route path={STORE_PATH} element={<StorePage />} />
<Route path={MY_APPS_PATH} element={<MyAppsPage />} />
<Route path={`${APP_DETAILS_PATH}/:id`} element={<AppPage />} />
<Route path={PUBLISH_PATH} element={<PublishPage {...props} />} />
</Routes>
</Router>
</Web3ReactProvider>
</div>
<div>
<Router basename={BASE_URL}>
<Header />
<Routes>
<Route path={STORE_PATH} element={<StorePage />} />
<Route path={MY_DOWNLOADS_PATH} element={<MyDownloadsPage />} />
<Route path={`${APP_DETAILS_PATH}/:id`} element={<AppPage />} />
<Route path={PUBLISH_PATH} element={<PublishPage />} />
<Route path={`${DOWNLOAD_PATH}/:id`} element={<DownloadPage />} />
</Routes>
</Router>
</div >
);
}

View File

@ -1,978 +0,0 @@
[
{
"type": "function",
"name": "UPGRADE_INTERFACE_VERSION",
"inputs": [],
"outputs": [
{
"name": "",
"type": "string",
"internalType": "string"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "approve",
"inputs": [
{
"name": "to",
"type": "address",
"internalType": "address"
},
{
"name": "tokenId",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "apps",
"inputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [
{
"name": "packageName",
"type": "string",
"internalType": "string"
},
{
"name": "publisherKnsNodeId",
"type": "bytes32",
"internalType": "bytes32"
},
{
"name": "metadataUrl",
"type": "string",
"internalType": "string"
},
{
"name": "metadataHash",
"type": "bytes32",
"internalType": "bytes32"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "balanceOf",
"inputs": [
{
"name": "owner",
"type": "address",
"internalType": "address"
}
],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "contractURI",
"inputs": [],
"outputs": [
{
"name": "",
"type": "string",
"internalType": "string"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "getApproved",
"inputs": [
{
"name": "tokenId",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "getInitializedVersion",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint64",
"internalType": "uint64"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "getPackageId",
"inputs": [
{
"name": "packageName",
"type": "string",
"internalType": "string"
},
{
"name": "publisherName",
"type": "bytes",
"internalType": "bytes"
}
],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "pure"
},
{
"type": "function",
"name": "getPackageInfo",
"inputs": [
{
"name": "package",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [
{
"name": "",
"type": "tuple",
"internalType": "struct IKinodeAppStore.PackageInfo",
"components": [
{
"name": "packageName",
"type": "string",
"internalType": "string"
},
{
"name": "publisherKnsNodeId",
"type": "bytes32",
"internalType": "bytes32"
},
{
"name": "metadataUrl",
"type": "string",
"internalType": "string"
},
{
"name": "metadataHash",
"type": "bytes32",
"internalType": "bytes32"
}
]
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "getPackageInfo",
"inputs": [
{
"name": "packageName",
"type": "string",
"internalType": "string"
},
{
"name": "publisherName",
"type": "bytes",
"internalType": "bytes"
}
],
"outputs": [
{
"name": "",
"type": "tuple",
"internalType": "struct IKinodeAppStore.PackageInfo",
"components": [
{
"name": "packageName",
"type": "string",
"internalType": "string"
},
{
"name": "publisherKnsNodeId",
"type": "bytes32",
"internalType": "bytes32"
},
{
"name": "metadataUrl",
"type": "string",
"internalType": "string"
},
{
"name": "metadataHash",
"type": "bytes32",
"internalType": "bytes32"
}
]
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "initialize",
"inputs": [
{
"name": "_knsResolver",
"type": "address",
"internalType": "contract KNSRegistryResolver"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "isApprovedForAll",
"inputs": [
{
"name": "owner",
"type": "address",
"internalType": "address"
},
{
"name": "operator",
"type": "address",
"internalType": "address"
}
],
"outputs": [
{
"name": "",
"type": "bool",
"internalType": "bool"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "knsResolver",
"inputs": [],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "contract KNSRegistryResolver"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "name",
"inputs": [],
"outputs": [
{
"name": "",
"type": "string",
"internalType": "string"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "owner",
"inputs": [],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "ownerOf",
"inputs": [
{
"name": "tokenId",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "proxiableUUID",
"inputs": [],
"outputs": [
{
"name": "",
"type": "bytes32",
"internalType": "bytes32"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "registerApp",
"inputs": [
{
"name": "packageName",
"type": "string",
"internalType": "string"
},
{
"name": "publisherName",
"type": "bytes",
"internalType": "bytes"
},
{
"name": "metadataUrl",
"type": "string",
"internalType": "string"
},
{
"name": "metadataHash",
"type": "bytes32",
"internalType": "bytes32"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "renounceOwnership",
"inputs": [],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "safeTransferFrom",
"inputs": [
{
"name": "from",
"type": "address",
"internalType": "address"
},
{
"name": "to",
"type": "address",
"internalType": "address"
},
{
"name": "tokenId",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "safeTransferFrom",
"inputs": [
{
"name": "from",
"type": "address",
"internalType": "address"
},
{
"name": "to",
"type": "address",
"internalType": "address"
},
{
"name": "tokenId",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "data",
"type": "bytes",
"internalType": "bytes"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "setApprovalForAll",
"inputs": [
{
"name": "operator",
"type": "address",
"internalType": "address"
},
{
"name": "approved",
"type": "bool",
"internalType": "bool"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "supportsInterface",
"inputs": [
{
"name": "interfaceId",
"type": "bytes4",
"internalType": "bytes4"
}
],
"outputs": [
{
"name": "",
"type": "bool",
"internalType": "bool"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "symbol",
"inputs": [],
"outputs": [
{
"name": "",
"type": "string",
"internalType": "string"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "tokenURI",
"inputs": [
{
"name": "tokenId",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [
{
"name": "",
"type": "string",
"internalType": "string"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "transferFrom",
"inputs": [
{
"name": "from",
"type": "address",
"internalType": "address"
},
{
"name": "to",
"type": "address",
"internalType": "address"
},
{
"name": "tokenId",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "transferOwnership",
"inputs": [
{
"name": "newOwner",
"type": "address",
"internalType": "address"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "unlistPacakge",
"inputs": [
{
"name": "package",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "updateContractURI",
"inputs": [
{
"name": "uri",
"type": "string",
"internalType": "string"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "updateMetadata",
"inputs": [
{
"name": "package",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "metadataUrl",
"type": "string",
"internalType": "string"
},
{
"name": "metadataHash",
"type": "bytes32",
"internalType": "bytes32"
}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "upgradeToAndCall",
"inputs": [
{
"name": "newImplementation",
"type": "address",
"internalType": "address"
},
{
"name": "data",
"type": "bytes",
"internalType": "bytes"
}
],
"outputs": [],
"stateMutability": "payable"
},
{
"type": "event",
"name": "AppMetadataUpdated",
"inputs": [
{
"name": "package",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
},
{
"name": "metadataUrl",
"type": "string",
"indexed": false,
"internalType": "string"
},
{
"name": "metadataHash",
"type": "bytes32",
"indexed": false,
"internalType": "bytes32"
}
],
"anonymous": false
},
{
"type": "event",
"name": "AppRegistered",
"inputs": [
{
"name": "package",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
},
{
"name": "packageName",
"type": "string",
"indexed": false,
"internalType": "string"
},
{
"name": "publisherName",
"type": "bytes",
"indexed": false,
"internalType": "bytes"
},
{
"name": "metadataUrl",
"type": "string",
"indexed": false,
"internalType": "string"
},
{
"name": "metadataHash",
"type": "bytes32",
"indexed": false,
"internalType": "bytes32"
}
],
"anonymous": false
},
{
"type": "event",
"name": "Approval",
"inputs": [
{
"name": "owner",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "approved",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "tokenId",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
}
],
"anonymous": false
},
{
"type": "event",
"name": "ApprovalForAll",
"inputs": [
{
"name": "owner",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "operator",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "approved",
"type": "bool",
"indexed": false,
"internalType": "bool"
}
],
"anonymous": false
},
{
"type": "event",
"name": "Initialized",
"inputs": [
{
"name": "version",
"type": "uint64",
"indexed": false,
"internalType": "uint64"
}
],
"anonymous": false
},
{
"type": "event",
"name": "OwnershipTransferred",
"inputs": [
{
"name": "previousOwner",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "newOwner",
"type": "address",
"indexed": true,
"internalType": "address"
}
],
"anonymous": false
},
{
"type": "event",
"name": "Transfer",
"inputs": [
{
"name": "from",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "to",
"type": "address",
"indexed": true,
"internalType": "address"
},
{
"name": "tokenId",
"type": "uint256",
"indexed": true,
"internalType": "uint256"
}
],
"anonymous": false
},
{
"type": "event",
"name": "Upgraded",
"inputs": [
{
"name": "implementation",
"type": "address",
"indexed": true,
"internalType": "address"
}
],
"anonymous": false
},
{
"type": "error",
"name": "AddressEmptyCode",
"inputs": [
{
"name": "target",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ERC1967InvalidImplementation",
"inputs": [
{
"name": "implementation",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ERC1967NonPayable",
"inputs": []
},
{
"type": "error",
"name": "ERC721IncorrectOwner",
"inputs": [
{
"name": "sender",
"type": "address",
"internalType": "address"
},
{
"name": "tokenId",
"type": "uint256",
"internalType": "uint256"
},
{
"name": "owner",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ERC721InsufficientApproval",
"inputs": [
{
"name": "operator",
"type": "address",
"internalType": "address"
},
{
"name": "tokenId",
"type": "uint256",
"internalType": "uint256"
}
]
},
{
"type": "error",
"name": "ERC721InvalidApprover",
"inputs": [
{
"name": "approver",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ERC721InvalidOperator",
"inputs": [
{
"name": "operator",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ERC721InvalidOwner",
"inputs": [
{
"name": "owner",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ERC721InvalidReceiver",
"inputs": [
{
"name": "receiver",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ERC721InvalidSender",
"inputs": [
{
"name": "sender",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "ERC721NonexistentToken",
"inputs": [
{
"name": "tokenId",
"type": "uint256",
"internalType": "uint256"
}
]
},
{
"type": "error",
"name": "FailedInnerCall",
"inputs": []
},
{
"type": "error",
"name": "InvalidInitialization",
"inputs": []
},
{
"type": "error",
"name": "NotInitializing",
"inputs": []
},
{
"type": "error",
"name": "OwnableInvalidOwner",
"inputs": [
{
"name": "owner",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "OwnableUnauthorizedAccount",
"inputs": [
{
"name": "account",
"type": "address",
"internalType": "address"
}
]
},
{
"type": "error",
"name": "UUPSUnauthorizedCallContext",
"inputs": []
},
{
"type": "error",
"name": "UUPSUnsupportedProxiableUUID",
"inputs": [
{
"name": "slot",
"type": "bytes32",
"internalType": "bytes32"
}
]
},
{
"type": "error",
"name": "Unauthorized",
"inputs": []
}
]

View File

@ -0,0 +1,60 @@
import { multicallAbi, kimapAbi, mechAbi, KIMAP, MULTICALL, KINO_ACCOUNT_IMPL } from "./";
import { encodeFunctionData, encodePacked, stringToHex } from "viem";
export function encodeMulticalls(metadataUri: string, metadataHash: string) {
const metadataHashCall = encodeFunctionData({
abi: kimapAbi,
functionName: 'note',
args: [
encodePacked(["bytes"], [stringToHex("~metadata-hash")]),
encodePacked(["bytes"], [stringToHex(metadataHash)]),
]
})
const metadataUriCall = encodeFunctionData({
abi: kimapAbi,
functionName: 'note',
args: [
encodePacked(["bytes"], [stringToHex("~metadata-uri")]),
encodePacked(["bytes"], [stringToHex(metadataUri)]),
]
})
const calls = [
{ target: KIMAP, callData: metadataHashCall },
{ target: KIMAP, callData: metadataUriCall },
];
const multicall = encodeFunctionData({
abi: multicallAbi,
functionName: 'aggregate',
args: [calls]
});
return multicall;
}
export function encodeIntoMintCall(multicalls: `0x${string}`, our_address: `0x${string}`, app_name: string) {
const initCall = encodeFunctionData({
abi: mechAbi,
functionName: 'execute',
args: [
MULTICALL,
BigInt(0),
multicalls,
1
]
});
const mintCall = encodeFunctionData({
abi: kimapAbi,
functionName: 'mint',
args: [
our_address,
encodePacked(["bytes"], [stringToHex(app_name)]),
initCall,
"0x",
KINO_ACCOUNT_IMPL,
]
})
return mintCall;
}

View File

@ -0,0 +1,24 @@
import { parseAbi } from "viem";
export { encodeMulticalls, encodeIntoMintCall } from "./helpers";
export const KIMAP: `0x${string}` = "0xcA92476B2483aBD5D82AEBF0b56701Bb2e9be658";
export const MULTICALL: `0x${string}` = "0xcA11bde05977b3631167028862bE2a173976CA11";
export const KINO_ACCOUNT_IMPL: `0x${string}` = "0x38766C70a4FB2f23137D9251a1aA12b1143fC716";
export const multicallAbi = parseAbi([
`function aggregate(Call[] calls) external payable returns (uint256 blockNumber, bytes[] returnData)`,
`struct Call { address target; bytes callData; }`,
]);
export const kimapAbi = parseAbi([
"function mint(address, bytes calldata, bytes calldata, bytes calldata, address) external returns (address tba)",
"function note(bytes calldata,bytes calldata) external returns (bytes32)",
"function get(bytes32 node) external view returns (address tokenBoundAccount, address tokenOwner, bytes memory note)",
]);
export const mechAbi = parseAbi([
"function execute(address to, uint256 value, bytes calldata data, uint8 operation) returns (bytes memory returnData)",
"function token() external view returns (uint256,address,uint256)"
])

File diff suppressed because it is too large Load Diff

View File

@ -1,44 +0,0 @@
/* Autogenerated file. Do not edit manually. */
/* tslint:disable */
/* eslint-disable */
import type { Listener } from "@ethersproject/providers";
import type { Event, EventFilter } from "ethers";
export interface TypedEvent<
TArgsArray extends Array<any> = any,
TArgsObject = any
> extends Event {
args: TArgsArray & TArgsObject;
}
export interface TypedEventFilter<_TEvent extends TypedEvent>
extends EventFilter {}
export interface TypedListener<TEvent extends TypedEvent> {
(...listenerArg: [...__TypechainArgsArray<TEvent>, TEvent]): void;
}
type __TypechainArgsArray<T> = T extends TypedEvent<infer U> ? U : never;
export interface OnEvent<TRes> {
<TEvent extends TypedEvent>(
eventFilter: TypedEventFilter<TEvent>,
listener: TypedListener<TEvent>
): TRes;
(eventName: string, listener: Listener): TRes;
}
export type MinEthersFactory<C, ARGS> = {
deploy(...a: ARGS[]): Promise<C>;
};
export type GetContractTypeFromFactory<F> = F extends MinEthersFactory<
infer C,
any
>
? C
: never;
export type GetARGsTypeFromFactory<F> = F extends MinEthersFactory<any, any>
? Parameters<F["deploy"]>
: never;

View File

@ -1,999 +0,0 @@
/* Autogenerated file. Do not edit manually. */
/* tslint:disable */
/* eslint-disable */
import { Contract, Signer, utils } from "ethers";
import type { Provider } from "@ethersproject/providers";
import type { PackageStore, PackageStoreInterface } from "../PackageStore";
const _abi = [
{
type: "function",
name: "UPGRADE_INTERFACE_VERSION",
inputs: [],
outputs: [
{
name: "",
type: "string",
internalType: "string",
},
],
stateMutability: "view",
},
{
type: "function",
name: "approve",
inputs: [
{
name: "to",
type: "address",
internalType: "address",
},
{
name: "tokenId",
type: "uint256",
internalType: "uint256",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "apps",
inputs: [
{
name: "",
type: "uint256",
internalType: "uint256",
},
],
outputs: [
{
name: "packageName",
type: "string",
internalType: "string",
},
{
name: "publisherKnsNodeId",
type: "bytes32",
internalType: "bytes32",
},
{
name: "metadataUrl",
type: "string",
internalType: "string",
},
{
name: "metadataHash",
type: "bytes32",
internalType: "bytes32",
},
],
stateMutability: "view",
},
{
type: "function",
name: "balanceOf",
inputs: [
{
name: "owner",
type: "address",
internalType: "address",
},
],
outputs: [
{
name: "",
type: "uint256",
internalType: "uint256",
},
],
stateMutability: "view",
},
{
type: "function",
name: "contractURI",
inputs: [],
outputs: [
{
name: "",
type: "string",
internalType: "string",
},
],
stateMutability: "view",
},
{
type: "function",
name: "getApproved",
inputs: [
{
name: "tokenId",
type: "uint256",
internalType: "uint256",
},
],
outputs: [
{
name: "",
type: "address",
internalType: "address",
},
],
stateMutability: "view",
},
{
type: "function",
name: "getInitializedVersion",
inputs: [],
outputs: [
{
name: "",
type: "uint64",
internalType: "uint64",
},
],
stateMutability: "view",
},
{
type: "function",
name: "getPackageId",
inputs: [
{
name: "packageName",
type: "string",
internalType: "string",
},
{
name: "publisherName",
type: "bytes",
internalType: "bytes",
},
],
outputs: [
{
name: "",
type: "uint256",
internalType: "uint256",
},
],
stateMutability: "pure",
},
{
type: "function",
name: "getPackageInfo",
inputs: [
{
name: "package",
type: "uint256",
internalType: "uint256",
},
],
outputs: [
{
name: "",
type: "tuple",
internalType: "struct IKinodeAppStore.PackageInfo",
components: [
{
name: "packageName",
type: "string",
internalType: "string",
},
{
name: "publisherKnsNodeId",
type: "bytes32",
internalType: "bytes32",
},
{
name: "metadataUrl",
type: "string",
internalType: "string",
},
{
name: "metadataHash",
type: "bytes32",
internalType: "bytes32",
},
],
},
],
stateMutability: "view",
},
{
type: "function",
name: "getPackageInfo",
inputs: [
{
name: "packageName",
type: "string",
internalType: "string",
},
{
name: "publisherName",
type: "bytes",
internalType: "bytes",
},
],
outputs: [
{
name: "",
type: "tuple",
internalType: "struct IKinodeAppStore.PackageInfo",
components: [
{
name: "packageName",
type: "string",
internalType: "string",
},
{
name: "publisherKnsNodeId",
type: "bytes32",
internalType: "bytes32",
},
{
name: "metadataUrl",
type: "string",
internalType: "string",
},
{
name: "metadataHash",
type: "bytes32",
internalType: "bytes32",
},
],
},
],
stateMutability: "view",
},
{
type: "function",
name: "initialize",
inputs: [
{
name: "_knsResolver",
type: "address",
internalType: "contract KNSRegistryResolver",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "isApprovedForAll",
inputs: [
{
name: "owner",
type: "address",
internalType: "address",
},
{
name: "operator",
type: "address",
internalType: "address",
},
],
outputs: [
{
name: "",
type: "bool",
internalType: "bool",
},
],
stateMutability: "view",
},
{
type: "function",
name: "knsResolver",
inputs: [],
outputs: [
{
name: "",
type: "address",
internalType: "contract KNSRegistryResolver",
},
],
stateMutability: "view",
},
{
type: "function",
name: "name",
inputs: [],
outputs: [
{
name: "",
type: "string",
internalType: "string",
},
],
stateMutability: "view",
},
{
type: "function",
name: "owner",
inputs: [],
outputs: [
{
name: "",
type: "address",
internalType: "address",
},
],
stateMutability: "view",
},
{
type: "function",
name: "ownerOf",
inputs: [
{
name: "tokenId",
type: "uint256",
internalType: "uint256",
},
],
outputs: [
{
name: "",
type: "address",
internalType: "address",
},
],
stateMutability: "view",
},
{
type: "function",
name: "proxiableUUID",
inputs: [],
outputs: [
{
name: "",
type: "bytes32",
internalType: "bytes32",
},
],
stateMutability: "view",
},
{
type: "function",
name: "registerApp",
inputs: [
{
name: "packageName",
type: "string",
internalType: "string",
},
{
name: "publisherName",
type: "bytes",
internalType: "bytes",
},
{
name: "metadataUrl",
type: "string",
internalType: "string",
},
{
name: "metadataHash",
type: "bytes32",
internalType: "bytes32",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "renounceOwnership",
inputs: [],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "safeTransferFrom",
inputs: [
{
name: "from",
type: "address",
internalType: "address",
},
{
name: "to",
type: "address",
internalType: "address",
},
{
name: "tokenId",
type: "uint256",
internalType: "uint256",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "safeTransferFrom",
inputs: [
{
name: "from",
type: "address",
internalType: "address",
},
{
name: "to",
type: "address",
internalType: "address",
},
{
name: "tokenId",
type: "uint256",
internalType: "uint256",
},
{
name: "data",
type: "bytes",
internalType: "bytes",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "setApprovalForAll",
inputs: [
{
name: "operator",
type: "address",
internalType: "address",
},
{
name: "approved",
type: "bool",
internalType: "bool",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "supportsInterface",
inputs: [
{
name: "interfaceId",
type: "bytes4",
internalType: "bytes4",
},
],
outputs: [
{
name: "",
type: "bool",
internalType: "bool",
},
],
stateMutability: "view",
},
{
type: "function",
name: "symbol",
inputs: [],
outputs: [
{
name: "",
type: "string",
internalType: "string",
},
],
stateMutability: "view",
},
{
type: "function",
name: "tokenURI",
inputs: [
{
name: "tokenId",
type: "uint256",
internalType: "uint256",
},
],
outputs: [
{
name: "",
type: "string",
internalType: "string",
},
],
stateMutability: "view",
},
{
type: "function",
name: "transferFrom",
inputs: [
{
name: "from",
type: "address",
internalType: "address",
},
{
name: "to",
type: "address",
internalType: "address",
},
{
name: "tokenId",
type: "uint256",
internalType: "uint256",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "transferOwnership",
inputs: [
{
name: "newOwner",
type: "address",
internalType: "address",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "unlistPacakge",
inputs: [
{
name: "package",
type: "uint256",
internalType: "uint256",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "updateContractURI",
inputs: [
{
name: "uri",
type: "string",
internalType: "string",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "updateMetadata",
inputs: [
{
name: "package",
type: "uint256",
internalType: "uint256",
},
{
name: "metadataUrl",
type: "string",
internalType: "string",
},
{
name: "metadataHash",
type: "bytes32",
internalType: "bytes32",
},
],
outputs: [],
stateMutability: "nonpayable",
},
{
type: "function",
name: "upgradeToAndCall",
inputs: [
{
name: "newImplementation",
type: "address",
internalType: "address",
},
{
name: "data",
type: "bytes",
internalType: "bytes",
},
],
outputs: [],
stateMutability: "payable",
},
{
type: "event",
name: "AppMetadataUpdated",
inputs: [
{
name: "package",
type: "uint256",
indexed: true,
internalType: "uint256",
},
{
name: "metadataUrl",
type: "string",
indexed: false,
internalType: "string",
},
{
name: "metadataHash",
type: "bytes32",
indexed: false,
internalType: "bytes32",
},
],
anonymous: false,
},
{
type: "event",
name: "AppRegistered",
inputs: [
{
name: "package",
type: "uint256",
indexed: true,
internalType: "uint256",
},
{
name: "packageName",
type: "string",
indexed: false,
internalType: "string",
},
{
name: "publisherName",
type: "bytes",
indexed: false,
internalType: "bytes",
},
{
name: "metadataUrl",
type: "string",
indexed: false,
internalType: "string",
},
{
name: "metadataHash",
type: "bytes32",
indexed: false,
internalType: "bytes32",
},
],
anonymous: false,
},
{
type: "event",
name: "Approval",
inputs: [
{
name: "owner",
type: "address",
indexed: true,
internalType: "address",
},
{
name: "approved",
type: "address",
indexed: true,
internalType: "address",
},
{
name: "tokenId",
type: "uint256",
indexed: true,
internalType: "uint256",
},
],
anonymous: false,
},
{
type: "event",
name: "ApprovalForAll",
inputs: [
{
name: "owner",
type: "address",
indexed: true,
internalType: "address",
},
{
name: "operator",
type: "address",
indexed: true,
internalType: "address",
},
{
name: "approved",
type: "bool",
indexed: false,
internalType: "bool",
},
],
anonymous: false,
},
{
type: "event",
name: "Initialized",
inputs: [
{
name: "version",
type: "uint64",
indexed: false,
internalType: "uint64",
},
],
anonymous: false,
},
{
type: "event",
name: "OwnershipTransferred",
inputs: [
{
name: "previousOwner",
type: "address",
indexed: true,
internalType: "address",
},
{
name: "newOwner",
type: "address",
indexed: true,
internalType: "address",
},
],
anonymous: false,
},
{
type: "event",
name: "Transfer",
inputs: [
{
name: "from",
type: "address",
indexed: true,
internalType: "address",
},
{
name: "to",
type: "address",
indexed: true,
internalType: "address",
},
{
name: "tokenId",
type: "uint256",
indexed: true,
internalType: "uint256",
},
],
anonymous: false,
},
{
type: "event",
name: "Upgraded",
inputs: [
{
name: "implementation",
type: "address",
indexed: true,
internalType: "address",
},
],
anonymous: false,
},
{
type: "error",
name: "AddressEmptyCode",
inputs: [
{
name: "target",
type: "address",
internalType: "address",
},
],
},
{
type: "error",
name: "ERC1967InvalidImplementation",
inputs: [
{
name: "implementation",
type: "address",
internalType: "address",
},
],
},
{
type: "error",
name: "ERC1967NonPayable",
inputs: [],
},
{
type: "error",
name: "ERC721IncorrectOwner",
inputs: [
{
name: "sender",
type: "address",
internalType: "address",
},
{
name: "tokenId",
type: "uint256",
internalType: "uint256",
},
{
name: "owner",
type: "address",
internalType: "address",
},
],
},
{
type: "error",
name: "ERC721InsufficientApproval",
inputs: [
{
name: "operator",
type: "address",
internalType: "address",
},
{
name: "tokenId",
type: "uint256",
internalType: "uint256",
},
],
},
{
type: "error",
name: "ERC721InvalidApprover",
inputs: [
{
name: "approver",
type: "address",
internalType: "address",
},
],
},
{
type: "error",
name: "ERC721InvalidOperator",
inputs: [
{
name: "operator",
type: "address",
internalType: "address",
},
],
},
{
type: "error",
name: "ERC721InvalidOwner",
inputs: [
{
name: "owner",
type: "address",
internalType: "address",
},
],
},
{
type: "error",
name: "ERC721InvalidReceiver",
inputs: [
{
name: "receiver",
type: "address",
internalType: "address",
},
],
},
{
type: "error",
name: "ERC721InvalidSender",
inputs: [
{
name: "sender",
type: "address",
internalType: "address",
},
],
},
{
type: "error",
name: "ERC721NonexistentToken",
inputs: [
{
name: "tokenId",
type: "uint256",
internalType: "uint256",
},
],
},
{
type: "error",
name: "FailedInnerCall",
inputs: [],
},
{
type: "error",
name: "InvalidInitialization",
inputs: [],
},
{
type: "error",
name: "NotInitializing",
inputs: [],
},
{
type: "error",
name: "OwnableInvalidOwner",
inputs: [
{
name: "owner",
type: "address",
internalType: "address",
},
],
},
{
type: "error",
name: "OwnableUnauthorizedAccount",
inputs: [
{
name: "account",
type: "address",
internalType: "address",
},
],
},
{
type: "error",
name: "UUPSUnauthorizedCallContext",
inputs: [],
},
{
type: "error",
name: "UUPSUnsupportedProxiableUUID",
inputs: [
{
name: "slot",
type: "bytes32",
internalType: "bytes32",
},
],
},
{
type: "error",
name: "Unauthorized",
inputs: [],
},
] as const;
export class PackageStore__factory {
static readonly abi = _abi;
static createInterface(): PackageStoreInterface {
return new utils.Interface(_abi) as PackageStoreInterface;
}
static connect(
address: string,
signerOrProvider: Signer | Provider
): PackageStore {
return new Contract(address, _abi, signerOrProvider) as PackageStore;
}
}

View File

@ -1,4 +0,0 @@
/* Autogenerated file. Do not edit manually. */
/* tslint:disable */
/* eslint-disable */
export { PackageStore__factory } from "./PackageStore__factory";

View File

@ -1,6 +0,0 @@
/* Autogenerated file. Do not edit manually. */
/* tslint:disable */
/* eslint-disable */
export type { PackageStore } from "./PackageStore";
export * as factories from "./factories";
export { PackageStore__factory } from "./factories/PackageStore__factory";

View File

@ -1,10 +0,0 @@
<svg width="122" height="81" viewBox="0 0 122 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_6_651)">
<path d="M89.3665 8.06803L121.5 0.35155L66.5111 0.320312L63.7089 7.69502L0.5 5.7032L54.0253 32.9925L36.1529 80.3203L89.3665 8.06803Z" fill="#FFF5D9"/>
</g>
<defs>
<clipPath id="clip0_6_651">
<rect width="121" height="80" fill="white" transform="translate(0.5 0.320312)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 431 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#FFF5D9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>

Before

Width:  |  Height:  |  Size: 188 B

View File

@ -1,65 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import { AppInfo } from "../types/Apps";
import UpdateButton from "./UpdateButton";
import DownloadButton from "./DownloadButton";
import InstallButton from "./InstallButton";
import LaunchButton from "./LaunchButton";
import { FaCheck } from "react-icons/fa6";
import classNames from "classnames";
interface ActionButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
app: AppInfo;
isIcon?: boolean;
permitMultiButton?: boolean;
launchPath?: string
}
export default function ActionButton({ app, launchPath = '', isIcon = false, permitMultiButton = false, ...props }: ActionButtonProps) {
const { installed, downloaded, updatable } = useMemo(() => {
const versions = Object.entries(app?.metadata?.properties?.code_hashes || {});
const latestHash = (versions.find(([v]) => v === app.metadata?.properties?.current_version) || [])[1];
const installed = app.installed;
const downloaded = Boolean(app.state);
const updatable =
Boolean(app.state?.our_version && latestHash) &&
app.state?.our_version !== latestHash &&
app.publisher !== (window as any).our.node;
return {
installed,
downloaded,
updatable,
};
}, [app]);
return (
<>
{/* if it's got a UI and it's updatable, show both buttons if we have space (launch will otherwise push out update) */}
{permitMultiButton && installed && updatable && launchPath && <UpdateButton app={app} {...props} isIcon={isIcon} />}
{(installed && launchPath)
? <LaunchButton app={app} {...props} isIcon={isIcon} launchPath={launchPath} />
: (installed && updatable)
? <UpdateButton app={app} {...props} isIcon={isIcon} />
: !downloaded
? <DownloadButton app={app} {...props} isIcon={isIcon} />
: !installed
? <InstallButton app={app} {...props} isIcon={isIcon} />
: isIcon
? <button
className="pointer-events none icon clear absolute top-0 right-0"
>
<FaCheck />
</button>
: <></>
// <button
// onClick={() => { }}
// {...props as any}
// className={classNames("clear pointer-events-none", props.className)}
// >
// Installed
// </button>
}
</>
);
}

View File

@ -1,61 +0,0 @@
import React from "react";
import AppHeader from "./AppHeader";
import ActionButton from "./ActionButton";
import { AppInfo } from "../types/Apps";
import { appId } from "../utils/app";
import { isMobileCheck } from "../utils/dimensions";
import classNames from "classnames";
import { useNavigate } from "react-router-dom";
import { APP_DETAILS_PATH } from "../constants/path";
import MoreActions from "./MoreActions";
interface AppEntryProps extends React.HTMLAttributes<HTMLDivElement> {
app: AppInfo;
size?: "small" | "medium" | "large";
overrideImageSize?: "small" | "medium" | "large";
showMoreActions?: boolean;
launchPath?: string;
}
export default function AppEntry({ app, size = "medium", overrideImageSize, showMoreActions, launchPath, ...props }: AppEntryProps) {
const isMobile = isMobileCheck()
const navigate = useNavigate()
return (
<div
{...props}
key={appId(app)}
className={classNames("flex justify-between rounded-lg hover:bg-white/10 card cursor-pointer", props.className, {
'flex-wrap gap-2': isMobile,
'flex-col relative': size !== 'large'
})}
onClick={() => {
if (!showMoreActions) {
navigate(`/${APP_DETAILS_PATH}/${appId(app)}`)
}
}}
>
<AppHeader app={app} size={size} overrideImageSize={overrideImageSize} />
<div className={classNames("flex items-center", {
'absolute': size !== 'large',
'top-2 right-2': size !== 'large' && showMoreActions,
'top-0 right-0': size !== 'large' && !showMoreActions,
'ml-auto': size === 'large' && isMobile,
'min-w-1/5': size === 'large'
})}>
<ActionButton
app={app}
launchPath={launchPath}
isIcon={!showMoreActions && size !== 'large'}
className={classNames({
'bg-orange text-lg': size === 'large',
'mr-2': showMoreActions,
'w-full': size === 'large'
})}
/>
{showMoreActions && <MoreActions app={app} className="self-stretch" />}
</div>
</div>
);
}

View File

@ -1,85 +0,0 @@
import React from "react";
import { AppInfo } from "../types/Apps";
import { appId } from "../utils/app";
import classNames from "classnames";
import ColorDot from "./ColorDot";
import { isMobileCheck } from "../utils/dimensions";
import AppIconPlaceholder from './AppIconPlaceholder'
interface AppHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
app: AppInfo;
size?: "small" | "medium" | "large";
overrideImageSize?: "small" | "medium" | "large"
}
export default function AppHeader({
app,
size = "medium",
overrideImageSize,
...props
}: AppHeaderProps) {
const isMobile = isMobileCheck()
const appName = <div
className={classNames({
'text-3xl font-[OpenSans]': !isMobile && size === 'large',
'text-xl': !isMobile && size !== 'large',
'text-lg': isMobile
})}
>
{app.metadata?.name || appId(app)}
</div>
const imageSize = overrideImageSize || size
return <div
{...props}
className={classNames('flex w-full justify-content-start', size, props.className, {
'flex-col': size === 'small',
'gap-2': isMobile,
'gap-4': !isMobile,
'gap-6': !isMobile && size === 'large'
})}
>
{size === 'small' && appName}
{app.metadata?.image
? <img
src={app.metadata.image}
alt="app icon"
className={classNames('object-cover', {
'rounded': !imageSize,
'rounded-md': imageSize === 'small',
'rounded-lg': imageSize === 'medium',
'rounded-2xl': imageSize === 'large',
'h-32': imageSize === 'large' || imageSize === 'small',
'h-20': imageSize === 'medium',
})}
/>
: <AppIconPlaceholder
text={app.metadata_hash || app.state?.our_version?.toString() || ''}
size={imageSize}
/>}
<div className={classNames("flex flex-col", {
'gap-2': isMobile,
'gap-4 max-w-3/4': isMobile && size !== 'small'
})}>
{size !== 'small' && appName}
{app.metadata?.description && (
<div
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
className={classNames({
'text-2xl': size === 'large'
})}
>
{app.metadata.description}
</div>
)}
</div>
</div>
}

View File

@ -1,26 +0,0 @@
import React from 'react';
import { isMobileCheck } from '../utils/dimensions';
import classNames from 'classnames';
const AppIconPlaceholder: React.FC<{ text: string, className?: string, size: 'small' | 'medium' | 'large' }> = ({ text, className, size }) => {
const index = text.split('').pop()?.toUpperCase() || '0'
const derivedFilename = `/icons/${index}`
if (!derivedFilename) {
return null
}
const isMobile = isMobileCheck()
return <img
src={derivedFilename}
className={classNames('m-0 align-self-center rounded-full', {
'h-32 w-32': !isMobile && size === 'large',
'h-18 w-18': !isMobile && size === 'medium',
'h-12 w-12': isMobile || size === 'small',
}, className)}
/>
}
export default AppIconPlaceholder

View File

@ -1,32 +0,0 @@
import React from "react";
import { FaCheck } from "react-icons/fa6";
export default function Checkbox({
readOnly = false,
checked,
setChecked,
}: {
readOnly?: boolean;
checked: boolean;
setChecked?: (checked: boolean) => void;
}) {
return (
<div className="relative">
<input
type="checkbox"
id="checked"
name="checked"
checked={checked}
onChange={(e) => setChecked && setChecked(e.target.checked)}
autoFocus
readOnly={readOnly}
/>
{checked && (
<FaCheck
className="absolute left-1 top-1 cursor-pointer"
onClick={() => setChecked && setChecked(false)}
/>
)}
</div>
);
}

View File

@ -1,56 +0,0 @@
import classNames from 'classnames'
import React from 'react'
import { hexToRgb, hslToRgb, rgbToHex, rgbToHsl } from '../utils/colors'
import { isMobileCheck } from '../utils/dimensions'
interface ColorDotProps extends React.HTMLAttributes<HTMLSpanElement> {
num: string,
dotSize?: 'small' | 'medium' | 'large'
}
const ColorDot: React.FC<ColorDotProps> = ({
num,
dotSize,
...props
}) => {
const isMobile = isMobileCheck()
num = num ? num : '';
while (num.length < 6) {
num = '0' + num
}
const leftHsl = rgbToHsl(hexToRgb(num.slice(0, 6)))
const rightHsl = rgbToHsl(hexToRgb(num.length > 6 ? num.slice(num.length - 6) : num))
leftHsl.s = rightHsl.s = 1
const leftColor = rgbToHex(hslToRgb(leftHsl))
const rightColor = rgbToHex(hslToRgb(rightHsl))
const angle = (parseInt(num, 16) % 360) || -45
return (
<div {...props} className={classNames('flex', props.className)}>
<div
className={classNames('m-0 align-self-center border rounded-full outline-black', {
'h-32 w-32': !isMobile && dotSize === 'large',
'h-18 w-18': !isMobile && dotSize === 'medium',
'h-12 w-12': isMobile || dotSize === 'small',
'border-4': !isMobile,
'border-2': isMobile,
})}
style={{
borderTopColor: leftColor,
borderRightColor: rightColor,
borderBottomColor: rightColor,
borderLeftColor: leftColor,
background: `linear-gradient(${angle}deg, ${leftColor} 0 50%, ${rightColor} 50% 100%)`,
filter: 'saturate(0.25)',
opacity: '0.75'
}} />
{props.children}
</div>
)
}
export default ColorDot

View File

@ -1,122 +0,0 @@
import React, { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { AppInfo } from "../types/Apps";
import useAppsStore from "../store/apps-store";
import Modal from "./Modal";
import { getAppName } from "../utils/app";
import Loader from "./Loader";
import classNames from "classnames";
import { FaDownload } from "react-icons/fa6";
interface DownloadButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
app: AppInfo;
isIcon?: boolean;
}
export default function DownloadButton({ app, isIcon = false, ...props }: DownloadButtonProps) {
const { downloadApp, getCaps, getMyApp, getMyApps } =
useAppsStore();
const [showModal, setShowModal] = useState(false);
const [mirror, setMirror] = useState(app.metadata?.properties?.mirrors?.[0] || "Other");
const [customMirror, setCustomMirror] = useState("");
const [downloading, setDownloading] = useState("");
useEffect(() => {
setMirror(app.metadata?.properties?.mirrors?.[0] || "Other");
}, [app.metadata?.properties?.mirrors]);
const onClick = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setShowModal(true);
}, [app, setShowModal, getCaps]);
const download = useCallback(async (e: FormEvent) => {
e.preventDefault();
e.stopPropagation();
const targetMirror = mirror === "Other" ? customMirror : mirror;
if (!targetMirror) {
window.alert("Please select a mirror");
return;
}
try {
setDownloading(`Downloading ${getAppName(app)}...`);
await downloadApp(app, targetMirror);
const interval = setInterval(() => {
getMyApp(app)
.then(() => {
setDownloading("");
setShowModal(false);
clearInterval(interval);
getMyApps();
})
.catch(console.log);
}, 2000);
} catch (e) {
console.error(e);
window.alert(
`Failed to download app from ${targetMirror}, please try a different mirror.`
);
setDownloading("");
}
}, [mirror, customMirror, app, downloadApp, getMyApp]);
const appName = getAppName(app);
return (
<>
<button
{...props}
type="button"
className={classNames("text-sm self-start", props.className, {
'icon clear': isIcon,
'black': !isIcon,
})}
disabled={!!downloading}
onClick={onClick}
>
{isIcon
? <FaDownload />
: downloading
? 'Downloading...'
: 'Download'}
</button>
<Modal show={showModal} hide={() => setShowModal(false)}>
{downloading ? (
<div className="flex-col-center gap-4">
<Loader msg={downloading} />
<div className="text-center">
App is downloading in the background. You can safely close this window.
</div>
</div>
) : (
<form className="flex flex-col items-center gap-2" onSubmit={download}>
<h4>Download '{appName}'</h4>
<h5>Select Mirror</h5>
<select value={mirror} onChange={(e) => setMirror(e.target.value)}>
{((app.metadata?.properties?.mirrors || []).concat(["Other"])).map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
{mirror === "Other" && (
<input
type="text"
value={customMirror}
onChange={(e) => setCustomMirror(e.target.value)}
placeholder="Mirror, i.e. 'template.os'"
className="p-1 max-w-[240px] w-full"
required
autoFocus
/>
)}
<button type="submit">
Download
</button>
</form>
)}
</Modal>
</>
);
}

View File

@ -1,23 +0,0 @@
import React from 'react';
import { FaEllipsisH } from 'react-icons/fa';
import { Menu, MenuButton } from '@szhsin/react-menu';
import classNames from 'classnames';
interface DropdownProps extends React.HTMLAttributes<HTMLDivElement> {
}
export default function Dropdown({ ...props }: DropdownProps) {
return (
<Menu
{...props}
unmountOnClose={true}
className={classNames("relative", props.className)}
direction='left'
menuButton={<MenuButton className="small">
<FaEllipsisH className='-mb-1' />
</MenuButton>}
>
{props.children}
</Menu>
)
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { STORE_PATH, PUBLISH_PATH, MY_DOWNLOADS_PATH } from '../constants/path';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { FaHome } from "react-icons/fa";
const Header: React.FC = () => {
return (
<header className="app-header">
<div className="header-left">
<nav>
<button onClick={() => window.location.href = '/'}>
<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>
</nav>
</div>
<div className="header-right">
<ConnectButton />
</div>
</header>
);
};
export default Header;

View File

@ -1,19 +0,0 @@
import classNames from "classnames";
import React from "react"
import { FaHome } from "react-icons/fa"
import { isMobileCheck } from "../utils/dimensions";
const HomeButton: React.FC = () => {
const isMobile = isMobileCheck()
return <button
className={classNames("clear absolute p-2", {
'top-2 left-2': isMobile,
'top-8 left-8': !isMobile
})}
onClick={() => window.location.href = '/'}
>
<FaHome size={24} />
</button>
}
export default HomeButton;

View File

@ -1,97 +0,0 @@
import React, { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { AppInfo } from "../types/Apps";
import useAppsStore from "../store/apps-store";
import Modal from "./Modal";
import { getAppName } from "../utils/app";
import Loader from "./Loader";
import classNames from "classnames";
import { FaI } from "react-icons/fa6";
interface InstallButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
app: AppInfo;
isIcon?: boolean;
}
export default function InstallButton({ app, isIcon = false, ...props }: InstallButtonProps) {
const { installApp, getCaps, getMyApp, getMyApps } =
useAppsStore();
const [showModal, setShowModal] = useState(false);
const [caps, setCaps] = useState<string[]>([]);
const [installing, setInstalling] = useState("");
const onClick = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
getCaps(app).then((manifest) => {
setCaps(manifest.request_capabilities);
});
setShowModal(true);
}, [app, setShowModal, getCaps]);
const install = useCallback(async () => {
try {
setInstalling(`Installing ${getAppName(app)}...`);
await installApp(app);
const interval = setInterval(() => {
getMyApp(app)
.then((app) => {
if (!app.installed) return;
setInstalling("");
setShowModal(false);
clearInterval(interval);
getMyApps();
})
.catch(console.log);
}, 2000);
} catch (e) {
console.error(e);
window.alert(`Failed to install, please try again.`);
setInstalling("");
}
}, [app, installApp, getMyApp]);
return (
<>
<button
{...props}
type="button"
className={classNames("text-sm self-start", props.className, {
'icon clear': isIcon
})}
onClick={onClick}
disabled={!!installing}
>
{isIcon
? <FaI />
: installing
? 'Installing...'
: "Install"}
</button>
<Modal show={showModal} hide={() => setShowModal(false)}>
{installing ? (
<div className="flex-col-center gap-4">
<Loader msg={installing} />
<div className="text-center">
App is installing in the background. You can safely close this window.
</div>
</div>
) : (
<div className="flex-col-center gap-2">
<h4>Approve App Permissions</h4>
<h5 className="m-0">
{getAppName(app)} needs the following permissions:
</h5>
<ul className="flex flex-col items-start">
{caps.map((cap) => (
<li key={cap}>{cap}</li>
))}
</ul>
<button type="button" onClick={install}>
Approve & Install
</button>
</div>
)}
</Modal>
</>
);
}

View File

@ -1,27 +0,0 @@
import React, { useEffect, useRef } from 'react';
import jazzicon from '@metamask/jazzicon';
interface JazziconProps extends React.HTMLAttributes<HTMLDivElement> {
address: string;
diameter?: number;
}
const Jazzicon: React.FC<JazziconProps> = ({ address, diameter = 40, ...props }) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (address && ref.current) {
const seed = parseInt(address.slice(2, 10), 16); // Derive a seed from Ethereum address
const icon = jazzicon(diameter, seed);
// Clear the current icon
ref.current.innerHTML = '';
// Append the new icon
ref.current.appendChild(icon);
}
}, [address, diameter]);
return <div {...props} ref={ref} />;
};
export default Jazzicon;

View File

@ -1,34 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { AppInfo } from "../types/Apps";
import classNames from "classnames";
import { FaPlay } from "react-icons/fa6";
interface LaunchButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
app: AppInfo;
launchPath: string;
isIcon?: boolean;
}
export default function LaunchButton({ app, launchPath, isIcon = false, ...props }: LaunchButtonProps) {
const onLaunch = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
window.location.href = `/${launchPath.replace('/', '')}`
return;
}, [app, launchPath]);
return (
<>
<button
{...props}
type="button"
className={classNames("text-sm self-start", props.className, {
'icon clear': isIcon,
'alt': !isIcon
})}
onClick={onLaunch}
>
{isIcon ? <FaPlay /> : "Launch"}
</button>
</>
);
}

View File

@ -1,15 +0,0 @@
import React from 'react'
import { FaCircleNotch } from 'react-icons/fa6'
type LoaderProps = {
msg: string
}
export default function Loader({ msg }: LoaderProps) {
return (
<div id="loading" className="flex-col-center text-center gap-4">
<h4>{msg}</h4>
<FaCircleNotch className="animate-spin rounded-full h-8 w-8" />
</div>
)
}

View File

@ -1,279 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { AppInfo } from "../types/Apps";
import { FaX } from "react-icons/fa6";
interface Props {
app?: AppInfo;
packageName: string;
publisherId: string;
goBack: () => void;
}
const VALID_VERSION_REGEX = /^\d+\.\d+\.\d+$/;
const MetadataForm = ({ app, packageName, publisherId, goBack }: Props) => {
const [formData, setFormData] = useState({
name: app?.metadata?.name || "",
description: app?.metadata?.description || "",
image: app?.metadata?.image || "",
external_url: app?.metadata?.external_url || "",
animation_url: app?.metadata?.animation_url || "",
// properties, which can come from the app itself
package_name: packageName,
current_version: "",
publisher: publisherId,
mirrors: [publisherId],
});
const [codeHashes, setCodeHashes] = useState<[string, string][]>(
Object.entries(app?.metadata?.properties?.code_hashes || {}).concat([
["", app?.state?.our_version || ""],
])
);
const handleFieldChange = (field, value) => {
setFormData({
...formData,
[field]: value,
});
};
useEffect(() => {
handleFieldChange("package_name", packageName);
}, [packageName]);
useEffect(() => {
handleFieldChange("publisher", publisherId);
}, [publisherId]);
const handleSubmit = useCallback(() => {
const code_hashes = codeHashes.reduce((acc, [version, hash]) => {
acc[version] = hash;
return acc;
}, {});
if (!VALID_VERSION_REGEX.test(formData.current_version)) {
window.alert("Current version must be in the format x.y.z");
return;
} else if (!code_hashes[formData.current_version]) {
window.alert(
`Code hashes must include current version (${formData.current_version})`
);
return;
} else if (
!Object.keys(code_hashes).reduce(
(valid, version) => valid && VALID_VERSION_REGEX.test(version),
true
)
) {
window.alert("Code hashes must be a JSON object with valid version keys");
return;
}
const jsonData = JSON.stringify({
name: formData.name,
description: formData.description,
image: formData.image,
external_url: formData.external_url,
animation_url: formData.animation_url,
properties: {
package_name: formData.package_name,
current_version: formData.current_version,
publisher: formData.publisher,
mirrors: formData.mirrors,
code_hashes,
},
});
const blob = new Blob([jsonData], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download =
formData.package_name + "_" + formData.publisher + "_metadata.json";
a.click();
URL.revokeObjectURL(url);
}, [formData, codeHashes]);
const handleClearForm = () => {
setFormData({
name: "",
description: "",
image: "",
external_url: "",
animation_url: "",
package_name: "",
current_version: "",
publisher: "",
mirrors: [],
});
setCodeHashes([]);
};
return (
<form className="flex flex-col card mt-2 gap-2">
<h4>Fill out metadata</h4>
<div className="flex flex-col w-3/4">
<label className="metadata-label">Name</label>
<input
type="text"
placeholder="Name"
value={formData.name}
onChange={(e) => handleFieldChange("name", e.target.value)}
/>
</div>
<div className="flex flex-col w-3/4">
<label className="metadata-label">Description</label>
<input
type="text"
placeholder="Description"
value={formData.description}
onChange={(e) => handleFieldChange("description", e.target.value)}
/>
</div>
<div className="flex flex-col w-3/4">
<label className="metadata-label">Image URL</label>
<input
type="text"
placeholder="Image URL"
value={formData.image}
onChange={(e) => handleFieldChange("image", e.target.value)}
/>
</div>
<div className="flex flex-col w-3/4">
<label className="metadata-label">External URL</label>
<input
type="text"
placeholder="External URL"
value={formData.external_url}
onChange={(e) => handleFieldChange("external_url", e.target.value)}
/>
</div>
<div className="flex flex-col w-3/4">
<label className="metadata-label">Animation URL</label>
<input
type="text"
placeholder="Animation URL"
value={formData.animation_url}
onChange={(e) => handleFieldChange("animation_url", e.target.value)}
/>
</div>
<div className="flex flex-col w-3/4">
<label className="metadata-label">Package Name</label>
<input
type="text"
placeholder="Package Name"
value={formData.package_name}
onChange={(e) => handleFieldChange("package_name", e.target.value)}
/>
</div>
<div className="flex flex-col w-3/4">
<label className="metadata-label">Current Version</label>
<input
type="text"
placeholder="Current Version"
value={formData.current_version}
onChange={(e) => handleFieldChange("current_version", e.target.value)}
/>
</div>
<div className="flex flex-col w-3/4">
<label className="metadata-label">Publisher</label>
<input
type="text"
placeholder="Publisher"
value={formData.publisher}
onChange={(e) => handleFieldChange("publisher", e.target.value)}
/>
</div>
<div className="flex flex-col w-3/4">
<label className="metadata-label">Mirrors (separated by commas)</label>
<input
type="text"
placeholder="Mirrors (separated by commas)"
value={formData.mirrors.join(",")}
onChange={(e) =>
handleFieldChange(
"mirrors",
e.target.value.split(",").map((m) => m.trim())
)
}
/>
</div>
<div
className="flex flex-col w-3/4 gap-2"
>
<div
className="flex gap-2 mt-0 justify-between w-full"
>
<h5 className="m-0">Code Hashes</h5>
<button
type="button"
onClick={() => setCodeHashes([...codeHashes, ["", ""]])}
className="clear"
>
Add code hash
</button>
</div>
{codeHashes.map(([version, hash], ind, arr) => (
<div
key={ind + "_code_hash"}
className="flex gap-2 mt-0 w-full"
>
<input
type="text"
placeholder="Version"
value={version}
onChange={(e) =>
setCodeHashes((prev) => {
const newHashes = [...prev];
newHashes[ind][0] = e.target.value;
return newHashes;
})
}
className="flex-1"
/>
<input
type="text"
placeholder="Hash"
value={hash}
onChange={(e) =>
setCodeHashes((prev) => {
const newHashes = [...prev];
newHashes[ind][1] = e.target.value;
return newHashes;
})
}
className="flex-5"
/>
{arr.length > 1 && (
<button
type="button"
onClick={() =>
setCodeHashes((prev) => prev.filter((_, i) => i !== ind))
}
className="icon"
>
<FaX />
</button>
)}
</div>
))}
</div>
<div className="flex gap-2 my-4">
<button type="button" onClick={handleSubmit} className="alt">
Download JSON
</button>
<button type="button" onClick={handleClearForm} className="clear">
Clear Form
</button>
<button type="button" onClick={goBack}>
Done
</button>
</div>
</form>
);
};
export default MetadataForm;

View File

@ -0,0 +1,107 @@
import React, { useState, useEffect } from 'react';
import useAppsStore from "../store";
interface MirrorSelectorProps {
packageId: string | undefined;
onMirrorSelect: (mirror: string) => void;
}
const MirrorSelector: React.FC<MirrorSelectorProps> = ({ packageId, onMirrorSelect }) => {
const { fetchListing, checkMirror } = useAppsStore();
const [selectedMirror, setSelectedMirror] = useState<string>("");
const [customMirror, setCustomMirror] = useState<string>("");
const [isCustomMirrorSelected, setIsCustomMirrorSelected] = useState(false);
const [mirrorStatuses, setMirrorStatuses] = useState<{ [mirror: string]: boolean | null | 'http' }>({});
const [availableMirrors, setAvailableMirrors] = useState<string[]>([]);
useEffect(() => {
const fetchMirrors = async () => {
if (!packageId) return;
const appData = await fetchListing(packageId);
if (!appData) return;
const mirrors = [appData.package_id.publisher_node, ...(appData.metadata?.properties?.mirrors || [])];
setAvailableMirrors(mirrors);
setSelectedMirror(appData.package_id.publisher_node);
mirrors.forEach(mirror => {
if (mirror.startsWith('http')) {
setMirrorStatuses(prev => ({ ...prev, [mirror]: 'http' }));
} else {
setMirrorStatuses(prev => ({ ...prev, [mirror]: null }));
checkMirror(mirror)
.then(status => setMirrorStatuses(prev => ({ ...prev, [mirror]: status?.is_online ?? false })))
.catch(() => setMirrorStatuses(prev => ({ ...prev, [mirror]: false })));
}
});
};
fetchMirrors();
}, [packageId, fetchListing, checkMirror]);
useEffect(() => {
onMirrorSelect(selectedMirror);
}, [selectedMirror, onMirrorSelect]);
const handleMirrorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
if (value === "custom") {
setIsCustomMirrorSelected(true);
} else {
setSelectedMirror(value);
setIsCustomMirrorSelected(false);
setCustomMirror("");
}
};
const handleSetCustomMirror = () => {
if (customMirror) {
setSelectedMirror(customMirror);
setIsCustomMirrorSelected(false);
setAvailableMirrors(prev => [...prev, customMirror]);
if (customMirror.startsWith('http')) {
setMirrorStatuses(prev => ({ ...prev, [customMirror]: 'http' }));
} else {
checkMirror(customMirror)
.then(status => setMirrorStatuses(prev => ({ ...prev, [customMirror]: status?.is_online ?? false })))
.catch(() => setMirrorStatuses(prev => ({ ...prev, [customMirror]: false })));
}
}
};
const getMirrorStatus = (mirror: string, status: boolean | null | 'http') => {
if (status === 'http') return '(HTTP)';
if (status === null) return '(checking)';
return status ? '(online)' : '(offline)';
};
return (
<div className="mirror-selector">
<select value={selectedMirror || ""} onChange={handleMirrorChange}>
<option value="">Select a mirror</option>
{availableMirrors.map((mirror, index) => (
<option key={`${mirror}-${index}`} value={mirror} disabled={mirrorStatuses[mirror] === false}>
{mirror} {getMirrorStatus(mirror, mirrorStatuses[mirror])}
</option>
))}
<option value="custom">Custom mirror</option>
</select>
{isCustomMirrorSelected && (
<div className="custom-mirror-input">
<input
type="text"
value={customMirror}
onChange={(e) => setCustomMirror(e.target.value)}
placeholder="Enter custom mirror URL"
/>
<button onClick={handleSetCustomMirror} disabled={!customMirror}>
Set Custom Mirror
</button>
</div>
)}
</div>
);
};
export default MirrorSelector;

View File

@ -1,60 +0,0 @@
import classNames from 'classnames'
import React, { MouseEvent } from 'react'
import { FaX } from 'react-icons/fa6'
export interface ModalProps extends React.HTMLAttributes<HTMLDivElement> {
show: boolean
hide: () => void
hideClose?: boolean
children: React.ReactNode,
title?: string
}
const Modal: React.FC<ModalProps> = ({
show,
hide,
hideClose = false,
title,
...props
}) => {
const dontHide = (e: MouseEvent) => {
e.stopPropagation()
}
if (!show) {
return null
}
return (
<div
className={classNames(`bg-black/25 backdrop-blur-lg fixed top-0 bottom-0 left-0 right-0 flex flex-col c z-30 min-h-[10em] min-w-[30em]`,
{ show }
)}
onClick={hide}
>
<div
{...props}
className={`flex flex-col relative bg-black/90 rounded-lg py-6 px-12 ${props.className || ''}`}
onClick={dontHide}
>
{Boolean(title) && <h4 className='mt-0 mb-2'>{title}</h4>}
{!hideClose && (
<button
className='icon absolute top-1 right-1'
onClick={hide}
>
<FaX />
</button>
)}
<div
className='flex flex-col items-center w-full'
onClick={dontHide}
>
{props.children}
</div>
</div>
</div>
)
}
export default Modal

View File

@ -1,83 +0,0 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import Dropdown from "./Dropdown";
import { AppInfo } from "../types/Apps";
import { appId } from "../utils/app";
import useAppsStore from "../store/apps-store";
import { APP_DETAILS_PATH } from "../constants/path";
interface MoreActionsProps extends React.HTMLAttributes<HTMLButtonElement> {
app: AppInfo;
className?: string;
}
export default function MoreActions({ app, className }: MoreActionsProps) {
const { uninstallApp, setMirroring, setAutoUpdate } = useAppsStore();
const navigate = useNavigate();
const downloaded = Boolean(app.state);
if (!downloaded) {
if (!app.metadata) return <></>;
return (
<Dropdown className={className}>
<div className="flex flex-col backdrop-blur-lg bg-black/10 p-2 rounded-lg relative z-10">
{app.metadata?.description && (
<button
className="my-1 whitespace-nowrap clear"
onClick={() => navigate(`/${APP_DETAILS_PATH}/${appId(app)}`)}
>
View Details
</button>
)}
{app.metadata?.external_url && (
<a
target="_blank"
href={app.metadata?.external_url}
className="mb-1 whitespace-nowrap button clear"
>
View Site
</a>
)}
</div>
</Dropdown>
);
}
return (
<Dropdown className={className}>
<div className="flex flex-col p-2 rounded-lg backdrop-blur-lg relative z-10">
<button
className="my-1 whitespace-nowrap clear"
onClick={() => navigate(`/${APP_DETAILS_PATH}/${appId(app)}`)}
>
View Details
</button>
{app.installed && (
<>
<button
className="mb-1 whitespace-nowrap clear"
onClick={() => uninstallApp(app)}
>
Uninstall
</button>
<button
className="mb-1 whitespace-nowrap clear"
onClick={() => setMirroring(app, !app.state?.mirroring)}
>
{app.state?.mirroring ? "Stop" : "Start"} Mirroring
</button>
<button
className="mb-1 whitespace-nowrap clear"
onClick={() => setAutoUpdate(app, !app.state?.auto_update)}
>
{app.state?.auto_update ? "Disable" : "Enable"} Auto Update
</button>
</>
)}
</div>
</Dropdown>
);
}

View File

@ -1,107 +0,0 @@
import React from "react";
import { useLocation, useNavigate } from "react-router-dom";
import {
FaArrowLeft,
FaDownload,
FaMagnifyingGlass,
FaUpload,
} from "react-icons/fa6";
import { MY_APPS_PATH, PUBLISH_PATH } from "../constants/path";
import classNames from "classnames";
import { isMobileCheck } from "../utils/dimensions";
import HomeButton from "./HomeButton";
import { FaHome } from "react-icons/fa";
interface SearchHeaderProps {
value?: string;
onChange?: (value: string) => void;
onBack?: () => void;
onlyMyApps?: boolean;
hideSearch?: boolean;
hidePublish?: boolean;
}
export default function SearchHeader({
value = "",
onChange = () => null,
onBack,
hideSearch = false,
hidePublish = false,
}: SearchHeaderProps) {
const navigate = useNavigate();
const location = useLocation();
const inputRef = React.useRef<HTMLInputElement>(null);
const canGoBack = location.key !== "default";
const isMyAppsPage = location.pathname === MY_APPS_PATH;
const isMobile = isMobileCheck()
return (
<div className={classNames("flex justify-between", {
"gap-4": isMobile,
"gap-8": !isMobile
})}>
{location.pathname !== '/'
? <button
className="flex flex-col c icon icon-orange"
onClick={() => {
if (onBack) {
onBack()
} else {
canGoBack ? navigate(-1) : navigate('/')
}
}}
>
<FaArrowLeft />
</button>
: isMobile
? <button
className={classNames("icon icon-orange", {
})}
onClick={() => window.location.href = '/'}
>
<FaHome />
</button>
: <></>}
{!hidePublish && <button
className="flex flex-col c icon icon-orange"
onClick={() => navigate(PUBLISH_PATH)}
>
<FaUpload />
</button>}
{!hideSearch && (
<div className="flex flex-1 rounded-md relative">
<input
type="text"
ref={inputRef}
onChange={(event) => onChange(event.target.value)}
value={value}
placeholder="Search for apps..."
className="w-full self-stretch grow"
/>
<button
className={classNames("icon border-0 absolute top-1/2 -translate-y-1/2", {
'right-2': isMobile,
'right-4': !isMobile
})}
type="button"
onClick={() => inputRef.current?.focus()}
>
<FaMagnifyingGlass />
</button>
</div>
)}
<button
className={classNames("flex c", {
"gap-4": isMobile,
"gap-8 basis-1/5": !isMobile
})}
onClick={() => (isMyAppsPage ? navigate(-1) : navigate(MY_APPS_PATH))}
>
{!isMobile && <span>My Apps</span>}
<FaDownload />
</button>
</div>
);
}

View File

@ -1,39 +0,0 @@
import { React, useState } from "react"
import classNames from 'classnames'
import { FaQuestion, FaX } from 'react-icons/fa6'
interface TooltipProps {
text: string
button?: React.ReactNode
className?: string
position?: "top" | "bottom" | "left" | "right"
}
export const Tooltip: React.FC<TooltipProps> = ({ text, button, className, position }) => {
const [showTooltip, setShowTooltip] = useState(false)
return <div className={classNames("flex place-items-center place-content-center text-sm relative cursor-pointer shrink", className)}>
<div onClick={() => setShowTooltip(!showTooltip)}>
{button || <button
className="icon ml-4"
type='button'
>
<FaQuestion />
</button>}
</div>
<div className={classNames('absolute rounded bg-black p-2 min-w-[200px] z-10',
{
"hidden": !showTooltip,
"top-8": position === "top" || !position,
"bottom-8": position === "bottom",
"right-8": position === "left",
"left-8": position === "right",
})}>
{text}
</div>
<button className={classNames("absolute bg-black icon right-0 top-0", {
"hidden": !showTooltip,
})} onClick={() => setShowTooltip(false)}>
<FaX />
</button>
</div>
}

View File

@ -1,96 +0,0 @@
import React, { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { AppInfo } from "../types/Apps";
import useAppsStore from "../store/apps-store";
import Modal from "./Modal";
import { getAppName } from "../utils/app";
import Loader from "./Loader";
import classNames from "classnames";
import { FaU } from "react-icons/fa6";
interface UpdateButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
app: AppInfo;
isIcon?: boolean;
}
export default function UpdateButton({ app, isIcon = false, ...props }: UpdateButtonProps) {
const { updateApp, getCaps, getMyApp, getMyApps } =
useAppsStore();
const [showModal, setShowModal] = useState(false);
const [caps, setCaps] = useState<string[]>([]);
const [loading, setLoading] = useState("");
const onClick = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
getCaps(app).then((manifest) => {
setCaps(manifest.request_capabilities);
});
setShowModal(true);
}, [app, setShowModal, getCaps]);
const update = useCallback(async () => {
try {
setLoading(`Updating ${getAppName(app)}...`);
await updateApp(app);
const interval = setInterval(() => {
getMyApp(app)
.then((app) => {
if (!app.installed) return;
setLoading("");
setShowModal(false);
clearInterval(interval);
getMyApps();
})
.catch(console.log);
}, 2000);
} catch (e) {
console.error(e);
window.alert(`Failed to update, please try again.`);
setLoading("");
}
}, [app, updateApp, getMyApp]);
return (
<>
<button
{...props}
type="button"
className={classNames("text-sm self-start", props.className, {
'icon clear': isIcon
})}
onClick={onClick}
>
{isIcon ? <FaU /> : 'Update'}
</button>
<Modal show={showModal} hide={() => setShowModal(false)}>
{loading ? (
<Loader msg={loading} />
) : (
<>
<h4>Approve App Permissions</h4>
<h5 className="m-0">
{getAppName(app)} needs the following permissions:
</h5>
{/* <h5>Send Messages:</h5> */}
<br />
<ul className="flex flex-col items-start">
{caps.map((cap) => (
<li key={cap}>{cap}</li>
))}
</ul>
{/* <h5>Receive Messages:</h5>
<ul>
{caps.map((cap) => (
<li key={cap}>{cap}</li>
))}
</ul> */}
<button type="button" onClick={update}>
Approve & Update
</button>
</>
)}
</Modal>
</>
);
}

View File

@ -0,0 +1,2 @@
export { default as Header } from './Header';
export { default as MirrorSelector } from './MirrorSelector';

View File

@ -1,18 +0,0 @@
export enum ChainId {
SEPOLIA = 11155111,
OPTIMISM = 10,
OPTIMISM_GOERLI = 420,
LOCAL = 1337,
}
export const SEPOLIA_OPT_HEX = '0xaa36a7';
export const OPTIMISM_OPT_HEX = '0xa';
export const SEPOLIA_OPT_INT = '11155111';
export const OPTIMISM_OPT_INT = '10';
// Optimism (for now)
export const PACKAGE_STORE_ADDRESSES = {
[ChainId.OPTIMISM]: '0x52185B6a6017E6f079B994452F234f7C2533787B',
// [ChainId.SEPOLIA]: '0x18c39eB547A0060C6034f8bEaFB947D1C16eADF1',
};

View File

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

View File

@ -0,0 +1,25 @@
declare module 'idna-uts46-hx' {
export function toAscii(domain: string, options?: object): string;
export function toUnicode(domain: string, options?: object): string;
}
declare module '@ensdomains/eth-ens-namehash' {
export function hash(name: string): string;
export function normalize(name: string): string;
}
declare interface ImportMeta {
env: {
VITE_OPTIMISM_RPC_URL: string;
VITE_SEPOLIA_RPC_URL: string;
BASE_URL: string;
VITE_NODE_URL?: string;
DEV: boolean;
};
}
declare interface Window {
our: {
node: string;
process: string;
};
}

File diff suppressed because one or more lines are too long

View File

@ -1,12 +1,44 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import '@unocss/reset/tailwind.css'
import 'uno.css'
import '@rainbow-me/rainbowkit/styles.css';
import {
getDefaultConfig,
RainbowKitProvider,
} from '@rainbow-me/rainbowkit';
import { WagmiProvider, http } from 'wagmi';
import {
optimism,
} from 'wagmi/chains';
import {
QueryClientProvider,
QueryClient,
} from "@tanstack/react-query";
import './index.css'
const config = getDefaultConfig({
appName: 'Kinode App Store',
projectId: 'KINODE_APP_STORE',
chains: [optimism],
ssr: false,
transports: {
[optimism.id]: http(),
}
});
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider showRecentTransactions={true}>
<App />
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
</React.StrictMode>,
)

View File

@ -1,178 +1,182 @@
import React, { useState, useEffect, useMemo, useCallback, ReactElement } from "react";
import React, { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { AppInfo } from "../types/Apps";
import useAppsStore from "../store/apps-store";
import ActionButton from "../components/ActionButton";
import AppHeader from "../components/AppHeader";
import SearchHeader from "../components/SearchHeader";
import { PageProps } from "../types/Page";
import { appId } from "../utils/app";
import { PUBLISH_PATH } from "../constants/path";
import HomeButton from "../components/HomeButton";
import classNames from "classnames";
import { isMobileCheck } from "../utils/dimensions";
import { FaGlobe, FaPeopleGroup, FaStar } from "react-icons/fa6";
interface AppPageProps extends PageProps { }
import { FaDownload, FaCheck, FaTimes, FaPlay, FaSpinner, FaTrash, FaSync } from "react-icons/fa";
import useAppsStore from "../store";
import { AppListing, PackageState } from "../types/Apps";
import { compareVersions } from "../utils/compareVersions";
export default function AppPage() {
// eslint-disable-line
const { myApps, listedApps, getListedApp } = useAppsStore();
const { id } = useParams();
const navigate = useNavigate();
const params = useParams();
const [app, setApp] = useState<AppInfo | undefined>(undefined);
const [launchPath, setLaunchPath] = useState('');
const { fetchListing, fetchInstalledApp, uninstallApp, setAutoUpdate } = useAppsStore();
const [app, setApp] = useState<AppListing | null>(null);
const [installedApp, setInstalledApp] = useState<PackageState | null>(null);
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isUninstalling, setIsUninstalling] = useState(false);
const [isTogglingAutoUpdate, setIsTogglingAutoUpdate] = useState(false);
useEffect(() => {
const myApp = myApps.local.find((a) => appId(a) === params.id);
if (myApp) return setApp(myApp);
if (params.id) {
const app = listedApps.find((a) => appId(a) === params.id);
if (app) {
setApp(app);
} else {
getListedApp(params.id)
.then((app) => setApp(app))
.catch(console.error);
}
}
}, [params.id, myApps, listedApps]);
const loadData = useCallback(async () => {
if (!id) return;
setIsLoading(true);
setError(null);
const goToPublish = useCallback(() => {
navigate(PUBLISH_PATH, { state: { app } });
}, [app, navigate]);
try {
const [appData, installedAppData] = await Promise.all([
fetchListing(id),
fetchInstalledApp(id)
]);
const version = useMemo(
() => app?.metadata?.properties?.current_version || "Unknown",
[app]
);
const versions = Object.entries(app?.metadata?.properties?.code_hashes || {});
const hash =
app?.state?.our_version ||
(versions[(versions.length || 1) - 1] || ["", ""])[1];
setApp(appData);
setInstalledApp(installedAppData);
const isMobile = isMobileCheck()
if (appData?.metadata?.properties?.code_hashes) {
const versions = appData.metadata.properties.code_hashes;
if (versions.length > 0) {
const latestVer = versions.reduce((latest, current) =>
compareVersions(current[0], latest[0]) > 0 ? current : latest
)[0];
setLatestVersion(latestVer);
const appDetails: Array<{ top: ReactElement, middle: ReactElement, bottom: ReactElement }> = [
// {
// top: <div className={classNames({ 'text-sm': isMobile })}>0 ratings</div>,
// middle: <span className="text-2xl">5.0</span>,
// bottom: <div className={classNames("flex-center gap-1", {
// 'text-sm': isMobile
// })}>
// <FaStar />
// <FaStar />
// <FaStar />
// <FaStar />
// <FaStar />
// </div>
// },
{
top: <div className={classNames({ 'text-sm': isMobile })}>Developer</div>,
middle: <FaPeopleGroup size={36} />,
bottom: <div className={classNames({ 'text-sm': isMobile })}>
{app?.publisher}
</div>
},
{
top: <div className={classNames({ 'text-sm': isMobile })}>Version</div>,
middle: <span className="text-2xl">{version}</span>,
bottom: <div className={classNames({ 'text-xs': isMobile })}>
{hash.slice(0, 5)}...{hash.slice(-5)}
</div>
},
{
top: <div className={classNames({ 'text-sm': isMobile })}>Mirrors</div>,
middle: <FaGlobe size={36} />,
bottom: <div className={classNames({ 'text-sm': isMobile })}>
{app?.metadata?.properties?.mirrors?.length || 0}
</div>
}
]
useEffect(() => {
fetch('/apps').then(data => data.json())
.then((data: Array<{ package_name: string, path: string }>) => {
if (Array.isArray(data)) {
const homepageAppData = data.find(otherApp => app?.package === otherApp.package_name)
if (homepageAppData) {
setLaunchPath(homepageAppData.path)
if (installedAppData) {
const installedVersion = versions.find(([_, hash]) => hash === installedAppData.our_version_hash);
if (installedVersion) {
setCurrentVersion(installedVersion[0]);
}
}
}
})
}, [app])
}
} catch (err) {
setError("Failed to load app details. Please try again.");
console.error(err);
} finally {
setIsLoading(false);
}
}, [id, fetchListing, fetchInstalledApp]);
const handleUninstall = async () => {
if (!app) return;
setIsUninstalling(true);
try {
await uninstallApp(`${app.package_id.package_name}:${app.package_id.publisher_node}`);
await loadData();
} catch (error) {
console.error('Uninstallation failed:', error);
setError(`Uninstallation failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsUninstalling(false);
}
};
const handleToggleAutoUpdate = async () => {
if (!app || !latestVersion) return;
setIsTogglingAutoUpdate(true);
try {
const newAutoUpdateState = !app.auto_update;
await setAutoUpdate(`${app.package_id.package_name}:${app.package_id.publisher_node}`, latestVersion, newAutoUpdateState);
await loadData();
} catch (error) {
console.error('Failed to toggle auto-update:', error);
setError(`Failed to toggle auto-update: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsTogglingAutoUpdate(false);
}
};
useEffect(() => {
loadData();
}, [loadData]);
const handleDownload = () => {
navigate(`/download/${id}`);
};
const handleLaunch = () => {
navigate(`/${app?.package_id.package_name}:${app?.package_id.package_name}:${app?.package_id.publisher_node}/`);
};
if (isLoading) {
return <div className="app-page"><h4>Loading app details...</h4></div>;
}
if (error) {
return <div className="app-page"><h4>{error}</h4></div>;
}
if (!app) {
return <div className="app-page"><h4>App details not found for {id}</h4></div>;
}
return (
<div className={classNames("flex flex-col w-full p-2",
{
'gap-4 max-w-screen': isMobile,
'gap-8 max-w-[900px]': !isMobile,
})}
>
{!isMobile && <HomeButton />}
<SearchHeader
value=""
onChange={() => null}
hideSearch
hidePublish
/>
<div className={classNames("flex-col-center card !rounded-3xl", {
'p-12 gap-4 grow overflow-y-auto': isMobile,
'p-24 gap-8': !isMobile,
})}>
{app ? <>
<AppHeader app={app} size={isMobile ? "medium" : "large"} />
<div className="w-5/6 h-0 border border-orange" />
<div className={classNames("flex items-start text-xl", {
'gap-4 flex-wrap': isMobile,
'gap-8': !isMobile,
})}>
{appDetails.map((detail, index) => <>
<div
className={classNames("flex-col-center gap-2 justify-between self-stretch", {
'rounded-lg bg-white/10 p-1 min-w-1/4 grow': isMobile,
'opacity-50': !isMobile,
})}
key={index}
>
{detail.top}
{detail.middle}
{detail.bottom}
</div>
{!isMobile && index !== appDetails.length - 1 && <div className="h-3/4 w-0 border border-orange self-center" />}
</>)}
</div>
{Array.isArray(app.metadata?.properties?.screenshots)
&& app.metadata?.properties.screenshots.length > 0
&& <div className="flex flex-wrap overflow-x-auto max-w-full">
{app.metadata.properties.screenshots.map(
(screenshot, index) => (
<img key={index + screenshot} src={screenshot} className="mr-2 max-h-20 max-w-full rounded border border-black" />
)
)}
</div>}
<div className={classNames("flex-center gap-2", {
'flex-col': isMobile,
})}>
<ActionButton
app={app}
launchPath={launchPath}
className={classNames("self-center bg-orange text-lg px-12")}
permitMultiButton
/>
</div>
{app.installed && app.state?.mirroring && (
<button type="button" onClick={goToPublish}>
Publish
</button>
)}
</> : <>
<h4>App details not found for </h4>
<h4>{params.id}</h4>
</>}
<section className="app-page">
<div className="app-header">
{app.metadata?.image && (
<img src={app.metadata.image} alt={app.metadata?.name || app.package_id.package_name} className="app-icon" />
)}
<div className="app-title">
<h2>{app.metadata?.name || app.package_id.package_name}</h2>
<p className="app-id">{`${app.package_id.package_name}.${app.package_id.publisher_node}`}</p>
</div>
</div>
</div>
<div className="app-description">{app.metadata?.description || "No description available"}</div>
<div className="app-info">
<ul className="detail-list">
<li>
<span>Installed:</span>
<span className="status-icon">{installedApp ? <FaCheck className="installed" /> : <FaTimes className="not-installed" />}</span>
</li>
{currentVersion && (
<li><span>Current Version:</span> <span>{currentVersion}</span></li>
)}
{latestVersion && (
<li><span>Latest Version:</span> <span>{latestVersion}</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>Auto Update:</span>
<span className="status-icon">
{app.auto_update ? <FaCheck className="installed" /> : <FaTimes className="not-installed" />}
</span>
</li>
</ul>
</div>
<div className="app-actions">
{installedApp && (
<>
<button onClick={handleLaunch} className="primary">
<FaPlay /> Launch
</button>
<button onClick={handleUninstall} className="secondary" disabled={isUninstalling}>
{isUninstalling ? <FaSpinner className="fa-spin" /> : <FaTrash />} Uninstall
</button>
<button onClick={handleToggleAutoUpdate} className="secondary" disabled={isTogglingAutoUpdate}>
{isTogglingAutoUpdate ? <FaSpinner className="fa-spin" /> : <FaSync />}
{app.auto_update ? " Disable" : " Enable"} Auto Update
</button>
</>
)}
<button onClick={handleDownload} className="primary">
<FaDownload /> Download
</button>
</div>
{app.metadata?.properties?.screenshots && (
<div className="app-screenshots">
<h3>Screenshots</h3>
<div className="screenshot-container">
{app.metadata.properties.screenshots.map((screenshot, index) => (
<img key={index} src={screenshot} alt={`Screenshot ${index + 1}`} className="app-screenshot" />
))}
</div>
</div>
)}
</section>
);
}
}

View File

@ -0,0 +1,199 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useParams } from "react-router-dom";
import { FaDownload, FaCheck, FaSpinner, FaRocket, FaChevronDown, FaChevronUp, FaTrash } from "react-icons/fa";
import useAppsStore from "../store";
import { MirrorSelector } from '../components';
export default function DownloadPage() {
const { id } = useParams<{ id: string }>();
const {
listings,
downloads,
installed,
activeDownloads,
fetchData,
downloadApp,
installApp,
removeDownload,
clearAllActiveDownloads,
} = useAppsStore();
const [showMetadata, setShowMetadata] = useState(false);
const [selectedMirror, setSelectedMirror] = useState<string>("");
const [showCapApproval, setShowCapApproval] = useState(false);
const [selectedVersion, setSelectedVersion] = useState<{ version: string, hash: string } | null>(null);
const [manifest, setManifest] = useState<any>(null);
const app = useMemo(() => listings[id || ""], [listings, id]);
const appDownloads = useMemo(() => downloads[id || ""] || [], [downloads, id]);
const installedApp = useMemo(() => installed[id || ""], [installed, id]);
useEffect(() => {
if (id) {
clearAllActiveDownloads();
fetchData(id);
}
}, [id, fetchData, clearAllActiveDownloads]);
useEffect(() => {
if (app && !selectedMirror) {
setSelectedMirror(app.package_id.publisher_node || "");
}
}, [app, selectedMirror]);
const handleDownload = useCallback((version: string, hash: string) => {
if (!id || !selectedMirror || !app) return;
downloadApp(id, hash, selectedMirror);
}, [id, selectedMirror, app, downloadApp]);
const handleInstall = useCallback((version: string, hash: string) => {
if (!id || !app) return;
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);
setSelectedVersion({ version, hash });
setShowCapApproval(true);
} catch (error) {
console.error('Failed to parse manifest:', error);
}
} else {
console.error('Manifest not found for the selected version');
}
}, [id, app, appDownloads]);
const confirmInstall = useCallback(() => {
if (!id || !selectedVersion) return;
installApp(id, selectedVersion.hash).then(() => {
fetchData(id);
setShowCapApproval(false);
setManifest(null);
});
}, [id, selectedVersion, installApp, fetchData]);
const handleRemoveDownload = useCallback((version: string, hash: string) => {
if (!id) return;
removeDownload(id, hash).then(() => fetchData(id));
}, [id, removeDownload, fetchData]);
const versionList = useMemo(() => {
if (!app || !app.metadata?.properties?.code_hashes) return [];
return app.metadata.properties.code_hashes.map(([version, hash]) => {
const download = appDownloads.find(d => d.File && d.File.name === `${hash}.zip`);
const downloadKey = `${app.package_id.package_name}:${app.package_id.publisher_node}:${hash}`;
const activeDownload = activeDownloads[downloadKey];
const isDownloaded = !!download?.File && download.File.size > 0;
const isInstalled = installedApp?.our_version_hash === hash;
const isDownloading = !!activeDownload && activeDownload.downloaded < activeDownload.total;
const progress = isDownloading ? activeDownload : { downloaded: 0, total: 100 };
console.log(`Version ${version} - isInstalled: ${isInstalled}, installedApp:`, installedApp);
return { version, hash, isDownloaded, isInstalled, isDownloading, progress };
});
}, [app, appDownloads, activeDownloads, installedApp]);
if (!app) {
return <div className="downloads-page"><h4>Loading app details...</h4></div>;
}
return (
<div className="downloads-page">
<h2>{app.metadata?.name || app.package_id.package_name}</h2>
<p>{app.metadata?.description}</p>
<MirrorSelector packageId={id} onMirrorSelect={setSelectedMirror} />
<div className="version-list">
<h3>Available Versions</h3>
{versionList.length === 0 ? (
<p>No versions available for this app.</p>
) : (
<table>
<thead>
<tr>
<th>Version</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{versionList.map(({ version, hash, isDownloaded, isInstalled, isDownloading, progress }) => (
<tr key={version}>
<td>{version}</td>
<td>
{isInstalled ? 'Installed' : isDownloaded ? 'Downloaded' : isDownloading ? 'Downloading' : 'Not downloaded'}
</td>
<td>
{!isDownloaded && !isDownloading && (
<button
onClick={() => handleDownload(version, hash)}
disabled={!selectedMirror}
className="download-button"
>
<FaDownload /> Download
</button>
)}
{isDownloading && (
<div className="download-progress">
<FaSpinner className="fa-spin" />
Downloading... {Math.round((progress.downloaded / progress.total) * 100)}%
</div>
)}
{isDownloaded && !isInstalled && (
<>
<button
onClick={() => handleInstall(version, hash)}
className="install-button"
>
<FaRocket /> Install
</button>
<button
onClick={() => handleRemoveDownload(version, hash)}
className="delete-button"
>
<FaTrash /> Delete
</button>
</>
)}
{isInstalled && <FaCheck className="installed" />}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="app-details">
<h3>App Details</h3>
<button onClick={() => setShowMetadata(!showMetadata)}>
{showMetadata ? <FaChevronUp /> : <FaChevronDown />} Metadata
</button>
{showMetadata && (
<pre>{JSON.stringify(app.metadata, null, 2)}</pre>
)}
</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}>
Approve and Install
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,112 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { FaUpload } from "react-icons/fa";
import { AppInfo, MyApps } from "../types/Apps";
import useAppsStore from "../store/apps-store";
import AppEntry from "../components/AppEntry";
import SearchHeader from "../components/SearchHeader";
import { PageProps } from "../types/Page";
import { useNavigate } from "react-router-dom";
import { appId } from "../utils/app";
import { PUBLISH_PATH } from "../constants/path";
import HomeButton from "../components/HomeButton";
import { isMobileCheck } from "../utils/dimensions";
import classNames from "classnames";
export default function MyAppsPage() { // eslint-disable-line
const { myApps, getMyApps, } = useAppsStore()
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState<string>("");
const [displayedApps, setDisplayedApps] = useState<MyApps>(myApps);
useEffect(() => {
getMyApps()
.then(setDisplayedApps)
.catch((error) => console.error(error));
}, []); // eslint-disable-line
const searchMyApps = useCallback((query: string) => {
setSearchQuery(query);
const filteredApps = Object.keys(myApps).reduce((acc, key) => {
acc[key] = myApps[key].filter((app) => {
return app.package.toLowerCase().includes(query.toLowerCase())
|| app.metadata?.description?.toLowerCase().includes(query.toLowerCase())
|| app.metadata?.description?.toLowerCase().includes(query.toLowerCase());
})
return acc
}, {
downloaded: [] as AppInfo[],
installed: [] as AppInfo[],
local: [] as AppInfo[],
system: [] as AppInfo[],
} as MyApps)
setDisplayedApps(filteredApps);
}, [myApps]);
useEffect(() => {
if (searchQuery) {
searchMyApps(searchQuery);
} else {
setDisplayedApps(myApps);
}
}, [myApps]);
const isMobile = isMobileCheck()
console.log({ myApps })
return (
<div className={classNames("flex flex-col w-full h-screen p-2",
{
'gap-4 max-w-screen': isMobile,
'gap-8 max-w-[900px]': !isMobile,
})}>
<HomeButton />
<SearchHeader value={searchQuery} onChange={searchMyApps} />
<div className="flex justify-between items-center mt-2">
<h3>My Packages</h3>
<button onClick={() => navigate(PUBLISH_PATH)}>
<FaUpload className="mr-2" />
Publish Package
</button>
</div>
<div className={classNames("flex flex-col card gap-2 mt-2",
{
'max-h-[80vh] overflow-y-scroll overflow-x-visible': !isMobile,
})}
style={{
scrollbarWidth: 'thin',
scrollbarColor: '#FFF5D9 transparent',
}}
>
{displayedApps.downloaded.length > 0 && <h4>Downloaded</h4>}
{(displayedApps.downloaded || []).map((app) => <AppEntry
key={appId(app)}
app={app}
showMoreActions
/>)}
{displayedApps.installed.length > 0 && <h4>Installed</h4>}
{(displayedApps.installed || []).map((app) => <AppEntry
key={appId(app)}
app={app}
showMoreActions
/>)}
{displayedApps.local.length > 0 && <h4>Local</h4>}
{(displayedApps.local || []).map((app) => <AppEntry
key={appId(app)}
app={app}
showMoreActions
/>)}
{displayedApps.system.length > 0 && <h4>System</h4>}
{(displayedApps.system || []).map((app) => <AppEntry
key={appId(app)}
app={app}
showMoreActions
/>)}
</div>
</div>
);
}

View File

@ -0,0 +1,207 @@
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";
export default function MyDownloadsPage() {
const { fetchDownloads, fetchDownloadsForApp, startMirroring, stopMirroring, installApp, removeDownload, fetchInstalled, installed } = useAppsStore();
const [currentPath, setCurrentPath] = useState<string[]>([]);
const [items, setItems] = useState<DownloadItem[]>([]);
const [isInstalling, setIsInstalling] = 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);
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 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');
// Construct packageId by combining currentPath and remaining parts of the filename
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>Downloads</h2>
<div className="file-explorer">
<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>
)}
{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

@ -1,94 +1,48 @@
import React, { useState, useCallback, FormEvent, useEffect } from "react";
import { useLocation } from "react-router-dom";
import { BigNumber, utils } from "ethers";
import { useWeb3React } from "@web3-react/core";
import { Link, useLocation } from "react-router-dom";
import { useAccount, useWriteContract, useWaitForTransactionReceipt, usePublicClient } from 'wagmi'
import { ConnectButton, useConnectModal } from '@rainbow-me/rainbowkit';
import { keccak256, toBytes } from 'viem';
import { mechAbi, KIMAP, encodeIntoMintCall, encodeMulticalls, kimapAbi, MULTICALL } from "../abis";
import { kinohash } from '../utils/kinohash';
import useAppsStore from "../store";
import SearchHeader from "../components/SearchHeader";
import { PageProps } from "../types/Page";
import { setChain } from "../utils/chain";
import { OPTIMISM_OPT_HEX } from "../constants/chain";
import { hooks, metaMask } from "../utils/metamask";
import Loader from "../components/Loader";
import { toDNSWireFormat } from "../utils/dnsWire";
import useAppsStore from "../store/apps-store";
import MetadataForm from "../components/MetadataForm";
import { AppInfo } from "../types/Apps";
import Checkbox from "../components/Checkbox";
import Jazzicon from "../components/Jazzicon";
import { Tooltip } from "../components/Tooltip";
import HomeButton from "../components/HomeButton";
import classNames from "classnames";
import { isMobileCheck } from "../utils/dimensions";
export default function PublishPage() {
const { openConnectModal } = useConnectModal();
const { ourApps, fetchOurApps } = useAppsStore();
const publicClient = usePublicClient();
const { useIsActivating } = hooks;
const { address, isConnected, isConnecting } = useAccount();
const { data: hash, writeContract, error } = useWriteContract();
const { isLoading: isConfirming, isSuccess: isConfirmed } =
useWaitForTransactionReceipt({
hash,
});
interface PublishPageProps extends PageProps { }
export default function PublishPage({
provider,
packageAbi,
}: PublishPageProps) {
// get state from router
const { state } = useLocation();
const { listedApps } = useAppsStore();
// TODO: figure out how to handle provider
const { account, isActive } = useWeb3React();
const isActivating = useIsActivating();
const [loading, setLoading] = useState("");
const [publishSuccess, setPublishSuccess] = useState<
{ packageName: string; publisherId: string } | undefined
>();
const [showMetadataForm, setShowMetadataForm] = useState<boolean>(false);
const [packageName, setPackageName] = useState<string>("");
const [publisherId, setPublisherId] = useState<string>(
window.our?.node || ""
); // BytesLike
const [publisherId, setPublisherId] = useState<string>(window.our?.node || "");
const [metadataUrl, setMetadataUrl] = useState<string>("");
const [metadataHash, setMetadataHash] = useState<string>(""); // BytesLike
const [isUpdate, setIsUpdate] = useState<boolean>(false);
const [myPublishedApps, setMyPublishedApps] = useState<AppInfo[]>([]);
const [metadataHash, setMetadataHash] = useState<string>("");
useEffect(() => {
const app: AppInfo | undefined = state?.app;
if (app) {
setPackageName(app.package);
setPublisherId(app.publisher);
setIsUpdate(true);
}
}, [state])
fetchOurApps();
}, [fetchOurApps]);
useEffect(() => {
setMyPublishedApps(
listedApps.filter((app) => app.owner?.toLowerCase() === account?.toLowerCase())
);
}, [listedApps, account])
const connectWallet = useCallback(async () => {
await metaMask.activate().catch(() => { });
try {
setChain(OPTIMISM_OPT_HEX);
} catch (error) {
console.error(error);
}
}, []);
const calculateMetadataHash = useCallback(async () => {
if (!metadataUrl) {
setMetadataHash("");
return;
}
try {
const metadataResponse = await fetch(metadataUrl);
const metadataText = await metadataResponse.text();
JSON.parse(metadataText); // confirm it's valid JSON
const metadataHash = utils.keccak256(utils.toUtf8Bytes(metadataText));
const metadataHash = keccak256(toBytes(metadataText));
setMetadataHash(metadataHash);
} catch (error) {
window.alert(
"Error calculating metadata hash. Please ensure the URL is valid and the metadata is in JSON format."
);
alert("Error calculating metadata hash. Please ensure the URL is valid and the metadata is in JSON format.");
}
}, [metadataUrl]);
@ -97,189 +51,142 @@ export default function PublishPage({
e.preventDefault();
e.stopPropagation();
let metadata = metadataHash;
if (!publicClient || !address) {
openConnectModal?.();
return;
}
try {
// Check if the package already exists and get its TBA
console.log('packageName, publisherId: ', packageName, publisherId)
let data = await publicClient.readContract({
abi: kimapAbi,
address: KIMAP,
functionName: 'get',
args: [kinohash(`${packageName}.${publisherId}`)]
});
let [tba, owner, _data] = data as [string, string, string];
let isUpdate = Boolean(tba && tba !== '0x' && owner === address);
let currentTBA = isUpdate ? tba as `0x${string}` : null;
console.log('currenttba, isupdate: ', currentTBA, isUpdate)
// If the package doesn't exist, check for the publisher's TBA
if (!currentTBA) {
data = await publicClient.readContract({
abi: kimapAbi,
address: KIMAP,
functionName: 'get',
args: [kinohash(publisherId)]
});
[tba, owner, _data] = data as [string, string, string];
isUpdate = false; // It's a new package, but we might have a publisher TBA
currentTBA = (tba && tba !== '0x') ? tba as `0x${string}` : null;
}
let metadata = metadataHash;
if (!metadata) {
// https://pongo-uploads.s3.us-east-2.amazonaws.com/chat_metadata.json
const metadataResponse = await fetch(metadataUrl);
await metadataResponse.json(); // confirm it's valid JSON
const metadataText = await metadataResponse.text(); // hash as text
metadata = utils.keccak256(utils.toUtf8Bytes(metadataText));
metadata = keccak256(toBytes(metadataText));
}
setLoading("Please confirm the transaction in your wallet");
const publisherIdDnsWireFormat = toDNSWireFormat(publisherId);
await setChain(OPTIMISM_OPT_HEX);
const multicall = encodeMulticalls(metadataUrl, metadata);
const args = isUpdate ? multicall : encodeIntoMintCall(multicall, address, packageName);
// TODO: have a checkbox to show if it's an update of an existing package
writeContract({
abi: mechAbi,
address: currentTBA || KIMAP,
functionName: 'execute',
args: [
isUpdate ? MULTICALL : KIMAP,
BigInt(0),
args,
isUpdate ? 1 : 0
],
gas: BigInt(1000000),
});
const tx = await (isUpdate
? packageAbi?.updateMetadata(
BigNumber.from(
utils.solidityKeccak256(
["string", "bytes"],
[packageName, publisherIdDnsWireFormat]
)
),
metadataUrl,
metadata
)
: packageAbi?.registerApp(
packageName,
publisherIdDnsWireFormat,
metadataUrl,
metadata
));
await new Promise((resolve) => setTimeout(resolve, 2000));
setLoading("Publishing package...");
await tx?.wait();
setPublishSuccess({ packageName, publisherId });
// Reset form fields
setPackageName("");
setPublisherId(window.our?.node || publisherId);
setPublisherId(window.our?.node || "");
setMetadataUrl("");
setMetadataHash("");
setIsUpdate(false);
} catch (error) {
console.error(error);
window.alert(
"Error publishing package. Please ensure the package name and publisher ID are valid, and the metadata is in JSON format."
);
} finally {
setLoading("");
}
},
[
packageName,
isUpdate,
publisherId,
metadataUrl,
metadataHash,
packageAbi,
setPublishSuccess,
setPackageName,
setPublisherId,
setMetadataUrl,
setMetadataHash,
setIsUpdate,
]
[publicClient, openConnectModal, packageName, publisherId, address, metadataUrl, metadataHash, writeContract]
);
const unpublishPackage = useCallback(
async (packageName: string, publisherName: string) => {
try {
await setChain(OPTIMISM_OPT_HEX);
if (!publicClient) {
openConnectModal?.();
return;
}
const tx = await
packageAbi?.unlistPacakge(
utils.keccak256(utils.solidityPack(
["string", "bytes"],
[packageName, toDNSWireFormat(publisherName)]
))
);
const data = await publicClient.readContract({
abi: kimapAbi,
address: KIMAP,
functionName: 'get',
args: [kinohash(`${packageName}.${publisherName}`)]
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const [tba, _owner, _data] = data as [string, string, string];
if (!tba || tba === '0x') {
console.error("No TBA found for this package");
return;
}
const multicall = encodeMulticalls("", "");
writeContract({
abi: mechAbi,
address: tba as `0x${string}`,
functionName: 'execute',
args: [
MULTICALL,
BigInt(0),
multicall,
1
],
gas: BigInt(1000000),
});
setLoading("Unlisting package...");
await tx?.wait();
} catch (error) {
console.error(error);
window.alert(
"Error unlisting package"
);
} finally {
setLoading("");
}
},
[packageAbi, setLoading]
[publicClient, openConnectModal, writeContract]
);
const checkIfUpdate = useCallback(async () => {
if (isUpdate) return;
if (
packageName &&
publisherId &&
listedApps.find(
(app) => app.package === packageName && app.publisher === publisherId
)
) {
setIsUpdate(true);
}
}, [listedApps, packageName, publisherId, isUpdate, setIsUpdate]);
const isMobile = isMobileCheck()
return (
<div className={classNames("w-full flex flex-col gap-2", {
'max-w-[900px]': !isMobile,
'p-2 h-screen w-screen': isMobile
})}>
{!isMobile && <HomeButton />}
<SearchHeader
hideSearch
hidePublish
onBack={showMetadataForm ? () => setShowMetadataForm(false) : undefined}
/>
<div className="flex-center justify-between">
<h4>Publish Package</h4>
{Boolean(account) && <div className="card flex-center">
<div className="publish-page">
<h1>Publish Package</h1>
{Boolean(address) && (
<div className="publisher-info">
<span>Publishing as:</span>
<Jazzicon address={account!} className="mx-2" />
<span className="font-mono">{account?.slice(0, 4)}...{account?.slice(-4)}</span>
</div>}
</div>
<span className="address">{address?.slice(0, 4)}...{address?.slice(-4)}</span>
</div>
)}
{loading ? (
<div className="flex-col-center">
<Loader msg={loading} />
</div>
) : publishSuccess ? (
<div className="flex-col-center gap-2">
<h4>Package Published!</h4>
<div>
<strong>Package Name:</strong> {publishSuccess.packageName}
</div>
<div>
<strong>Publisher ID:</strong> {publishSuccess.publisherId}
</div>
<button
className={`flex ml-2`}
onClick={() => setPublishSuccess(undefined)}
>
Publish Another Package
</button>
</div>
) : showMetadataForm ? (
<MetadataForm {...{ packageName, publisherId, app: state?.app }} goBack={() => setShowMetadataForm(false)} />
) : !account || !isActive ? (
{isConfirming ? (
<div className="message info">Publishing package...</div>
) : !address || !isConnected ? (
<>
<h4>Please connect your wallet {isMobile && <br />} to publish a package</h4>
<button className={`connect-wallet row`} onClick={connectWallet}>
Connect Wallet
</button>
<h4>Please connect your wallet to publish a package</h4>
<ConnectButton />
</>
) : isActivating ? (
<Loader msg="Approve connection in your wallet" />
) : isConnecting ? (
<div className="message info">Approve connection in your wallet</div>
) : (
<form
className="flex flex-col flex-1 overflow-y-auto gap-2"
onSubmit={publishPackage}
>
<div
className="flex cursor-pointer p-2 -mb-2"
onClick={() => setIsUpdate(!isUpdate)}
>
<Checkbox
checked={isUpdate} readOnly
/>
<label htmlFor="update" className="cursor-pointer ml-4">
Update existing package
</label>
</div>
<div className="flex flex-col">
<form className="publish-form" onSubmit={publishPackage}>
<div className="form-group">
<label htmlFor="package-name">Package Name</label>
<input
id="package-name"
@ -288,10 +195,9 @@ export default function PublishPage({
placeholder="my-package"
value={packageName}
onChange={(e) => setPackageName(e.target.value)}
onBlur={checkIfUpdate}
/>
</div>
<div className="flex flex-col">
<div className="form-group">
<label htmlFor="publisher-id">Publisher ID</label>
<input
id="publisher-id"
@ -299,13 +205,10 @@ export default function PublishPage({
required
value={publisherId}
onChange={(e) => setPublisherId(e.target.value)}
onBlur={checkIfUpdate}
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="metadata-url">
Metadata URL
</label>
<div className="form-group">
<label htmlFor="metadata-url">Metadata URL</label>
<input
id="metadata-url"
type="text"
@ -315,56 +218,57 @@ export default function PublishPage({
onBlur={calculateMetadataHash}
placeholder="https://github/my-org/my-repo/metadata.json"
/>
<div>
<p className="help-text">
Metadata is a JSON file that describes your package.
<br /> You can{" "}
<a onClick={() => setShowMetadataForm(true)}
className="underline cursor-pointer"
>
fill out a template here
</a>
.
</div>
</p>
</div>
<div className="flex flex-col">
<div className="form-group">
<label htmlFor="metadata-hash">Metadata Hash</label>
<input
readOnly
id="metadata-hash"
type="text"
value={metadataHash}
onChange={(e) => setMetadataHash(e.target.value)}
placeholder="Calculated automatically from metadata URL"
/>
</div>
<button type="submit">
Publish
<button type="submit" disabled={isConfirming}>
{isConfirming ? 'Publishing...' : 'Publish'}
</button>
</form>
)}
<div className="flex flex-col">
<h4>Packages You Own</h4>
{myPublishedApps.length > 0 ? (
<div className="flex flex-col">
{myPublishedApps.map((app) => (
<div key={`${app.package}${app.publisher}`} className="flex items-center justify-between">
<div className="flex items-center">
<Jazzicon address={app.publisher} className="mr-2" />
<span>{app.package}</span>
</div>
<button className="flex items-center" onClick={() => unpublishPackage(app.package, app.publisher)}>
<span>Unpublish</span>
{isConfirmed && (
<div className="message success">
Package published successfully!
</div>
)}
{error && (
<div className="message error">
Error: {error.message}
</div>
)}
<div className="my-packages">
<h2>Packages You Own</h2>
{Object.keys(ourApps).length > 0 ? (
<ul>
{Object.values(ourApps).map((app) => (
<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">
{app.metadata?.name || app.package_id.package_name}
</Link>
<button onClick={() => unpublishPackage(app.package_id.package_name, app.package_id.publisher_node)}>
Unpublish
</button>
</div>
</li>
))}
</div>
</ul>
) : (
<div className="flex items-center">
<span>No packages published</span>
</div>
<p>No packages published</p>
)}
</div>
</div>
);
}
}

View File

@ -1,239 +1,87 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { FaChevronLeft, FaChevronRight } from "react-icons/fa";
import { AppInfo } from "../types/Apps";
import useAppsStore from "../store/apps-store";
import AppEntry from "../components/AppEntry";
import SearchHeader from "../components/SearchHeader";
import { PageProps } from "../types/Page";
import { appId } from "../utils/app";
import classNames from 'classnames';
import { FaArrowRotateRight } from "react-icons/fa6";
import { isMobileCheck } from "../utils/dimensions";
import HomeButton from "../components/HomeButton";
interface StorePageProps extends PageProps { }
import React, { useState, useEffect } from "react";
import useAppsStore from "../store";
import { AppListing } from "../types/Apps";
import { Link } from "react-router-dom";
export default function StorePage() {
// eslint-disable-line
const { listedApps, getListedApps, rebuildIndex } = useAppsStore();
const [resultsSort, setResultsSort] = useState<string>("Recently published");
const { listings, fetchListings } = useAppsStore();
const [searchQuery, setSearchQuery] = useState<string>("");
const [displayedApps, setDisplayedApps] = useState<AppInfo[]>(listedApps);
const [page, setPage] = useState(1);
const [tags, setTags] = useState<string[]>([])
const [launchPaths, setLaunchPaths] = useState<{ [package_name: string]: string }>({})
const pages = useMemo(
() =>
Array.from(
{
length: Math.ceil(listedApps.length / 10)
},
(_, index) => index + 1
),
[listedApps]
);
const featuredPackageNames = ['dartfrog', 'kcal', 'memedeck', 'filter'];
useEffect(() => {
const start = (page - 1) * 10;
const end = start + 10;
setDisplayedApps(listedApps.slice(start, end));
}, [listedApps, page]);
fetchListings();
}, [fetchListings]);
// GET on load
useEffect(() => {
getListedApps()
.then((apps) => {
setDisplayedApps(Object.values(apps));
let _tags: string[] = [];
for (const app of Object.values(apps)) {
_tags = _tags.concat((app.metadata as any || {}).tags || [])
}
if (_tags.length === 0) {
_tags = ['App', 'Tags', 'Coming', 'Soon', 'tm'];
}
setTags(Array.from(new Set(_tags)))
})
.catch((error) => console.error(error));
}, []); // eslint-disable-line
const sortApps = useCallback(async (sort: string) => {
switch (sort) {
case "Recently published":
break;
case "Most popular":
break;
case "Best rating":
break;
case "Recently updated":
break;
}
}, []);
const searchApps = useCallback(
(query: string) => {
setSearchQuery(query);
const filteredApps = listedApps.filter(
(app) => {
return (
app.package.toLowerCase().includes(query.toLowerCase()) ||
app.metadata?.description
?.toLowerCase()
.includes(query.toLowerCase()) ||
app.metadata?.description
?.toLowerCase()
.includes(query.toLowerCase())
);
},
[listedApps]
);
setDisplayedApps(filteredApps);
},
[listedApps]
);
const tryRebuildIndex = useCallback(async () => {
try {
await rebuildIndex();
alert("Index rebuilt successfully.");
await getListedApps();
} catch (error) {
console.error(error);
}
}, [rebuildIndex]);
const isMobile = isMobileCheck()
useEffect(() => {
fetch('/apps').then(data => data.json())
.then((data: Array<{ package_name: string, path: string }>) => {
if (Array.isArray(data)) {
listedApps.forEach(app => {
const homepageAppData = data.find(otherApp => app.package === otherApp.package_name)
if (homepageAppData) {
setLaunchPaths({
...launchPaths,
[app.package]: homepageAppData.path
})
}
})
}
})
}, [listedApps])
// extensive temp null handling due to weird prod bug
const filteredApps = React.useMemo(() => {
if (!listings) return [];
return Object.values(listings).filter((app) => {
if (!app || !app.package_id) return false;
const nameMatch = app.package_id.package_name.toLowerCase().includes(searchQuery.toLowerCase());
const descMatch = app.metadata?.description?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
return nameMatch || descMatch;
});
}, [listings, searchQuery]);
return (
<div className={classNames("flex flex-col w-full max-h-screen p-2", {
'gap-4 max-w-screen': isMobile,
'gap-6 max-w-[900px]': !isMobile
})}>
{!isMobile && <HomeButton />}
<SearchHeader value={searchQuery} onChange={searchApps} />
<div className={classNames("flex items-center self-stretch justify-between", {
'gap-4 flex-wrap': isMobile,
'gap-8 grow': !isMobile
})}>
<button
className="flex flex-col c icon icon-orange"
onClick={tryRebuildIndex}
title="Rebuild index"
>
<FaArrowRotateRight />
</button>
{tags.slice(0, isMobile ? 3 : 6).map(tag => (
<button
key={tag}
className="clear flex c rounded-full !bg-white/10 !hover:bg-white/25"
onClick={() => {
console.log('clicked tag', tag)
}}
>
{tag}
</button>
))}
<select
value={resultsSort}
onChange={(e) => {
setResultsSort(e.target.value);
sortApps(e.target.value);
}}
className={classNames('hidden', {
'basis-1/5': !isMobile
})}
>
<option>Recently published</option>
<option>Most popular</option>
<option>Best rating</option>
<option>Recently updated</option>
</select>
<div className="store-page">
<div className="store-header">
<input
type="text"
placeholder="Search apps..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{!searchQuery && <div className={classNames("flex flex-col", {
'gap-4': !isMobile,
'grow overflow-y-auto gap-2 items-center px-2': isMobile
})}>
<h2>Featured Apps</h2>
<div className={classNames("flex gap-2", {
'flex-col': isMobile
})}>
{listedApps.filter(app => {
return featuredPackageNames.indexOf(app.package) !== -1
}).map((app) => (
<AppEntry
key={appId(app) + (app.state?.our_version || "")}
size={'medium'}
app={app}
launchPath={launchPaths[app.package]}
className={classNames("grow", {
'w-1/4': !isMobile,
'w-full': isMobile
})}
/>
))}
</div>
</div>}
<h2>{searchQuery ? 'Search Results' : 'All Apps'}</h2>
<div className={classNames("flex flex-col grow overflow-y-auto", {
'gap-2': isMobile,
'gap-4': !isMobile,
})}>
{displayedApps
.filter(app => searchQuery ? true : featuredPackageNames.indexOf(app.package) === -1)
.map(app => <AppEntry
key={appId(app) + (app.state?.our_version || "")}
size='large'
app={app}
className="self-stretch"
overrideImageSize="medium"
/>)}
<div className="app-list">
{!listings ? (
<p>Loading...</p>
) : filteredApps.length === 0 ? (
<p>No apps available.</p>
) : (
<table>
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Description</th>
<th>Publisher</th>
</tr>
</thead>
<tbody>
{filteredApps.map((app) => (
<AppRow key={`${app.package_id?.package_name}:${app.package_id?.publisher_node}`} app={app} />
))}
</tbody>
</table>
)}
</div>
{pages.length > 1 && <div className="flex flex-wrap self-center gap-2">
<button
className="icon"
onClick={() => page !== pages[0] && setPage(page - 1)}
>
<FaChevronLeft />
</button>
{pages.map((p) => (
<button
key={`page-${p}`}
className={classNames('icon', { "!bg-white/10": p === page })}
onClick={() => setPage(p)}
>
{p}
</button>
))}
<button
className="icon"
onClick={() => page !== pages[pages.length - 1] && setPage(page + 1)}
>
<FaChevronRight />
</button>
</div>}
</div>
);
}
const AppRow: React.FC<{ app: AppListing }> = ({ app }) => {
if (!app || !app.package_id) return null;
return (
<tr className="app-row">
<td>
{app.metadata?.image && (
<img
src={app.metadata.image}
alt={`${app.metadata?.name || app.package_id.package_name} icon`}
className="app-icon"
width="32"
height="32"
/>
)}
</td>
<td>
<Link to={`/app/${app.package_id.package_name}:${app.package_id.publisher_node}`} className="app-name">
{app.metadata?.name || app.package_id.package_name}
</Link>
</td>
<td>{app.metadata?.description || "No description available"}</td>
<td>{app.package_id.publisher_node}</td>
</tr>
);
};

View File

@ -1,217 +0,0 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { MyApps, AppInfo, PackageManifest } from '../types/Apps'
import { HTTP_STATUS } from '../constants/http';
import { appId, getAppType } from '../utils/app';
const BASE_URL = (import.meta as any).env.BASE_URL; // eslint-disable-line
const isApp = (a1: AppInfo, a2: AppInfo) => a1.package === a2.package && a1.publisher === a2.publisher
export interface AppsStore {
myApps: MyApps
listedApps: AppInfo[]
searchResults: AppInfo[]
query: string
getMyApps: () => Promise<MyApps>
getListedApps: () => Promise<AppInfo[]>
getMyApp: (app: AppInfo) => Promise<AppInfo>
installApp: (app: AppInfo) => Promise<void>
updateApp: (app: AppInfo) => Promise<void>
uninstallApp: (app: AppInfo) => Promise<void>
getListedApp: (packageName: string) => Promise<AppInfo>
downloadApp: (app: AppInfo, download_from: string) => Promise<void>
getCaps: (app: AppInfo) => Promise<PackageManifest>
approveCaps: (app: AppInfo) => Promise<void>
setMirroring: (info: AppInfo, mirroring: boolean) => Promise<void>
setAutoUpdate: (app: AppInfo, autoUpdate: boolean) => Promise<void>
rebuildIndex: () => Promise<void>
// searchApps: (query: string, onlyMyApps?: boolean) => Promise<AppInfo[]>
get: () => AppsStore
set: (partial: AppsStore | Partial<AppsStore>) => void
}
const useAppsStore = create<AppsStore>()(
persist(
(set, get) => ({
myApps: {
downloaded: [] as AppInfo[],
installed: [] as AppInfo[],
local: [] as AppInfo[],
system: [] as AppInfo[],
},
listedApps: [] as AppInfo[],
searchResults: [] as AppInfo[],
query: '',
getMyApps: async () => {
const listedApps = await get().getListedApps()
const res = await fetch(`${BASE_URL}/apps`)
const apps = await res.json() as AppInfo[]
const myApps = apps.reduce((acc, app) => {
const appType = getAppType(app)
if (listedApps.find(lapp => lapp.metadata_hash === app.metadata_hash)) {
console.log({ listedappmatch: app })
}
acc[appType].push(app)
return acc
}, {
downloaded: [],
installed: [],
local: [],
system: [],
} as MyApps)
set(() => ({ myApps }))
return myApps
},
getListedApps: async () => {
const res = await fetch(`${BASE_URL}/apps/listed`)
const apps = await res.json() as AppInfo[]
set({ listedApps: apps })
return apps
},
getMyApp: async (info: AppInfo) => {
const res = await fetch(`${BASE_URL}/apps/${appId(info)}`)
const app = await res.json() as AppInfo
const appType = getAppType(app)
const myApps = get().myApps
myApps[appType] = myApps[appType].map((a) => isApp(a, app) ? app : a)
const listedApps = [...get().listedApps].map((a) => isApp(a, app) ? app : a)
set({ myApps, listedApps })
return app
},
installApp: async (info: AppInfo) => {
const approveRes = await fetch(`${BASE_URL}/apps/${appId(info)}/caps`, {
method: 'POST'
})
if (approveRes.status !== HTTP_STATUS.OK) {
throw new Error(`Failed to approve caps for app: ${appId(info)}`)
}
const installRes = await fetch(`${BASE_URL}/apps/${appId(info)}`, {
method: 'POST'
})
if (installRes.status !== HTTP_STATUS.CREATED) {
throw new Error(`Failed to install app: ${appId(info)}`)
}
},
updateApp: async (app: AppInfo) => {
const res = await fetch(`${BASE_URL}/apps/${appId(app)}`, {
method: 'PUT'
})
if (res.status !== HTTP_STATUS.NO_CONTENT) {
throw new Error(`Failed to update app: ${appId(app)}`)
}
// TODO: get the app from the server instead of updating locally
},
uninstallApp: async (app: AppInfo) => {
if (!confirm(`Are you sure you want to remove ${appId(app)}?`)) return
const res = await fetch(`${BASE_URL}/apps/${appId(app)}`, {
method: 'DELETE'
})
if (res.status !== HTTP_STATUS.NO_CONTENT) {
throw new Error(`Failed to remove app: ${appId(app)}`)
}
const myApps = { ...get().myApps }
const appType = getAppType(app)
myApps[appType] = myApps[appType].filter((a) => !isApp(a, app))
const listedApps = get().listedApps.map((a) => isApp(a, app) ? { ...a, state: undefined, installed: false } : a)
set({ myApps, listedApps })
},
getListedApp: async (packageName: string) => {
const res = await fetch(`${BASE_URL}/apps/listed/${packageName}`)
if (res.status !== HTTP_STATUS.OK) {
throw new Error(`Failed to get app: ${packageName}`)
}
const app = await res.json() as AppInfo
return app
},
downloadApp: async (info: AppInfo, download_from: string) => {
const res = await fetch(`${BASE_URL}/apps/listed/${appId(info)}`, {
method: 'POST',
body: JSON.stringify({ download_from }),
})
if (res.status !== HTTP_STATUS.CREATED) {
throw new Error(`Failed to get app: ${appId(info)}`)
}
},
getCaps: async (info: AppInfo) => {
const res = await fetch(`${BASE_URL}/apps/${appId(info)}/caps`)
if (res.status !== HTTP_STATUS.OK) {
throw new Error(`Failed to get app: ${appId(info)}`)
}
const caps = await res.json() as PackageManifest[]
return caps[0]
},
approveCaps: async (info: AppInfo) => {
const res = await fetch(`${BASE_URL}/apps/${appId(info)}/caps`, {
method: 'POST'
})
if (res.status !== HTTP_STATUS.OK) {
throw new Error(`Failed to get app: ${appId(info)}`)
}
},
rebuildIndex: async () => {
const res = await fetch(`${BASE_URL}/apps/rebuild-index`, {
method: 'POST'
})
if (res.status !== HTTP_STATUS.OK) {
throw new Error('Failed to rebuild index')
}
},
setMirroring: async (info: AppInfo, mirroring: boolean) => {
const res = await fetch(`${BASE_URL}/apps/${appId(info)}/mirror`, {
method: mirroring ? 'PUT' : 'DELETE',
})
if (res.status !== HTTP_STATUS.OK) {
throw new Error(`Failed to start mirror: ${appId(info)}`)
}
get().getMyApp(info)
},
setAutoUpdate: async (info: AppInfo, autoUpdate: boolean) => {
const res = await fetch(`${BASE_URL}/apps/${appId(info)}/auto-update`, {
method: autoUpdate ? 'PUT' : 'DELETE',
})
if (res.status !== HTTP_STATUS.OK) {
throw new Error(`Failed to change auto update: ${appId(info)}`)
}
get().getMyApp(info)
},
// searchApps: async (query: string, onlyMyApps = true) => {
// if (onlyMyApps) {
// const searchResults = get().myApps.filter((app) =>
// app.name.toLowerCase().includes(query.toLowerCase())
// || app.publisher.toLowerCase().includes(query.toLowerCase())
// || app.metadata?.name?.toLowerCase()?.includes(query.toLowerCase())
// )
// set(() => ({ searchResults }))
// return searchResults
// } else {
// const res = await fetch(`${BASE_URL}/apps/search/${encodeURIComponent(query)}`)
// const searchResults = await res.json() as AppInfo[]
// set(() => ({ searchResults }))
// return searchResults
// }
// },
get,
set,
}),
{
name: 'app_store', // unique name
storage: createJSONStorage(() => sessionStorage), // (optional) by default, 'localStorage' is used
}
)
)
export default useAppsStore

View File

@ -0,0 +1,379 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { PackageState, AppListing, MirrorCheckFile, PackageManifest, DownloadItem } from '../types/Apps'
import { HTTP_STATUS } from '../constants/http'
import KinodeClientApi from "@kinode/client-api"
import { WEBSOCKET_URL } from '../utils/ws'
const BASE_URL = '/main:app_store:sys'
interface AppsStore {
listings: Record<string, AppListing>
installed: Record<string, PackageState>
downloads: Record<string, DownloadItem[]>
ourApps: AppListing[]
ws: KinodeClientApi
activeDownloads: Record<string, { downloaded: number, total: number }>
fetchData: (id: string) => Promise<void>
fetchListings: () => Promise<void>
fetchListing: (id: string) => Promise<AppListing | null>
fetchInstalled: () => Promise<void>
fetchInstalledApp: (id: string) => Promise<PackageState | null>
fetchDownloads: () => Promise<DownloadItem[]>
fetchOurApps: () => Promise<void>
fetchDownloadsForApp: (id: string) => Promise<DownloadItem[]>
checkMirror: (node: string) => Promise<MirrorCheckFile | null>
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>
approveCaps: (id: string) => Promise<void>
startMirroring: (id: string) => Promise<void>
stopMirroring: (id: string) => Promise<void>
setAutoUpdate: (id: string, version_hash: string, autoUpdate: boolean) => Promise<void>
setActiveDownload: (appId: string, downloaded: number, total: number) => void
clearActiveDownload: (appId: string) => void
clearAllActiveDownloads: () => void;
}
const useAppsStore = create<AppsStore>()(
persist(
(set, get): AppsStore => ({
listings: {},
installed: {},
downloads: {},
ourApps: [],
activeDownloads: {},
fetchData: async (id: string) => {
if (!id) return;
try {
const [listing, downloads, installedApp] = await Promise.all([
get().fetchListing(id),
get().fetchDownloadsForApp(id),
get().fetchInstalledApp(id)
]);
set((state) => ({
listings: listing ? { ...state.listings, [id]: listing } : state.listings,
downloads: { ...state.downloads, [id]: downloads },
installed: installedApp ? { ...state.installed, [id]: installedApp } : state.installed
}));
} catch (error) {
console.error("Error fetching app data:", error);
}
},
fetchListings: async () => {
try {
const res = await fetch(`${BASE_URL}/apps`);
if (res.status === HTTP_STATUS.OK) {
const data: AppListing[] = await res.json();
const listingsMap = data.reduce((acc, listing) => {
acc[`${listing.package_id.package_name}:${listing.package_id.publisher_node}`] = listing;
return acc;
}, {} as Record<string, AppListing>);
set({ listings: listingsMap });
}
} catch (error) {
console.error("Error fetching listings:", error);
}
},
fetchListing: async (id: string) => {
try {
const res = await fetch(`${BASE_URL}/apps/${id}`);
if (res.status === HTTP_STATUS.OK) {
const listing: AppListing = await res.json();
set((state) => ({
listings: { ...state.listings, [id]: listing }
}));
return listing;
}
} catch (error) {
console.error("Error fetching listing:", error);
}
return null;
},
fetchInstalled: async () => {
try {
const res = await fetch(`${BASE_URL}/installed`);
if (res.status === HTTP_STATUS.OK) {
const data: PackageState[] = await res.json();
const installedMap = data.reduce((acc, pkg) => {
acc[`${pkg.package_id.package_name}:${pkg.package_id.publisher_node}`] = pkg;
return acc;
}, {} as Record<string, PackageState>);
set({ installed: installedMap });
}
} catch (error) {
console.error("Error fetching installed apps:", error);
}
},
fetchInstalledApp: async (id: string) => {
try {
const res = await fetch(`${BASE_URL}/installed/${id}`);
if (res.status === HTTP_STATUS.OK) {
const installedApp: PackageState = await res.json();
set((state) => ({
installed: { ...state.installed, [id]: installedApp }
}));
return installedApp;
}
} catch (error) {
console.error("Error fetching installed app:", error);
}
return null;
},
fetchDownloads: async () => {
try {
const res = await fetch(`${BASE_URL}/downloads`);
if (res.status === HTTP_STATUS.OK) {
const downloads: DownloadItem[] = await res.json();
set({ downloads: { root: downloads } });
return downloads;
}
} catch (error) {
console.error("Error fetching downloads:", error);
}
return [];
},
fetchOurApps: async () => {
try {
const res = await fetch(`${BASE_URL}/ourapps`);
if (res.status === HTTP_STATUS.OK) {
const data: AppListing[] = await res.json();
set({ ourApps: data });
}
} catch (error) {
console.error("Error fetching our apps:", error);
}
},
fetchDownloadsForApp: async (id: string) => {
try {
const res = await fetch(`${BASE_URL}/downloads/${id}`);
if (res.status === HTTP_STATUS.OK) {
const downloads: DownloadItem[] = await res.json();
set((state) => ({
downloads: { ...state.downloads, [id]: downloads }
}));
return downloads;
}
} catch (error) {
console.error("Error fetching downloads for app:", error);
}
return [];
},
checkMirror: async (node: string) => {
try {
const res = await fetch(`${BASE_URL}/mirrorcheck/${node}`);
if (res.status === HTTP_STATUS.OK) {
return await res.json() as MirrorCheckFile;
}
} catch (error) {
console.error("Error checking mirror:", error);
}
return null;
},
installApp: async (id: string, version_hash: string) => {
try {
const res = await fetch(`${BASE_URL}/apps/${id}/install`, {
method: 'POST',
body: JSON.stringify({ version_hash })
});
if (res.status === HTTP_STATUS.CREATED) {
await get().fetchInstalled();
}
} catch (error) {
console.error("Error installing app:", error);
}
},
uninstallApp: async (id: string) => {
try {
const res = await fetch(`${BASE_URL}/apps/${id}`, { method: 'DELETE' });
if (res.status === HTTP_STATUS.NO_CONTENT) {
await get().fetchInstalled();
}
} catch (error) {
console.error("Error uninstalling app:", error);
}
},
downloadApp: async (id: string, version_hash: string, downloadFrom: string) => {
const [package_name, publisher_node] = id.split(':');
const appId = `${id}:${version_hash}`;
set((state) => ({
activeDownloads: {
...state.activeDownloads,
[appId]: { downloaded: 0, total: 100 }
}
}));
try {
const res = await fetch(`${BASE_URL}/apps/${id}/download`, {
method: 'POST',
body: JSON.stringify({
package_id: { package_name, publisher_node },
version_hash,
download_from: downloadFrom,
}),
});
if (res.status !== HTTP_STATUS.OK) {
get().clearActiveDownload(appId);
}
} catch (error) {
console.error("Error downloading app:", error);
get().clearActiveDownload(appId);
}
},
clearAllActiveDownloads: () => set({ activeDownloads: {} }),
removeDownload: async (packageId: string, versionHash: string) => {
try {
const response = await fetch(`${BASE_URL}/downloads/${packageId}/remove`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ version_hash: versionHash }),
});
if (response.ok) {
await get().fetchDownloadsForApp(packageId);
}
} catch (error) {
console.error('Failed to remove download:', error);
}
},
getCaps: async (id: string) => {
try {
const res = await fetch(`${BASE_URL}/apps/${id}/caps`);
if (res.status === HTTP_STATUS.OK) {
return await res.json() as PackageManifest;
}
} catch (error) {
console.error("Error getting caps:", error);
}
return null;
},
approveCaps: async (id: string) => {
try {
const res = await fetch(`${BASE_URL}/apps/${id}/caps`, { method: 'POST' });
if (res.status === HTTP_STATUS.OK) {
await get().fetchListing(id);
}
} catch (error) {
console.error("Error approving caps:", error);
}
},
startMirroring: async (id: string) => {
try {
const res = await fetch(`${BASE_URL}/downloads/${id}/mirror`, {
method: 'PUT'
});
if (res.status === HTTP_STATUS.OK) {
await get().fetchDownloadsForApp(id.split(':').slice(0, -1).join(':'));
}
} catch (error) {
console.error("Error starting mirroring:", error);
}
},
stopMirroring: async (id: string) => {
try {
const res = await fetch(`${BASE_URL}/downloads/${id}/mirror`, {
method: 'DELETE'
});
if (res.status === HTTP_STATUS.OK) {
await get().fetchDownloadsForApp(id.split(':').slice(0, -1).join(':'));
}
} catch (error) {
console.error("Error stopping mirroring:", error);
}
},
setAutoUpdate: async (id: string, version_hash: string, autoUpdate: boolean) => {
try {
const method = autoUpdate ? 'PUT' : 'DELETE';
const res = await fetch(`${BASE_URL}/apps/${id}/auto-update`, {
method,
body: JSON.stringify({ version_hash })
});
if (res.status === HTTP_STATUS.OK) {
await get().fetchListing(id);
}
} catch (error) {
console.error("Error setting auto-update:", error);
}
},
setActiveDownload: (appId, downloaded, total) => {
set((state) => ({
activeDownloads: {
...state.activeDownloads,
[appId]: { downloaded, total }
}
}));
},
clearActiveDownload: (appId) => {
set((state) => {
const { [appId]: _, ...rest } = state.activeDownloads;
return { activeDownloads: rest };
});
},
ws: new KinodeClientApi({
uri: WEBSOCKET_URL,
nodeId: (window as any).our?.node,
processId: "main:app_store:sys",
onMessage: (message) => {
console.log('WebSocket message received', message);
try {
const data = JSON.parse(message);
if (data.kind === 'progress') {
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);
} else if (data.kind === 'complete') {
const { package_id, version_hash } = data.data;
const appId = `${package_id.package_name}:${package_id.publisher_node}:${version_hash}`;
get().clearActiveDownload(appId);
get().fetchData(`${package_id.package_name}:${package_id.publisher_node}`);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
},
onOpen: (_e) => {
console.log('WebSocket connection opened');
},
onClose: (_e) => {
console.log('WebSocket connection closed');
},
onError: (error) => {
console.error('WebSocket error:', error);
},
}),
}),
{
name: 'app_store',
}
)
)
export default useAppsStore

View File

@ -1,18 +1,37 @@
export interface MyApps {
downloaded: AppInfo[]
installed: AppInfo[]
local: AppInfo[]
system: AppInfo[]
export interface PackageId {
package_name: string;
publisher_node: string;
}
export interface AppListing {
owner?: string
package: string
publisher: string
package_id: PackageId
tba: string
metadata_uri: string
metadata_hash: string
metadata?: OnchainPackageMetadata
installed: boolean
state?: PackageState
auto_update: boolean
}
export type DownloadItem = {
Dir?: DirItem;
File?: FileItem;
};
export interface DirItem {
name: string;
mirroring: boolean;
}
export interface FileItem {
name: string;
size: number;
manifest: string;
}
export interface MirrorCheckFile {
node: string;
is_online: boolean;
error: string | null;
}
export interface Erc721Properties {
@ -20,7 +39,7 @@ export interface Erc721Properties {
publisher: string;
current_version: string;
mirrors: string[];
code_hashes: Record<string, string>;
code_hashes: [string, string][];
license?: string;
screenshots?: string[];
wit_version?: [number, number, number];
@ -36,19 +55,10 @@ export interface OnchainPackageMetadata {
}
export interface PackageState {
mirrored_from: string;
our_version: string;
installed: boolean;
package_id: PackageId;
our_version_hash: string;
verified: boolean;
caps_approved: boolean;
manifest_hash?: string;
mirroring: boolean;
auto_update: boolean;
// source_zip?: Uint8Array, // bytes
}
export interface AppInfo extends AppListing {
permissions?: string[]
}
export interface PackageManifest {
@ -56,46 +66,7 @@ export interface PackageManifest {
process_wasm_path: string
on_exit: string
request_networking: boolean
request_capabilities: string[]
grant_capabilities: string[]
request_capabilities: any[]
grant_capabilities: any[]
public: boolean
}
[
{
"installed": false,
"metadata": null,
"metadata_hash": "0xf244e4e227494c6a0716597f0c405284eb53f7916427d48ceb03a24ed5b52b5d",
"owner": "0x7Bf904E36715B650Fb1F99113cb4A2B2FfE22392",
"package": "sdapi",
"publisher": "mothu-et-doria.os",
"state": null
},
{
"installed": false,
"metadata_hash": "0xe43f616b39f2511f2c3c29c801a0993de5a74ab1fc4382ff7c68aad50f0242f3",
"owner": "0xDe12193c037F768fDC0Db0B77B7E70de723b95E7",
"package": "chat",
"publisher": "mythicengineer.os",
"state": null
},
{
"installed": false,
"metadata": null,
"metadata_hash": "0x4385b4b9ddddcc25ce99d6ae1542b1362c0e7f41abf1385cd9eda4d39ced6e39",
"owner": "0x7213aa2A6581b37506C035b387b4Bf2Fb93E2f88",
"package": "chat_template",
"publisher": "odinsbadeye.os",
"state": null
},
{
"installed": false,
"metadata": null,
"metadata_hash": "0x0f4c02462407d88fb43a0e24df7e36b7be4a09f2fc27bb690e5b76c8d21088ef",
"owner": "0x958946dEcCfe3546fE7F3f98eb07c100E472F09D",
"package": "kino_files",
"publisher": "gloriainexcelsisdeo.os",
"state": null
}
]

View File

@ -1,7 +0,0 @@
import { ethers } from "ethers";
import { PackageStore } from "../abis/types";
export interface PageProps {
provider?: ethers.providers.Web3Provider;
packageAbi?: PackageStore
}

View File

@ -1,24 +0,0 @@
import { AppInfo } from "../types/Apps";
export const appId = (app: AppInfo) => `${app.package}:${app.publisher}`
export const getAppName = (app: AppInfo) => app.metadata?.name || appId(app)
export enum AppType {
Downloaded = 'downloaded',
Installed = 'installed',
Local = 'local',
System = 'system',
}
export const getAppType = (app: AppInfo) => {
if (app.publisher === 'sys') {
return AppType.System
} else if (app.state?.our_version && !app.state?.caps_approved) {
return AppType.Downloaded
} else if (!app.metadata) {
return AppType.Local
} else {
return AppType.Installed
}
}

View File

@ -1,88 +0,0 @@
import { SEPOLIA_OPT_HEX, OPTIMISM_OPT_HEX } from "../constants/chain";
const CHAIN_NOT_FOUND = "4902"
export interface Chain {
chainId: string, // Replace with the correct chainId for Sepolia
chainName: string,
nativeCurrency: {
name: string,
symbol: string,
decimals: number
},
rpcUrls: string[],
blockExplorerUrls: string[]
}
export const CHAIN_DETAILS: { [key: string]: Chain } = {
[SEPOLIA_OPT_HEX]: {
chainId: SEPOLIA_OPT_HEX,
chainName: 'Sepolia',
nativeCurrency: {
name: 'Ether',
symbol: 'ETH',
decimals: 18
},
rpcUrls: ['https://rpc.sepolia.org'],
blockExplorerUrls: ['https://sepolia.etherscan.io']
},
[OPTIMISM_OPT_HEX]: {
chainId: OPTIMISM_OPT_HEX,
chainName: 'Optimism',
nativeCurrency: {
name: 'Ether',
symbol: 'ETH',
decimals: 18
},
rpcUrls: ['https://mainnet.optimism.io'],
blockExplorerUrls: ['https://optimistic.etherscan.io']
}
}
export const getNetworkName = (networkId: string) => {
switch (networkId) {
case '1':
case '0x1':
return 'Ethereum'; // Ethereum Mainnet
case '10':
case 'a':
case '0xa':
return 'Optimism'; // Optimism
case '42161':
return 'Arbitrum'; // Arbitrum One
case '11155111':
case 'aa36a7':
case '0xaa36a7':
return 'Sepolia'; // Sepolia Testnet
default:
return 'Unknown';
}
};
export const setChain = async (chainId: string) => {
let networkId = await (window.ethereum as any)?.request({ method: 'net_version' }).catch(() => '1') // eslint-disable-line
networkId = '0x' + (typeof networkId === 'string' ? networkId.replace(/^0x/, '') : networkId.toString(16))
if (!CHAIN_DETAILS[chainId]) {
console.error(`Invalid chain ID: ${chainId}`)
return
}
if (chainId !== networkId) {
try {
await (window.ethereum as any)?.request({ // eslint-disable-line
method: "wallet_switchEthereumChain",
params: [{ chainId }]
});
} catch (err) {
if (String(err).includes(CHAIN_NOT_FOUND)) {
await (window.ethereum as any)?.request({ // eslint-disable-line
method: 'wallet_addEthereumChain',
params: [CHAIN_DETAILS[chainId]]
})
} else {
window.alert(`You must enable the ${getNetworkName(chainId)} network in your wallet.`)
throw new Error(`User cancelled connection to ${chainId}`)
}
}
}
}

View File

@ -0,0 +1,12 @@
// Helper function to compare version strings
export const compareVersions = (v1: string, v2: string) => {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const part1 = parts1[i] || 0;
const part2 = parts2[i] || 0;
if (part1 > part2) return 1;
if (part1 < part2) return -1;
}
return 0;
};

View File

@ -1 +0,0 @@
export const isMobileCheck = () => window.innerWidth <= 600

View File

@ -0,0 +1,21 @@
import sha3 from 'js-sha3';
import { toUnicode } from 'idna-uts46-hx';
export const kinohash = (inputName: string): `0x${string}` =>
('0x' + normalize(inputName)
.split('.')
.reverse()
.reduce(reducer, '00'.repeat(32))) as `0x${string}`;
const reducer = (node: string, label: string): string =>
sha3.keccak_256(Buffer.from(node + sha3.keccak_256(label), 'hex'));
export const normalize = (name: string): string => {
const tilde = name.startsWith('~');
const clean = tilde ? name.slice(1) : name;
const normalized = clean ? unicode(clean) : clean;
return tilde ? '~' + normalized : normalized;
};
const unicode = (name: string): string =>
toUnicode(name, { useStd3ASCII: true, transitional: false })

View File

@ -1,4 +0,0 @@
import { initializeConnector } from '@web3-react/core'
import { MetaMask } from '@web3-react/metamask'
export const [metaMask, hooks] = initializeConnector<MetaMask>((actions) => new MetaMask({ actions }))

View File

@ -0,0 +1,11 @@
// TODO: remove as much as possible of this..
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}`;
// This env also has BASE_URL which should match the process + package name
export const WEBSOCKET_URL = import.meta.env.DEV
? `${PROXY_TARGET.replace('http', 'ws')}`
: undefined;

View File

@ -1,17 +1,13 @@
import { defineConfig } from 'vite'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
import react from '@vitejs/plugin-react'
import UnoCSS from 'unocss/vite'
import { transformerDirectives } from 'unocss'
import presetIcons from '@unocss/preset-icons'
import presetUno from '@unocss/preset-uno'
import presetWind from '@unocss/preset-wind'
/*
If you are developing a UI outside of a Kinode project,
comment out the following 2 lines:
*/
// import manifest from '../pkg/manifest.json'
// import metadata from '../pkg/metadata.json'
import manifest from '../pkg/manifest.json'
import metadata from '../metadata.json'
/*
IMPORTANT:
@ -27,35 +23,10 @@ console.log('process.env.VITE_NODE_URL', process.env.VITE_NODE_URL, PROXY_URL);
export default defineConfig({
plugins: [
UnoCSS({
presets: [presetUno(), presetWind(), presetIcons()],
shortcuts: [
{
'flex-center': 'flex justify-center items-center',
'flex-col-center': 'flex flex-col justify-center items-center',
},
],
rules: [
],
theme: {
colors: {
'white': '#FFF5D9',
'black': '#22211F',
'orange': '#F35422',
'transparent': 'transparent',
'gray': '#7E7E7E',
},
font: {
'sans': ['Barlow', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', '"Noto Sans"', 'sans-serif', '"Apple Color Emoji"', '"Segoe UI Emoji"', '"Segoe UI Symbol"', '"Noto Color Emoji"'],
'serif': ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'],
'mono': ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', '"Liberation Mono"', '"Courier New"', 'monospace'],
'heading': ['OpenSans', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', '"Noto Sans"', 'sans-serif', '"Apple Color Emoji"', '"Segoe UI Emoji"', '"Segoe UI Symbol"', '"Noto Color Emoji"'],
'display': ['Futura', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', '"Noto Sans"', 'sans-serif', '"Apple Color Emoji"', '"Segoe UI Emoji"', '"Segoe UI Symbol"', '"Noto Color Emoji"'],
},
},
transformers: [
transformerDirectives()
],
nodePolyfills({
globals: {
Buffer: true,
}
}),
react(),
],
@ -68,37 +39,30 @@ export default defineConfig({
server: {
open: true,
proxy: {
'/our': {
[`^${BASE_URL}/our.js`]: {
target: PROXY_URL,
changeOrigin: true,
rewrite: (path) => {
console.log('Proxying jsrequest:', path);
return '/our.js';
},
},
[`${BASE_URL}/our.js`]: {
[`^${BASE_URL}/kinode.css`]: {
target: PROXY_URL,
changeOrigin: true,
rewrite: (path) => path.replace(BASE_URL, ''),
rewrite: (path) => {
console.log('Proxying csrequest:', path);
return '/kinode.css';
},
},
// This route will match all other HTTP requests to the backend
[`^${BASE_URL}/(?!(@vite/client|src/.*|node_modules/.*|@react-refresh|__uno.css|$))`]: {
[`^${BASE_URL}/(?!(@vite/client|src/.*|node_modules/.*|@react-refresh|$))`]: {
target: PROXY_URL,
changeOrigin: true,
},
// '/example': {
// target: PROXY_URL,
// changeOrigin: true,
// rewrite: (path) => path.replace(BASE_URL, ''),
// // This is only for debugging purposes
// configure: (proxy, _options) => {
// proxy.on('error', (err, _req, _res) => {
// console.log('proxy error', err);
// });
// proxy.on('proxyReq', (proxyReq, req, _res) => {
// console.log('Sending Request to the Target:', req.method, req.url);
// });
// proxy.on('proxyRes', (proxyRes, req, _res) => {
// console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
// });
// },
// },
}
}
},
},
});

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ simulation-mode = []
[dependencies]
anyhow = "1.0"
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.8.0" }
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.9.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wit-bindgen = "0.24.0"

View File

@ -222,13 +222,8 @@ checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
[[package]]
name = "kinode_process_lib"
<<<<<<< HEAD:modules/chess/chess/Cargo.lock
version = "0.5.7"
source = "git+https://github.com/kinode-dao/process_lib?tag=v0.5.9-alpha#c1ac7227951fbd8cabf6568704f0ce11e8558c8a"
=======
version = "0.5.6"
source = "git+https://github.com/kinode-dao/process_lib?rev=fccb6a0#fccb6a0c07ebda3e385bff7f76e4984b741f01c7"
>>>>>>> develop:kinode/packages/chess/chess/Cargo.lock
dependencies = [
"anyhow",
"bincode",

View File

@ -8,13 +8,11 @@ simulation-mode = []
[dependencies]
anyhow = "1.0"
base64 = "0.22.0"
bincode = "1.3.3"
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.8.0" }
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.9.0" }
pleco = "0.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
url = "*"
wit-bindgen = "0.24.0"
[lib]

Some files were not shown because too many files have changed in this diff Show More