mirror of
https://github.com/uqbar-dao/nectar.git
synced 2024-12-22 16:11:38 +03:00
Merge branch 'main' into main
This commit is contained in:
commit
b74b90694f
22
.github/workflows/build_release.yml
vendored
Normal file
22
.github/workflows/build_release.yml
vendored
Normal 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
|
7
.github/workflows/release_candidate.yml
vendored
7
.github/workflows/release_candidate.yml
vendored
@ -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
6
.gitignore
vendored
@ -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
2104
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
@ -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"]
|
||||
|
2
LICENSE
2
LICENSE
@ -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.
|
||||
|
53
README.md
53
README.md
@ -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 behavior–use 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
182
css/kinode.css
Normal 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;
|
||||
}
|
Before Width: | Height: | Size: 644 B After Width: | Height: | Size: 644 B |
@ -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"
|
||||
|
189
kinode/build.rs
189
kinode/build.rs
@ -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(())
|
||||
|
@ -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;
|
||||
}
|
@ -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
@ -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(())
|
||||
}
|
||||
|
@ -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(¤t_listing.name, ¤t_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(())
|
||||
}
|
||||
}
|
||||
|
@ -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: ð::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: ð::Provider, filter: ð::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"),
|
||||
)
|
||||
}
|
||||
|
29
kinode/packages/app_store/chain/Cargo.toml
Normal file
29
kinode/packages/app_store/chain/Cargo.toml
Normal 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"
|
497
kinode/packages/app_store/chain/src/lib.rs
Normal file
497
kinode/packages/app_store/chain/src/lib.rs
Normal 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(¬e.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: ð::Provider, filter: ð::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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -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..!");
|
||||
|
26
kinode/packages/app_store/downloads/Cargo.toml
Normal file
26
kinode/packages/app_store/downloads/Cargo.toml
Normal 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"
|
589
kinode/packages/app_store/downloads/src/lib.rs
Normal file
589
kinode/packages/app_store/downloads/src/lib.rs
Normal 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 == ¤t_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,
|
||||
}
|
||||
}
|
||||
}
|
@ -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"]
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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
@ -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>
|
@ -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 },
|
||||
],
|
||||
},
|
||||
}
|
@ -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
|
@ -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"
|
||||
]
|
||||
}
|
@ -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" />
|
||||
|
14603
kinode/packages/app_store/ui/package-lock.json
generated
14603
kinode/packages/app_store/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
@ -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 |
@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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": []
|
||||
}
|
||||
]
|
60
kinode/packages/app_store/ui/src/abis/helpers.ts
Normal file
60
kinode/packages/app_store/ui/src/abis/helpers.ts
Normal 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;
|
||||
}
|
24
kinode/packages/app_store/ui/src/abis/index.ts
Normal file
24
kinode/packages/app_store/ui/src/abis/index.ts
Normal 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
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
/* Autogenerated file. Do not edit manually. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export { PackageStore__factory } from "./PackageStore__factory";
|
@ -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";
|
@ -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 |
@ -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 |
@ -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>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
}
|
@ -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
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
27
kinode/packages/app_store/ui/src/components/Header.tsx
Normal file
27
kinode/packages/app_store/ui/src/components/Header.tsx
Normal 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;
|
@ -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;
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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;
|
107
kinode/packages/app_store/ui/src/components/MirrorSelector.tsx
Normal file
107
kinode/packages/app_store/ui/src/components/MirrorSelector.tsx
Normal 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;
|
@ -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
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
2
kinode/packages/app_store/ui/src/components/index.ts
Normal file
2
kinode/packages/app_store/ui/src/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as Header } from './Header';
|
||||
export { default as MirrorSelector } from './MirrorSelector';
|
@ -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',
|
||||
|
||||
};
|
@ -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';
|
||||
|
25
kinode/packages/app_store/ui/src/declarations.d.ts
vendored
Normal file
25
kinode/packages/app_store/ui/src/declarations.d.ts
vendored
Normal 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
@ -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>,
|
||||
)
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
199
kinode/packages/app_store/ui/src/pages/DownloadPage.tsx
Normal file
199
kinode/packages/app_store/ui/src/pages/DownloadPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
207
kinode/packages/app_store/ui/src/pages/MyDownloadsPage.tsx
Normal file
207
kinode/packages/app_store/ui/src/pages/MyDownloadsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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
|
379
kinode/packages/app_store/ui/src/store/index.ts
Normal file
379
kinode/packages/app_store/ui/src/store/index.ts
Normal 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
|
@ -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
|
||||
}
|
||||
]
|
||||
|
@ -1,7 +0,0 @@
|
||||
import { ethers } from "ethers";
|
||||
import { PackageStore } from "../abis/types";
|
||||
|
||||
export interface PageProps {
|
||||
provider?: ethers.providers.Web3Provider;
|
||||
packageAbi?: PackageStore
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
12
kinode/packages/app_store/ui/src/utils/compareVersions.ts
Normal file
12
kinode/packages/app_store/ui/src/utils/compareVersions.ts
Normal 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;
|
||||
};
|
@ -1 +0,0 @@
|
||||
export const isMobileCheck = () => window.innerWidth <= 600
|
21
kinode/packages/app_store/ui/src/utils/kinohash.ts
Normal file
21
kinode/packages/app_store/ui/src/utils/kinohash.ts
Normal 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 })
|
@ -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 }))
|
11
kinode/packages/app_store/ui/src/utils/ws.ts
Normal file
11
kinode/packages/app_store/ui/src/utils/ws.ts
Normal 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;
|
@ -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
@ -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"
|
||||
|
5
kinode/packages/chess/chess/Cargo.lock
generated
5
kinode/packages/chess/chess/Cargo.lock
generated
@ -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",
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user