Merge pull request #347 from kinode-dao/develop

develop 0.8.0
This commit is contained in:
doria 2024-06-10 09:22:41 +09:00 committed by GitHub
commit 577ca6425d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
211 changed files with 14377 additions and 7082 deletions

23
.github/workflows/release_candidate.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: rust release-candidate CI
on:
push:
branches: [ release-candidate ]
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: build and deploy kinode
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_ED25519KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 60m
script: |
cd ~
./build-kinode.sh

3
.gitignore vendored
View File

@ -2,6 +2,7 @@ target/
wit/
**/target/
**/wit/
**/wit-*/
**/*.wasm
.vscode
.app-signing
@ -10,8 +11,6 @@ wit/
*.swo
*.zip
/home
packages/**/pkg/*.wasm
packages/**/wit
*/**/node_modules
.env
kinode/src/bootstrapped_processes.rs

788
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
[package]
name = "kinode_lib"
authors = ["KinodeDAO"]
version = "0.7.4"
version = "0.8.0"
edition = "2021"
description = "A general-purpose sovereign cloud computing platform"
homepage = "https://kinode.org"
@ -22,9 +22,9 @@ members = [
"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/m", "kinode/packages/terminal/top",
"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/tester/tester", "kinode/packages/tester/test_runner",
"kinode/packages/tester/tester",
]
default-members = ["lib"]
resolver = "2"

View File

@ -1,7 +1,7 @@
[package]
name = "kinode"
authors = ["KinodeDAO"]
version = "0.7.4"
version = "0.8.0"
edition = "2021"
description = "A general-purpose sovereign cloud computing platform"
homepage = "https://kinode.org"
@ -14,7 +14,7 @@ path = "src/main.rs"
[build-dependencies]
anyhow = "1.0.71"
kit = { git = "https://github.com/kinode-dao/kit", rev = "25b474a" }
kit = { git = "https://github.com/kinode-dao/kit", rev = "d319c5b" }
rayon = "1.8.1"
sha2 = "0.10"
tokio = "1.28"
@ -26,18 +26,25 @@ simulation-mode = []
[dependencies]
aes-gcm = "0.10.3"
alloy-contract = { git = "https://github.com/alloy-rs/alloy", rev = "6f8ebb4" }
alloy-consensus = { git = "https://github.com/alloy-rs/alloy", rev = "6f8ebb4" }
alloy-pubsub = { git = "https://github.com/alloy-rs/alloy", rev = "6f8ebb4" }
alloy-rpc-types = { git = "https://github.com/alloy-rs/alloy", rev = "6f8ebb4" }
alloy-rpc-client = { git = "https://github.com/alloy-rs/alloy", rev = "6f8ebb4", features = ["ws"]}
alloy-transport-ws = { git = "https://github.com/alloy-rs/alloy", rev = "6f8ebb4" }
alloy-providers = { git = "https://github.com/alloy-rs/alloy", rev = "6f8ebb4" }
alloy-network = { git = "https://github.com/alloy-rs/alloy", rev = "6f8ebb4" }
alloy-primitives = "0.6.2"
alloy-sol-macro = "0.6.2"
alloy-sol-types = "0.6.2"
alloy-signer = { git = "https://github.com/alloy-rs/alloy", rev = "6f8ebb4" }
alloy = { git = "https://github.com/alloy-rs/alloy", rev = "05f8162", features = [
"consensus",
"contract",
"json-rpc",
"network",
"provider-ws",
"providers",
"pubsub",
"rpc-client-ws",
"rpc-client",
"rpc-types-eth",
"rpc-types",
"signer-wallet",
"signers",
] }
alloy-primitives = "0.7.5"
alloy-sol-macro = "0.7.5"
alloy-sol-types = "0.7.5"
anyhow = "1.0.71"
async-trait = "0.1.71"
base64 = "0.22.0"
@ -80,11 +87,14 @@ serde_json = "1.0"
serde_urlencoded = "0.7"
sha2 = "0.10"
sha3 = "0.10.8"
snow = { version = "0.9.5", features = ["ring-resolver"] }
# snow = { version = "0.9.5", features = ["ring-resolver"] }
# unfortunately need to use forked version for async use and in-place encryption
snow = { git = "https://github.com/dr-frmr/snow", branch = "dr/extract_cipherstates", features = ["ring-resolver"] }
socket2 = "0.5.7"
static_dir = "0.2.0"
thiserror = "1.0"
tokio = { version = "1.28", features = ["fs", "macros", "rt-multi-thread", "signal", "sync"] }
tokio-tungstenite = "0.21.0"
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"

View File

@ -59,7 +59,7 @@ 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)
kit::build::execute(&entry_path, true, false, true, features, None, None) // TODO
.await
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
@ -119,13 +119,17 @@ fn main() -> anyhow::Result<()> {
let results: Vec<anyhow::Result<(String, String, Vec<u8>)>> = entries
.par_iter()
.map(|entry_path| {
.filter_map(|entry_path| {
let parent_pkg_path = entry_path.join("pkg");
build_and_zip_package(
if !parent_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(),
&features,
)
))
})
.collect();

View File

@ -0,0 +1,160 @@
interface main {
//
// app store API as presented by main:app_store:sys-v0
//
use standard.{package-id};
record onchain-metadata {
name: option<string>,
description: option<string>,
image: option<string>,
external-url: option<string>,
animation-url: option<string>,
properties: onchain-properties,
}
record onchain-properties {
package-name: string,
publisher: string,
current-version: string,
mirrors: list<string>,
code-hashes: list<tuple<string, string>>,
license: option<string>,
screenshots: option<list<string>>,
wit-version: option<u32>,
dependencies: option<list<string>>,
}
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,
}
variant local-request {
new-package(new-package-request),
download(download-request),
install(package-id),
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),
}
enum new-package-response {
success,
no-blob,
install-failed,
already-exists,
}
variant download-response {
started,
bad-response,
denied(reason),
already-exists,
already-downloading,
}
enum install-response {
success,
failure, // TODO
}
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
}
record apis-response {
apis: list<package-id>,
}
// the API itself will be in response blob if success!
enum get-api-response {
success,
failure, // TODO
}
}
world app-store-sys-v0 {
import main;
include process-v0;
}

View File

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

View File

@ -1,149 +0,0 @@
use kinode_process_lib::*;
use serde::{Deserialize, Serialize};
//
// app store API
//
/// Remote requests, those sent between instantiations of this process
/// on different nodes, take this form. Will add more here in the future
#[derive(Debug, Serialize, Deserialize)]
pub enum RemoteRequest {
/// Request a package from another node who we expect to
/// be mirroring it. If the remote node is mirroring the package,
/// they must respond with RemoteResponse::DownloadApproved,
/// at which point requester can expect an FTWorkerRequest::Receive.
Download {
package_id: PackageId,
desired_version_hash: Option<String>,
},
}
/// The response expected from sending a [`RemoteRequest`].
#[derive(Debug, Serialize, Deserialize)]
pub enum RemoteResponse {
DownloadApproved,
DownloadDenied(ReasonDenied),
Metadata,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum ReasonDenied {
NoPackage,
NotMirroring,
HashMismatch { requested: String, have: String },
FileNotFound,
WorkerSpawnFailed,
}
/// Local requests sent to the app store take this form.
#[derive(Debug, Serialize, Deserialize)]
pub enum LocalRequest {
/// Expects a zipped package as blob, and creates a new package from it.
///
/// If requested, will return a NewPackageResponse indicating success/failure.
/// This is used for locally installing a package.
NewPackage {
package: PackageId,
/// Sets whether we will mirror this package for others
mirror: bool,
},
/// Try to download a package from a specified node.
///
/// If requested, will return a DownloadResponse indicating success/failure.
/// No blob is expected.
Download {
package: PackageId,
download_from: NodeId,
/// Sets whether we will mirror this package for others
mirror: bool,
/// Sets whether we will try to automatically update this package
/// when a new version is posted to the listings contract
auto_update: bool,
/// The version hash we're looking for. If None, will download the latest.
desired_version_hash: Option<String>,
},
/// Select a downloaded package and install it. Will only succeed if the
/// package is currently in the filesystem. If the package has *already*
/// been installed, this will kill the running package and reset it with
/// what's on disk.
///
/// If requested, will return an InstallResponse indicating success/failure.
/// No blob is expected.
Install(PackageId),
/// Select an installed package and uninstall it.
/// This will kill the processes in the **manifest** of the package,
/// but not the processes that were spawned by those processes! Take
/// care to kill those processes yourself. This will also delete the drive
/// containing the source code for this package. This does not guarantee
/// that other data created by this package will be removed from places such
/// as the key-value store.
///
/// If requested, will return an UninstallResponse indicating success/failure.
/// No blob is expected.
Uninstall(PackageId),
/// Start mirroring a package. This will fail if the package has not been downloaded.
StartMirroring(PackageId),
/// Stop mirroring a package. This will fail if the package has not been downloaded.
StopMirroring(PackageId),
/// Turn on automatic updates to a package. This will fail if the package has not been downloaded.
StartAutoUpdate(PackageId),
/// Turn off automatic updates to a package. This will fail if the package has not been downloaded.
StopAutoUpdate(PackageId),
/// This is an expensive operation! Throw away our state and rebuild from scratch.
/// Re-index the locally downloaded/installed packages AND the onchain data.
RebuildIndex,
}
/// Local responses take this form.
/// The variant of `LocalResponse` given will match the `LocalRequest` it is
/// responding to.
#[derive(Debug, Serialize, Deserialize)]
pub enum LocalResponse {
NewPackageResponse(NewPackageResponse),
DownloadResponse(DownloadResponse),
InstallResponse(InstallResponse),
UninstallResponse(UninstallResponse),
MirrorResponse(MirrorResponse),
AutoUpdateResponse(AutoUpdateResponse),
RebuiltIndex,
}
// TODO for all: expand these to elucidate why something failed
// these are locally-given responses to local requests
#[derive(Debug, Serialize, Deserialize)]
pub enum NewPackageResponse {
Success,
Failure,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum DownloadResponse {
Started,
Failure,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum InstallResponse {
Success,
Failure,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum UninstallResponse {
Success,
Failure,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum MirrorResponse {
Success,
Failure,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum AutoUpdateResponse {
Success,
Failure,
}

View File

@ -1,12 +1,137 @@
use crate::{DownloadResponse, PackageListing, PackageState, RequestedPackage, State};
use crate::state::{PackageListing, PackageState, State};
use crate::DownloadResponse;
use kinode_process_lib::{
eth,
http::{send_response, IncomingHttpRequest, Method, StatusCode},
Address, NodeId, PackageId,
http::{
bind_http_path, bind_ws_path, send_response, serve_ui, IncomingHttpRequest, Method,
StatusCode,
},
Address, NodeId, PackageId, Request,
};
use serde_json::json;
use std::collections::HashMap;
const ICON: &str = include_str!("icon");
/// Bind static and dynamic HTTP paths for the app store,
/// bind to our WS updates path, and add icon and widget to homepage.
pub fn init_frontend(our: &Address) {
for path in [
"/apps",
"/apps/listed",
"/apps/:id",
"/apps/listed/:id",
"/apps/:id/caps",
"/apps/:id/mirror",
"/apps/:id/auto-update",
"/apps/rebuild-index",
] {
bind_http_path(path, true, false).expect("failed to bind http path");
}
serve_ui(
&our,
"ui",
true,
false,
vec!["/", "/my-apps", "/app-details/:id", "/publish"],
)
.expect("failed to serve static UI");
bind_ws_path("/", true, true).expect("failed to bind ws path");
// add ourselves to the homepage
Request::to(("our", "homepage", "homepage", "sys"))
.body(
serde_json::json!({
"Add": {
"label": "App Store",
"icon": ICON,
"path": "/",
"widget": make_widget()
}
})
.to_string()
.as_bytes()
.to_vec(),
)
.send()
.unwrap();
}
fn make_widget() -> String {
return r#"<html>
<head>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.app {
width: 100%;
}
.app-image {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
.app-info {
max-width: 67%
}
@media screen and (min-width: 500px) {
.app {
width: 49%;
}
}
</style>
</head>
<body class="text-white overflow-hidden">
<div
id="latest-apps"
class="flex flex-wrap p-2 gap-2 items-center backdrop-brightness-125 rounded-xl shadow-lg h-screen w-screen overflow-y-auto"
style="
scrollbar-color: transparent transparent;
scrollbar-width: none;
"
>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
fetch('/main:app_store:sys/apps/listed', { credentials: 'include' })
.then(response => response.json())
.then(data => {
const container = document.getElementById('latest-apps');
data.forEach(app => {
if (app.metadata) {
const a = document.createElement('a');
a.className = 'app p-2 grow flex items-stretch rounded-lg shadow bg-white/10 hover:bg-white/20 font-sans cursor-pointer';
a.href = `/main:app_store:sys/app-details/${app.package}:${app.publisher}`
a.target = '_blank';
a.rel = 'noopener noreferrer';
const iconLetter = app.metadata_hash.replace('0x', '')[0].toUpperCase();
a.innerHTML = `<div
class="app-image rounded mr-2 grow"
style="
background-image: url('${app.metadata.image || `/icons/${iconLetter}`}');
height: 92px;
width: 92px;
max-width: 33%;
"
></div>
<div class="app-info flex flex-col grow">
<h2 class="font-bold">${app.metadata.name}</h2>
<p>${app.metadata.description}</p>
</div>`;
container.appendChild(a);
}
});
})
.catch(error => console.error('Error fetching apps:', error));
});
</script>
</body>
</html>"#
.to_string();
}
/// Actions supported over HTTP:
/// - get all downloaded apps: GET /apps
/// - get all listed apps: GET /apps/listed
@ -26,14 +151,8 @@ use std::collections::HashMap;
/// - stop auto-updating a downloaded app: DELETE /apps/:id/auto-update
///
/// - RebuildIndex: POST /apps/rebuild-index
pub fn handle_http_request(
our: &Address,
state: &mut State,
eth_provider: &eth::Provider,
requested_packages: &mut HashMap<PackageId, RequestedPackage>,
req: &IncomingHttpRequest,
) -> anyhow::Result<()> {
match serve_paths(our, state, eth_provider, requested_packages, req) {
pub fn handle_http_request(state: &mut State, req: &IncomingHttpRequest) -> anyhow::Result<()> {
match serve_paths(state, req) {
Ok((status_code, _headers, body)) => send_response(
status_code,
Some(HashMap::from([(
@ -99,15 +218,12 @@ fn gen_package_info(
}
fn serve_paths(
our: &Address,
state: &mut State,
eth_provider: &eth::Provider,
requested_packages: &mut HashMap<PackageId, RequestedPackage>,
req: &IncomingHttpRequest,
) -> anyhow::Result<(StatusCode, Option<HashMap<String, String>>, Vec<u8>)> {
let method = req.method()?;
let bound_path: &str = req.bound_path(Some(&our.process.to_string()));
let bound_path: &str = req.bound_path(Some(&state.our.process.to_string()));
let url_params = req.url_params();
match bound_path {
@ -183,7 +299,7 @@ fn serve_paths(
}
Method::POST => {
// install an app
crate::handle_install(our, state, &package_id)?;
crate::handle_install(state, &package_id)?;
Ok((StatusCode::CREATED, None, format!("Installed").into_bytes()))
}
Method::PUT => {
@ -201,20 +317,19 @@ fn serve_paths(
.ok_or(anyhow::anyhow!("No mirror for package {package_id}"))?
.to_string();
match crate::start_download(
our,
requested_packages,
&package_id,
&download_from,
state,
package_id,
download_from,
pkg_state.mirroring,
pkg_state.auto_update,
&None,
None,
) {
DownloadResponse::Started => Ok((
StatusCode::CREATED,
None,
format!("Downloading").into_bytes(),
)),
DownloadResponse::Failure => Ok((
_ => Ok((
StatusCode::SERVICE_UNAVAILABLE,
None,
format!("Failed to download").into_bytes(),
@ -297,23 +412,22 @@ fn serve_paths(
let auto_update = false;
let desired_version_hash = None;
match crate::start_download(
our,
requested_packages,
&package_id,
&download_from,
state,
package_id,
download_from,
mirror,
auto_update,
&desired_version_hash,
desired_version_hash,
) {
DownloadResponse::Started => Ok((
StatusCode::CREATED,
None,
format!("Downloading").into_bytes(),
)),
DownloadResponse::Failure => Ok((
other => Ok((
StatusCode::SERVICE_UNAVAILABLE,
None,
format!("Failed to download").into_bytes(),
format!("Failed to download: {other:?}").into_bytes(),
)),
}
}
@ -334,10 +448,9 @@ fn serve_paths(
format!("Missing id").into_bytes(),
));
};
match method {
// return the capabilities for that app
Method::GET => Ok(match crate::fetch_package_manifest(&package_id) {
Method::GET => Ok(match crate::utils::fetch_package_manifest(&package_id) {
Ok(manifest) => (StatusCode::OK, None, serde_json::to_vec(&manifest)?),
Err(_) => (
StatusCode::NOT_FOUND,
@ -432,7 +545,7 @@ fn serve_paths(
format!("Invalid method {method} for {bound_path}").into_bytes(),
));
}
crate::rebuild_index(our, state, eth_provider);
crate::rebuild_index(state);
Ok((StatusCode::OK, None, vec![]))
}
_ => Ok((

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,585 @@
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 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
//
#[derive(Debug, Serialize, Deserialize)]
pub enum AppStoreLogError {
NoBlockNumber,
DecodeLogError,
PackageHashMismatch,
InvalidPublisherName,
MetadataNotFound,
MetadataHashMismatch,
PublisherNameMismatch,
}
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::PackageHashMismatch => write!(f, "mismatched package hash"),
AppStoreLogError::InvalidPublisherName => write!(f, "invalid publisher name"),
AppStoreLogError::MetadataNotFound => write!(f, "metadata not found"),
AppStoreLogError::MetadataHashMismatch => write!(f, "metadata hash mismatch"),
AppStoreLogError::PublisherNameMismatch => write!(f, "publisher name mismatch"),
}
}
}
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>,
}
/// 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,
pub verified: bool,
pub caps_approved: bool,
/// the hash of the manifest file, 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>,
/// 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()
}
}
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
/// with all packages parseable from our filesystem.
pub fn new(
our: Address,
provider: eth::Provider,
contract_address: String,
) -> 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(),
};
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)??
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));
};
for entry in entries {
// ignore non-package dirs
let Ok(package_id) = entry.path.parse::<PackageId>() else {
continue;
};
if entry.file_type == vfs::FileType::Directory {
let zip_file = vfs::File {
path: format!("/{}/pkg/{}.zip", package_id, package_id),
timeout: 5,
};
let Ok(zip_file_bytes) = zip_file.read() else {
continue;
};
// generate entry from this data
// for the version hash, take the SHA-256 hash of the zip file
let our_version = utils::generate_version_hash(&zip_file_bytes);
let manifest_file = vfs::File {
path: format!("/{}/pkg/manifest.json", package_id),
timeout: 5,
};
let manifest_bytes = manifest_file.read()?;
// the user will need to turn mirroring and auto-update back on if they
// have to reset the state of their app store for some reason. the apps
// themselves will remain on disk unless explicitly deleted.
self.add_downloaded_package(
&package_id,
PackageState {
mirrored_from: None,
our_version,
installed: true,
verified: true, // implicitly verified (TODO re-evaluate)
caps_approved: false, // must re-approve if you want to do something
manifest_hash: Some(utils::generate_metadata_hash(&manifest_bytes)),
mirroring: false,
auto_update: false,
metadata: None,
},
None,
)?;
if let Ok(Ok(_)) = Request::new()
.target(("our", "vfs", "distro", "sys"))
.body(
serde_json::to_vec(&vfs::VfsRequest {
path: format!("/{package_id}/pkg/api"),
action: vfs::VfsAction::Metadata,
})
.unwrap(),
)
.send_and_await_response(VFS_TIMEOUT)
{
self.downloaded_apis.insert(package_id.to_owned());
}
}
}
Ok(())
}
pub fn uninstall(&mut self, package_id: &PackageId) -> anyhow::Result<()> {
utils::uninstall(package_id)?;
self.downloaded_packages.remove(package_id);
kinode_process_lib::set_state(&serde_json::to_vec(self)?);
println!("uninstalled {package_id}");
Ok(())
}
/// saves state
///
/// only saves the onchain data in our package listings --
/// in order to fetch metadata and trigger auto-update for all packages,
/// call [`State::update_listings`], or call this with `true` as the third argument.
pub fn ingest_contract_event(
&mut self,
log: eth::Log,
update_listings: bool,
) -> Result<(), AppStoreLogError> {
let block_number: u64 = log.block_number.ok_or(AppStoreLogError::NoBlockNumber)?;
match log.topics()[0] {
AppRegistered::SIGNATURE_HASH => {
let app = AppRegistered::decode_log_data(log.data(), false)
.map_err(|_| AppStoreLogError::DecodeLogError)?;
let package_name = app.packageName;
let publisher_dnswire = app.publisherName;
let metadata_url = app.metadataUrl;
let metadata_hash = app.metadataHash;
let package_hash = log.topics()[1].to_string();
let metadata_hash = metadata_hash.to_string();
kinode_process_lib::print_to_terminal(
1,
&format!("new package {package_name} registered onchain"),
);
if utils::generate_package_hash(&package_name, &publisher_dnswire) != package_hash {
return Err(AppStoreLogError::PackageHashMismatch);
}
let Ok(publisher_name) = net::dnswire_decode(&publisher_dnswire) else {
return Err(AppStoreLogError::InvalidPublisherName);
};
let metadata = if update_listings {
let metadata =
utils::fetch_metadata_from_url(&metadata_url, &metadata_hash, 5)?;
if metadata.properties.publisher != publisher_name {
return Err(AppStoreLogError::PublisherNameMismatch);
}
Some(metadata)
} else {
None
};
self.package_hashes.insert(
PackageId::new(&package_name, &publisher_name),
package_hash.clone(),
);
match self.listed_packages.entry(package_hash) {
std::collections::hash_map::Entry::Occupied(mut listing) => {
let listing = listing.get_mut();
listing.name = package_name;
listing.publisher = publisher_name;
listing.metadata_url = metadata_url;
listing.metadata_hash = metadata_hash;
listing.metadata = metadata;
}
std::collections::hash_map::Entry::Vacant(listing) => {
listing.insert(PackageListing {
owner: "".to_string(),
name: package_name,
publisher: publisher_name,
metadata_url,
metadata_hash,
metadata,
});
}
};
}
AppMetadataUpdated::SIGNATURE_HASH => {
let upd = AppMetadataUpdated::decode_log_data(log.data(), false)
.map_err(|_| AppStoreLogError::DecodeLogError)?;
let metadata_url = upd.metadataUrl;
let metadata_hash = upd.metadataHash;
let package_hash = log.topics()[1].to_string();
let metadata_hash = metadata_hash.to_string();
let Some(current_listing) =
self.get_listing_with_hash_mut(&package_hash.to_string())
else {
// package not found, so we can't update it
// this will never happen if we're ingesting logs in order
return Ok(());
};
let metadata = if update_listings {
Some(utils::fetch_metadata_from_url(
&metadata_url,
&metadata_hash,
5,
)?)
} else {
None
};
current_listing.metadata_url = metadata_url;
current_listing.metadata_hash = metadata_hash;
if update_listings {
current_listing.metadata = metadata.clone();
let package_id =
PackageId::new(&current_listing.name, &current_listing.publisher);
if let Some(package_state) = self.downloaded_packages.get(&package_id) {
auto_update(&self.our, package_id, &metadata.unwrap(), &package_state);
}
} else {
current_listing.metadata = metadata;
}
}
Transfer::SIGNATURE_HASH => {
let from = alloy_primitives::Address::from_word(log.topics()[1]);
let to = alloy_primitives::Address::from_word(log.topics()[2]);
let package_hash = log.topics()[3].to_string();
if from == alloy_primitives::Address::ZERO {
// this is a new package, set the owner
match self.listed_packages.entry(package_hash) {
std::collections::hash_map::Entry::Occupied(mut listing) => {
let listing = listing.get_mut();
listing.owner = to.to_string();
}
std::collections::hash_map::Entry::Vacant(listing) => {
listing.insert(PackageListing {
owner: to.to_string(),
name: "".to_string(),
publisher: "".to_string(),
metadata_url: "".to_string(),
metadata_hash: "".to_string(),
metadata: None,
});
}
};
} else if to == alloy_primitives::Address::ZERO {
// this is a package deletion
if let Some(old) = self.listed_packages.remove(&package_hash) {
self.package_hashes
.remove(&PackageId::new(&old.name, &old.publisher));
}
} else {
let Some(listing) = self.get_listing_with_hash_mut(&package_hash) else {
// package not found, so we can't update it
// this will never happen if we're ingesting logs in order
return Ok(());
};
listing.owner = to.to_string();
}
}
_ => {}
}
self.last_saved_block = block_number;
if update_listings {
kinode_process_lib::set_state(&serde_json::to_vec(self).unwrap());
}
Ok(())
}
/// iterate through all package listings and try to fetch metadata.
/// this is done after ingesting a bunch of logs to remove fetches
/// of stale metadata.
pub fn update_listings(&mut self) {
for (_package_hash, listing) in self.listed_packages.iter_mut() {
if listing.metadata.is_none() {
if let Ok(metadata) =
utils::fetch_metadata_from_url(&listing.metadata_url, &listing.metadata_hash, 5)
{
let package_id = PackageId::new(&listing.name, &listing.publisher);
if let Some(package_state) = self.downloaded_packages.get(&package_id) {
auto_update(&self.our, package_id, &metadata, &package_state);
}
listing.metadata = Some(metadata);
}
}
}
kinode_process_lib::set_state(&serde_json::to_vec(self).unwrap());
}
}
/// if we have this app installed, and we have auto_update set to true,
/// we should try to download new version from the mirrored_from node
/// and install it if successful.
fn auto_update(
our: &Address,
package_id: PackageId,
metadata: &Erc721Metadata,
package_state: &PackageState,
) {
if package_state.auto_update {
let latest_version_hash = metadata
.properties
.code_hashes
.get(&metadata.properties.current_version);
if let Some(mirrored_from) = &package_state.mirrored_from
&& Some(&package_state.our_version) != latest_version_hash
{
println!(
"auto-updating package {package_id} from {} to {} using mirror {mirrored_from}",
metadata
.properties
.code_hashes
.get(&package_state.our_version)
.unwrap_or(&package_state.our_version),
metadata.properties.current_version,
);
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();
}
}
}

View File

@ -1,570 +0,0 @@
use crate::LocalRequest;
use alloy_sol_types::{sol, SolEvent};
use kinode_process_lib::eth::Log;
use kinode_process_lib::kernel_types as kt;
use kinode_process_lib::{println, *};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
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
);
}
/// from kns_indexer:sys
#[derive(Debug, Serialize, Deserialize)]
pub enum IndexerRequests {
/// return the human readable name for a namehash
/// returns an Option<String>
NamehashToName { hash: String, block: u64 },
/// return the most recent on-chain routing information for a node name.
/// returns an Option<KnsUpdate>
NodeInfo { name: String, block: u64 },
}
//
// app store types
//
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_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>,
}
/// 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,
pub verified: bool,
pub caps_approved: bool,
/// the hash of the manifest file, 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
#[derive(Debug, Serialize, Deserialize)]
pub struct State {
/// 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.
/// we don't want to save the state every time we get a new listing,
/// so we only save it every so often and then mark the block at which
/// that last occurred here. when we boot, we can read logs starting
/// from this block and rebuild latest state.
pub last_saved_block: u64,
/// 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 package_hashes: HashMap<PackageId, PackageHash>,
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>,
}
impl State {
/// To create a new state, we populate the downloaded_packages map
/// with all packages parseable from our filesystem.
pub fn new(contract_address: String) -> anyhow::Result<Self> {
crate::print_to_terminal(1, "producing new state");
let mut state = State {
contract_address,
last_saved_block: crate::CONTRACT_FIRST_BLOCK,
package_hashes: HashMap::new(),
listed_packages: HashMap::new(),
downloaded_packages: HashMap::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)
}
/// Done in response to any new onchain listing update other than 'delete'
fn insert_listing(&mut self, package_hash: PackageHash, listing: PackageListing) {
self.package_hashes.insert(
PackageId::new(&listing.name, &listing.publisher),
package_hash.clone(),
);
self.listed_packages.insert(package_hash, listing);
}
/// Done in response to an onchain listing update of 'delete'
fn delete_listing(&mut self, package_hash: &PackageHash) {
if let Some(old) = self.listed_packages.remove(package_hash) {
self.package_hashes
.remove(&PackageId::new(&old.name, &old.publisher));
}
}
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 drive_name = format!("/{package_id}/pkg");
let blob = LazyLoadBlob {
mime: Some("application/zip".to_string()),
bytes: package_bytes,
};
// 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,
})?)
.send_and_await_response(5)??;
// 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(5)??;
let vfs::VfsResponse::Ok = serde_json::from_slice::<vfs::VfsResponse>(response.body())?
else {
return Err(anyhow::anyhow!(
"cannot add NewPackage: do not have capability to access vfs"
));
};
// 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,
})?)
.blob(blob)
.send_and_await_response(5)??;
let manifest_file = vfs::File {
path: format!("/{}/pkg/manifest.json", package_id),
timeout: 5,
};
let manifest_bytes = manifest_file.read()?;
let manifest_hash = generate_metadata_hash(&manifest_bytes);
package_state.manifest_hash = Some(manifest_hash);
}
self.downloaded_packages
.insert(package_id.to_owned(), package_state);
crate::set_state(&bincode::serialize(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);
crate::set_state(&bincode::serialize(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(3)??
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));
};
for entry in entries {
// 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 = 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, // implicity verified
caps_approved: true, // since it's already installed this must be true
manifest_hash: Some(generate_metadata_hash(&manifest_bytes)),
mirroring: false,
auto_update: false,
metadata: None,
},
None,
)?
}
}
Ok(())
}
pub fn uninstall(&mut self, 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(5)??;
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()?;
}
// 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,
})?)
.send_and_await_response(5)??;
// finally, remove from downloaded packages
self.downloaded_packages.remove(package_id);
crate::set_state(&bincode::serialize(self)?);
println!("uninstalled {package_id}");
Ok(())
}
/// saves state
pub fn ingest_listings_contract_event(
&mut self,
our: &Address,
log: Log,
) -> anyhow::Result<()> {
let block_number: u64 = log
.block_number
.ok_or(anyhow::anyhow!("got log with no block number"))?
.try_into()?;
match log.topics()[0] {
AppRegistered::SIGNATURE_HASH => {
let package_hash = log.topics()[1];
let app = AppRegistered::decode_log_data(log.data(), false)?;
let package_name = app.packageName;
let publisher_dnswire = app.publisherName;
let metadata_url = app.metadataUrl;
let metadata_hash = app.metadataHash;
let package_hash = package_hash.to_string();
let metadata_hash = metadata_hash.to_string();
crate::print_to_terminal(
1,
&format!(
"app registered with package_name {}, metadata_url {}, metadata_hash {}",
package_name, metadata_url, metadata_hash
),
);
if generate_package_hash(&package_name, &publisher_dnswire) != package_hash {
return Err(anyhow::anyhow!("got log with mismatched package hash"));
}
let Ok(publisher_name) = net::dnswire_decode(&publisher_dnswire) else {
return Err(anyhow::anyhow!("got log with invalid publisher name"));
};
let metadata = fetch_metadata(&metadata_url, &metadata_hash).ok();
if let Some(metadata) = &metadata {
if metadata.properties.publisher != publisher_name {
return Err(anyhow::anyhow!(format!(
"metadata publisher name mismatch: got {}, expected {}",
metadata.properties.publisher, publisher_name
)));
}
}
let listing = match self.get_listing_with_hash_mut(&package_hash) {
Some(current_listing) => {
current_listing.name = package_name;
current_listing.publisher = publisher_name;
current_listing.metadata_hash = metadata_hash;
current_listing.metadata = metadata;
current_listing.clone()
}
None => PackageListing {
owner: "".to_string(),
name: package_name,
publisher: publisher_name,
metadata_hash,
metadata,
},
};
self.insert_listing(package_hash, listing);
}
AppMetadataUpdated::SIGNATURE_HASH => {
let package_hash = log.topics()[1].to_string();
let upd = AppMetadataUpdated::decode_log_data(log.data(), false)?;
let metadata_url = upd.metadataUrl;
let metadata_hash = upd.metadataHash;
let metadata_hash = metadata_hash.to_string();
let current_listing = self
.get_listing_with_hash_mut(&package_hash.to_string())
.ok_or(anyhow::anyhow!("got log with no matching listing"))?;
let metadata = match fetch_metadata(&metadata_url, &metadata_hash) {
Ok(metadata) => {
if metadata.properties.publisher != current_listing.publisher {
return Err(anyhow::anyhow!(format!(
"metadata publisher name mismatch: got {}, expected {}",
metadata.properties.publisher, current_listing.publisher
)));
}
Some(metadata)
}
Err(e) => {
crate::print_to_terminal(1, &format!("failed to fetch metadata: {e:?}"));
None
}
};
current_listing.metadata_hash = metadata_hash;
current_listing.metadata = metadata;
let package_id = PackageId::new(&current_listing.name, &current_listing.publisher);
// 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.
if let Some(package_state) = self.downloaded_packages.get(&package_id) {
if package_state.auto_update {
if let Some(mirrored_from) = &package_state.mirrored_from {
crate::print_to_terminal(
1,
&format!("auto-updating package {package_id} from {mirrored_from}"),
);
Request::to(our)
.body(serde_json::to_vec(&LocalRequest::Download {
package: package_id,
download_from: mirrored_from.clone(),
mirror: package_state.mirroring,
auto_update: package_state.auto_update,
desired_version_hash: None,
})?)
.send()?;
}
}
}
}
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 {
match self.get_listing_with_hash_mut(&package_hash) {
Some(current_listing) => {
current_listing.owner = to.to_string();
}
None => {
let listing = PackageListing {
owner: to.to_string(),
name: "".to_string(),
publisher: "".to_string(),
metadata_hash: "".to_string(),
metadata: None,
};
self.insert_listing(package_hash, listing);
}
}
} else if to == alloy_primitives::Address::ZERO {
self.delete_listing(&package_hash);
} else {
let current_listing = self
.get_listing_with_hash_mut(&package_hash)
.ok_or(anyhow::anyhow!("got log with no matching listing"))?;
current_listing.owner = to.to_string();
}
}
_ => {}
}
self.last_saved_block = block_number;
crate::set_state(&bincode::serialize(self)?);
Ok(())
}
}
/// fetch metadata from metadata_url and verify it matches metadata_hash
fn fetch_metadata(metadata_url: &str, metadata_hash: &str) -> anyhow::Result<kt::Erc721Metadata> {
let url = url::Url::parse(metadata_url)?;
let _response = http::send_request_await_response(http::Method::GET, url, None, 5, vec![])?;
let Some(body) = get_blob() else {
return Err(anyhow::anyhow!("no blob"));
};
let hash = generate_metadata_hash(&body.bytes);
if &hash == metadata_hash {
Ok(serde_json::from_slice::<kt::Erc721Metadata>(&body.bytes)?)
} else {
Err(anyhow::anyhow!(
"metadata hash mismatch: got {hash}, expected {metadata_hash}"
))
}
}
/// generate a Keccak-256 hash of the metadata bytes
fn generate_metadata_hash(metadata: &[u8]) -> String {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(metadata);
format!("0x{:x}", hasher.finalize())
}
/// generate a Keccak-256 hash of the package name and publisher (match onchain)
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 {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(zip_bytes);
format!("{:x}", hasher.finalize())
}

View File

@ -0,0 +1,586 @@
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,
},
std::collections::HashSet,
std::str::FromStr,
};
// 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,
}
}
}
// less annoying but still bad
impl OnchainMetadata {
pub fn to_erc721_metadata(self) -> kt::Erc721Metadata {
use kt::Erc721Properties;
kt::Erc721Metadata {
name: self.name,
description: self.description,
image: self.image,
external_url: self.external_url,
animation_url: self.animation_url,
properties: Erc721Properties {
package_name: self.properties.package_name,
publisher: self.properties.publisher,
current_version: self.properties.current_version,
mirrors: self.properties.mirrors,
code_hashes: self.properties.code_hashes.into_iter().collect(),
license: self.properties.license,
screenshots: self.properties.screenshots,
wit_version: self.properties.wit_version,
dependencies: self.properties.dependencies,
},
}
}
}
/// fetch state from disk or create a new one if that fails
pub fn fetch_state(our: Address, provider: eth::Provider) -> State {
if let Some(state_bytes) = get_state() {
match serde_json::from_slice::<SerializedState>(&state_bytes) {
Ok(state) => {
if state.contract_address == CONTRACT_ADDRESS {
return State::from_serialized(our, provider, state);
} else {
println!(
"state contract address mismatch! expected {}, got {}",
CONTRACT_ADDRESS, state.contract_address
);
}
}
Err(e) => println!("failed to deserialize saved state: {e}"),
}
}
State::new(our, provider, CONTRACT_ADDRESS.to_string()).expect("state creation failed")
}
pub fn app_store_filter(state: &State) -> eth::Filter {
eth::Filter::new()
.address(eth::Address::from_str(&state.contract_address).unwrap())
.from_block(state.last_saved_block)
.events(EVENTS)
}
/// create a filter to fetch app store event logs from chain and subscribe to new events
pub fn fetch_and_subscribe_logs(state: &mut State) {
let filter = app_store_filter(state);
// get past logs, subscribe to new ones.
for log in fetch_logs(&state.provider, &filter) {
if let Err(e) = state.ingest_contract_event(log, false) {
println!("error ingesting log: {e:?}");
};
}
state.update_listings();
subscribe_to_logs(&state.provider, filter);
}
/// subscribe to logs from the chain with a given filter
pub fn subscribe_to_logs(eth_provider: &eth::Provider, filter: eth::Filter) {
loop {
match eth_provider.subscribe(1, filter.clone()) {
Ok(()) => break,
Err(_) => {
println!("failed to subscribe to chain! trying again in 5s...");
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
}
}
println!("subscribed to logs successfully");
}
/// fetch logs from the chain with a given filter
fn fetch_logs(eth_provider: &eth::Provider, filter: &eth::Filter) -> Vec<eth::Log> {
loop {
match eth_provider.get_logs(filter) {
Ok(res) => return res,
Err(_) => {
println!("failed to fetch logs! trying again in 5s...");
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
}
}
}
/// fetch metadata from url and verify it matches metadata_hash
pub fn fetch_metadata_from_url(
metadata_url: &str,
metadata_hash: &str,
timeout: u64,
) -> Result<kt::Erc721Metadata, AppStoreLogError> {
if let Ok(url) = url::Url::parse(metadata_url) {
if let Ok(_) =
http::send_request_await_response(http::Method::GET, url, None, timeout, vec![])
{
if let Some(body) = get_blob() {
let hash = generate_metadata_hash(&body.bytes);
if &hash == metadata_hash {
return Ok(serde_json::from_slice::<kt::Erc721Metadata>(&body.bytes)
.map_err(|_| AppStoreLogError::MetadataNotFound)?);
} else {
return Err(AppStoreLogError::MetadataHashMismatch);
}
}
}
}
Err(AppStoreLogError::MetadataNotFound)
}
/// generate a Keccak-256 hash of the metadata bytes
pub fn generate_metadata_hash(metadata: &[u8]) -> String {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(metadata);
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 {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(zip_bytes);
format!("{:x}", hasher.finalize())
}
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)??;
let Some(blob) = get_blob() else {
return Err(anyhow::anyhow!("no blob"));
};
Ok(serde_json::from_slice::<Vec<kt::PackageManifestEntry>>(
&blob.bytes,
)?)
}
pub fn new_package(
package_id: &PackageId,
state: &mut State,
metadata: kt::Erc721Metadata,
mirror: bool,
bytes: Vec<u8>,
) -> anyhow::Result<()> {
// set the version hash for this new local package
let our_version = generate_version_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 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());
};
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.
pub fn create_package_drive(
package_id: &PackageId,
package_bytes: Vec<u8>,
) -> anyhow::Result<String> {
let drive_name = format!("/{package_id}/pkg");
let blob = LazyLoadBlob {
mime: Some("application/zip".to_string()),
bytes: package_bytes,
};
// 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,
})?)
.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,
})?)
.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 {
return Err(anyhow::anyhow!(
"cannot add NewPackage: do not have capability to access vfs"
));
};
// 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,
})?)
.blob(blob)
.send_and_await_response(VFS_TIMEOUT)??;
let manifest_file = vfs::File {
path: format!("/{}/pkg/manifest.json", package_id),
timeout: VFS_TIMEOUT,
};
let manifest_bytes = manifest_file.read()?;
Ok(generate_metadata_hash(&manifest_bytes))
}
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()) {
// 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()) {
return Ok(true);
}
}
Ok(false)
}
/// given a package id, interact with VFS and kernel to get manifest,
/// grant the capabilities in manifest, then initialize and start
/// the processes in manifest.
///
/// this will also grant the process read/write access to their drive,
/// which we can only do if we were the process to create that drive.
/// 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,
our_node: &str,
wit_version: Option<u32>,
) -> anyhow::Result<()> {
// get the package manifest
let drive_path = format!("/{package_id}/pkg");
let manifest = fetch_package_manifest(package_id)?;
// first, for each process in manifest, initialize it
// then, once all have been initialized, grant them requested caps
// and finally start them.
for entry in &manifest {
let wasm_path = if entry.process_wasm_path.starts_with("/") {
entry.process_wasm_path.clone()
} else {
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 {
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()?;
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,
})?)
.send_and_await_response(VFS_TIMEOUT)??
.body(),
) {
return Err(anyhow::anyhow!("failed to read process file"));
};
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(),
) 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;
}
}
}
if entry.request_networking {
requested_capabilities.push(kt::Capability {
issuer: Address::new(our_node, ("kernel", "distro", "sys")),
params: "\"network\"".to_string(),
});
}
// always grant read/write to their drive, which we created for them
requested_capabilities.push(kt::Capability {
issuer: Address::new(our_node, ("vfs", "distro", "sys")),
params: serde_json::json!({
"kind": "read",
"drive": drive_path,
})
.to_string(),
});
requested_capabilities.push(kt::Capability {
issuer: Address::new(our_node, ("vfs", "distro", "sys")),
params: serde_json::json!({
"kind": "write",
"drive": drive_path,
})
.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()?;
}
// 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!"));
};
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()?;
} else {
println!("{process_id} manifest tried to grant 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") {
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(),
}],
},
)?)
.send()?;
}
}
} else {
println!("{process_id} manifest tried to grant invalid cap: {value}");
}
}
val => {
println!("{process_id} manifest tried to grant invalid cap: {val}");
continue;
}
}
}
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,
))?)
.send_and_await_response(VFS_TIMEOUT)??
.body(),
) else {
return Err(anyhow::anyhow!("failed to start process"));
};
}
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()?;
}
// 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,
})?)
.send_and_await_response(VFS_TIMEOUT)??;
Ok(())
}

View File

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

View File

@ -1 +0,0 @@
../../app_store/src/api.rs

View File

@ -1,13 +1,14 @@
use crate::kinode::process::main::{DownloadResponse, LocalRequest, LocalResponse};
use kinode::process::main::DownloadRequest;
use kinode_process_lib::{
await_next_message_body, call_init, println, Address, Message, NodeId, PackageId, Request,
};
mod api;
use api::*;
wit_bindgen::generate!({
path: "wit",
world: "process",
path: "target/wit",
generate_unused_types: true,
world: "app-store-sys-v0",
additional_derives: [PartialEq, serde::Deserialize, serde::Serialize],
});
call_init!(init);
@ -36,13 +37,16 @@ fn init(our: Address) {
let Ok(Ok(Message::Response { body, .. })) =
Request::to((our.node(), ("main", "app_store", "sys")))
.body(
serde_json::to_vec(&LocalRequest::Download {
package: package_id.clone(),
serde_json::to_vec(&LocalRequest::Download(DownloadRequest {
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,
})
}))
.unwrap(),
)
.send_and_await_response(5)
@ -60,7 +64,7 @@ fn init(our: Address) {
LocalResponse::DownloadResponse(DownloadResponse::Started) => {
println!("started downloading package {package_id} from {download_from}");
}
LocalResponse::DownloadResponse(DownloadResponse::Failure) => {
LocalResponse::DownloadResponse(_) => {
println!("failed to download package {package_id} from {download_from}");
}
_ => {

View File

@ -9,7 +9,7 @@ simulation-mode = []
[dependencies]
anyhow = "1.0"
bincode = "1.3.3"
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.7.2" }
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.8.0" }
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@ -6,8 +6,8 @@ mod ft_worker_lib;
use ft_worker_lib::*;
wit_bindgen::generate!({
path: "wit",
world: "process",
path: "target/wit",
world: "process-v0",
});
/// internal worker protocol

View File

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

View File

@ -1 +0,0 @@
../../app_store/src/api.rs

View File

@ -1,13 +1,13 @@
use crate::kinode::process::main::{InstallResponse, LocalRequest, LocalResponse};
use kinode_process_lib::{
await_next_message_body, call_init, println, Address, Message, PackageId, Request,
};
mod api;
use api::*;
wit_bindgen::generate!({
path: "wit",
world: "process",
path: "target/wit",
generate_unused_types: true,
world: "app-store-sys-v0",
additional_derives: [PartialEq, serde::Deserialize, serde::Serialize],
});
call_init!(init);
@ -33,7 +33,15 @@ fn init(our: Address) {
let Ok(Ok(Message::Response { body, .. })) =
Request::to((our.node(), ("main", "app_store", "sys")))
.body(serde_json::to_vec(&LocalRequest::Install(package_id.clone())).unwrap())
.body(
serde_json::to_vec(&LocalRequest::Install(
crate::kinode::process::main::PackageId {
package_name: package_id.package_name.clone(),
publisher_node: package_id.publisher_node.clone(),
},
))
.unwrap(),
)
.send_and_await_response(5)
else {
println!("install: failed to get a response from app_store..!");

View File

@ -4,12 +4,14 @@
"image": "",
"properties": {
"package_name": "app_store",
"current_version": "0.3.1",
"publisher": "sys",
"current_version": "0.3.1",
"mirrors": [],
"code_hashes": {
"0.3.1": ""
}
},
"wit_version": 0,
"dependencies": []
},
"external_url": "https://kinode.org",
"animation_url": ""

View File

@ -8,7 +8,8 @@
],
"grant_capabilities": [
"main:app_store:sys"
]
],
"wit_version": 0
},
"install.wasm": {
"root": false,
@ -19,7 +20,8 @@
],
"grant_capabilities": [
"main:app_store:sys"
]
],
"wit_version": 0
},
"uninstall.wasm": {
"root": false,
@ -30,6 +32,7 @@
],
"grant_capabilities": [
"main:app_store:sys"
]
],
"wit_version": 0
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -14,8 +14,8 @@
<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-34EVhDFF.js"></script>
<link rel="stylesheet" crossorigin href="/main:app_store:sys/assets/index-cJEV35Fc.css">
<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>

View File

@ -10,9 +10,11 @@ import classNames from "classnames";
interface ActionButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
app: AppInfo;
isIcon?: boolean;
permitMultiButton?: boolean;
launchPath?: string
}
export default function ActionButton({ app, isIcon = false, ...props }: ActionButtonProps) {
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];
@ -31,23 +33,10 @@ export default function ActionButton({ app, isIcon = false, ...props }: ActionBu
};
}, [app]);
const [launchPath, setLaunchPath] = useState('');
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)
}
}
})
}, [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)

View File

@ -15,9 +15,10 @@ interface AppEntryProps extends React.HTMLAttributes<HTMLDivElement> {
size?: "small" | "medium" | "large";
overrideImageSize?: "small" | "medium" | "large";
showMoreActions?: boolean;
launchPath?: string;
}
export default function AppEntry({ app, size = "medium", overrideImageSize, showMoreActions, ...props }: AppEntryProps) {
export default function AppEntry({ app, size = "medium", overrideImageSize, showMoreActions, launchPath, ...props }: AppEntryProps) {
const isMobile = isMobileCheck()
const navigate = useNavigate()
@ -36,19 +37,25 @@ export default function AppEntry({ app, size = "medium", overrideImageSize, show
}}
>
<AppHeader app={app} size={size} overrideImageSize={overrideImageSize} />
<ActionButton
app={app}
isIcon={!showMoreActions && size !== 'large'}
className={classNames({
'absolute': size !== 'large',
'top-2 right-2': size !== 'large' && showMoreActions,
'top-0 right-0': size !== 'large' && !showMoreActions,
'bg-orange text-lg min-w-1/5': size === 'large',
'ml-auto': size === 'large' && isMobile
})} />
{showMoreActions && <div className="absolute bottom-2 right-2">
<MoreActions app={app} />
</div>}
<div className={classNames("flex items-center", {
'absolute': size !== 'large',
'top-2 right-2': size !== 'large' && showMoreActions,
'top-0 right-0': size !== 'large' && !showMoreActions,
'ml-auto': size === 'large' && isMobile,
'min-w-1/5': size === 'large'
})}>
<ActionButton
app={app}
launchPath={launchPath}
isIcon={!showMoreActions && size !== 'large'}
className={classNames({
'bg-orange text-lg': size === 'large',
'mr-2': showMoreActions,
'w-full': size === 'large'
})}
/>
{showMoreActions && <MoreActions app={app} className="self-stretch" />}
</div>
</div>
);
}

View File

@ -4,6 +4,7 @@ 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;
@ -54,9 +55,9 @@ export default function AppHeader({
'h-20': imageSize === 'medium',
})}
/>
: <ColorDot
num={app.metadata_hash || app.state?.our_version?.toString() || ''}
dotSize={imageSize}
: <AppIconPlaceholder
text={app.metadata_hash || app.state?.our_version?.toString() || ''}
size={imageSize}
/>}
<div className={classNames("flex flex-col", {
'gap-2': isMobile,

View File

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

View File

@ -18,7 +18,7 @@ export default function DownloadButton({ app, isIcon = false, ...props }: Downlo
const [showModal, setShowModal] = useState(false);
const [mirror, setMirror] = useState(app.metadata?.properties?.mirrors?.[0] || "Other");
const [customMirror, setCustomMirror] = useState("");
const [loading, setLoading] = useState("");
const [downloading, setDownloading] = useState("");
useEffect(() => {
setMirror(app.metadata?.properties?.mirrors?.[0] || "Other");
@ -40,12 +40,12 @@ export default function DownloadButton({ app, isIcon = false, ...props }: Downlo
}
try {
setLoading(`Downloading ${getAppName(app)}...`);
setDownloading(`Downloading ${getAppName(app)}...`);
await downloadApp(app, targetMirror);
const interval = setInterval(() => {
getMyApp(app)
.then(() => {
setLoading("");
setDownloading("");
setShowModal(false);
clearInterval(interval);
getMyApps();
@ -57,7 +57,7 @@ export default function DownloadButton({ app, isIcon = false, ...props }: Downlo
window.alert(
`Failed to download app from ${targetMirror}, please try a different mirror.`
);
setLoading("");
setDownloading("");
}
}, [mirror, customMirror, app, downloadApp, getMyApp]);
@ -68,14 +68,27 @@ export default function DownloadButton({ app, isIcon = false, ...props }: Downlo
<button
{...props}
type="button"
className={classNames("text-sm self-start", props.className, { 'icon clear': isIcon })}
className={classNames("text-sm self-start", props.className, {
'icon clear': isIcon,
'black': !isIcon,
})}
disabled={!!downloading}
onClick={onClick}
>
{isIcon ? <FaDownload /> : 'Download'}
{isIcon
? <FaDownload />
: downloading
? 'Downloading...'
: 'Download'}
</button>
<Modal show={showModal} hide={() => setShowModal(false)}>
{loading ? (
<Loader msg={loading} />
{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>

View File

@ -17,7 +17,7 @@ export default function InstallButton({ app, isIcon = false, ...props }: Install
useAppsStore();
const [showModal, setShowModal] = useState(false);
const [caps, setCaps] = useState<string[]>([]);
const [loading, setLoading] = useState("");
const [installing, setInstalling] = useState("");
const onClick = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
@ -29,14 +29,14 @@ export default function InstallButton({ app, isIcon = false, ...props }: Install
const install = useCallback(async () => {
try {
setLoading(`Installing ${getAppName(app)}...`);
setInstalling(`Installing ${getAppName(app)}...`);
await installApp(app);
const interval = setInterval(() => {
getMyApp(app)
.then((app) => {
if (!app.installed) return;
setLoading("");
setInstalling("");
setShowModal(false);
clearInterval(interval);
getMyApps();
@ -46,7 +46,7 @@ export default function InstallButton({ app, isIcon = false, ...props }: Install
} catch (e) {
console.error(e);
window.alert(`Failed to install, please try again.`);
setLoading("");
setInstalling("");
}
}, [app, installApp, getMyApp]);
@ -59,14 +59,24 @@ export default function InstallButton({ app, isIcon = false, ...props }: Install
'icon clear': isIcon
})}
onClick={onClick}
disabled={!!installing}
>
{isIcon ? <FaI /> : "Install"}
{isIcon
? <FaI />
: installing
? 'Installing...'
: "Install"}
</button>
<Modal show={showModal} hide={() => setShowModal(false)}>
{loading ? (
<Loader msg={loading} />
{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:
@ -79,7 +89,7 @@ export default function InstallButton({ app, isIcon = false, ...props }: Install
<button type="button" onClick={install}>
Approve & Install
</button>
</>
</div>
)}
</Modal>
</>

View File

@ -22,7 +22,8 @@ export default function LaunchButton({ app, launchPath, isIcon = false, ...props
{...props}
type="button"
className={classNames("text-sm self-start", props.className, {
'icon clear': isIcon
'icon clear': isIcon,
'alt': !isIcon
})}
onClick={onLaunch}
>

View File

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

View File

@ -5,6 +5,7 @@ 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;
@ -55,10 +56,12 @@ export default function UpdateButton({ app, isIcon = false, ...props }: UpdateBu
<button
{...props}
type="button"
className={classNames("text-sm self-start", props.className)}
className={classNames("text-sm self-start", props.className, {
'icon clear': isIcon
})}
onClick={onClick}
>
Update
{isIcon ? <FaU /> : 'Update'}
</button>
<Modal show={showModal} hide={() => setShowModal(false)}>
{loading ? (

View File

@ -71,6 +71,10 @@ button[type="submit"],
@apply bg-white text-black border-white hover:text-white;
}
.black {
@apply bg-black text-white border-black hover:text-black hover:border-white hover:bg-white;
}
.thin {
@apply px-0 border-none;
}

View File

@ -22,6 +22,7 @@ export default function AppPage() {
const navigate = useNavigate();
const params = useParams();
const [app, setApp] = useState<AppInfo | undefined>(undefined);
const [launchPath, setLaunchPath] = useState('');
useEffect(() => {
const myApp = myApps.local.find((a) => appId(a) === params.id);
@ -55,19 +56,19 @@ export default function AppPage() {
const isMobile = isMobileCheck()
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 })}>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} />,
@ -91,6 +92,18 @@ export default function AppPage() {
}
]
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)
}
}
})
}, [app])
return (
<div className={classNames("flex flex-col w-full p-2",
{
@ -140,7 +153,16 @@ export default function AppPage() {
)
)}
</div>}
<ActionButton app={app} className={classNames("self-center bg-orange text-lg px-12")} />
<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

View File

@ -19,26 +19,30 @@ export default function StorePage() {
const { listedApps, getListedApps, rebuildIndex } = useAppsStore();
const [resultsSort, setResultsSort] = useState<string>("Recently published");
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(displayedApps.length / 10) },
{
length: Math.ceil(listedApps.length / 10)
},
(_, index) => index + 1
),
[displayedApps]
[listedApps]
);
const featuredPackageNames = ['dartfrog', 'kcal', 'memedeck', 'filter'];
useEffect(() => {
const start = (page - 1) * 10;
const end = start + 10;
setDisplayedApps(listedApps.slice(start, end));
}, [listedApps]);
}, [listedApps, page]);
// GET on load
useEffect(() => {
@ -57,27 +61,6 @@ export default function StorePage() {
.catch((error) => console.error(error));
}, []); // eslint-disable-line
// const pages = useMemo(
// () => {
// const displayedApps = query ? searchResults : latestApps;
// return Array.from(
// { length: Math.ceil((displayedApps.length - 2) / 10) },
// (_, index) => index + 1
// )
// },
// [query, searchResults, latestApps]
// );
// const featuredApps = useMemo(() => latestApps.slice(0, 2), [latestApps]);
// const displayedApps = useMemo(
// () => {
// const displayedApps = query ? searchResults : latestApps.slice(2);
// return displayedApps.slice((page - 1) * 10, page * 10)
// },
// [latestApps, searchResults, page, query]
// );
const sortApps = useCallback(async (sort: string) => {
switch (sort) {
case "Recently published":
@ -125,6 +108,23 @@ export default function StorePage() {
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])
return (
<div className={classNames("flex flex-col w-full max-h-screen p-2", {
'gap-4 max-w-screen': isMobile,
@ -162,7 +162,7 @@ export default function StorePage() {
setResultsSort(e.target.value);
sortApps(e.target.value);
}}
className={classNames({
className={classNames('hidden', {
'basis-1/5': !isMobile
})}
>
@ -172,19 +172,22 @@ export default function StorePage() {
<option>Recently updated</option>
</select>
</div>
{!searchQuery ? <div className={classNames("flex flex-col", {
{!searchQuery && <div className={classNames("flex flex-col", {
'gap-4': !isMobile,
'grow overflow-y-auto gap-2 items-center px-2': isMobile
})}>
<h2>Top apps this week...</h2>
<h2>Featured Apps</h2>
<div className={classNames("flex gap-2", {
'flex-col': isMobile
})}>
{displayedApps.slice(0, 4).map((app) => (
{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
@ -192,55 +195,45 @@ export default function StorePage() {
/>
))}
</div>
<h2>Must-have apps!</h2>
<div className={classNames("flex gap-2", {
'flex-col': isMobile
})}>
{displayedApps.slice(0, 5).map((app) => (
<AppEntry
key={appId(app) + (app.state?.our_version || "")}
size={isMobile ? 'medium' : 'small'}
app={app}
overrideImageSize={isMobile ? 'medium' : 'large'}
className={classNames("grow", {
'w-1/6': !isMobile,
'w-full': isMobile
})}
/>
))}
</div>
</div> : <div className={classNames("flex-col-center grow", {
</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.map(app => <AppEntry
size='large'
app={app}
className="self-stretch items-center"
overrideImageSize="medium"
/>)}
</div>}
<div className="flex flex-col gap-2 overflow-y-auto">
{pages.length > 1 && (
<div className="flex self-center">
{page !== pages[0] && (
<FaChevronLeft onClick={() => setPage(page - 1)} />
)}
{pages.map((p) => (
<div
key={`page-${p}`}
className={classNames('my-1 mx-2', { "font-bold": p === page })}
onClick={() => setPage(p)}
>
{p}
</div>
))}
{page !== pages[pages.length - 1] && (
<FaChevronRight onClick={() => setPage(page + 1)} />
)}
</div>
)}
{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>
{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>
);
}

View File

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

View File

@ -1 +0,0 @@
../../app_store/src/api.rs

View File

@ -1,13 +1,13 @@
use crate::kinode::process::main::{LocalRequest, LocalResponse, UninstallResponse};
use kinode_process_lib::{
await_next_message_body, call_init, println, Address, Message, PackageId, Request,
};
mod api;
use api::*;
wit_bindgen::generate!({
path: "wit",
world: "process",
path: "target/wit",
generate_unused_types: true,
world: "app-store-sys-v0",
additional_derives: [PartialEq, serde::Deserialize, serde::Serialize],
});
call_init!(init);
@ -33,7 +33,15 @@ fn init(our: Address) {
let Ok(Ok(Message::Response { body, .. })) =
Request::to((our.node(), ("main", "app_store", "sys")))
.body(serde_json::to_vec(&LocalRequest::Uninstall(package_id.clone())).unwrap())
.body(
serde_json::to_vec(&LocalRequest::Uninstall(
crate::kinode::process::main::PackageId {
package_name: package_id.package_name.clone(),
publisher_node: package_id.publisher_node.clone(),
},
))
.unwrap(),
)
.send_and_await_response(5)
else {
println!("uninstall: failed to get a response from app_store..!");

View File

@ -0,0 +1,32 @@
interface chess {
/// Our "chess protocol" request/response format. We'll always serialize these
/// to a byte vector and send them over IPC.
variant request {
new-game(new-game-request),
move(move-request),
resign(string),
}
variant response {
new-game-accepted,
new-game-rejected,
move-accepted,
move-rejected,
}
record new-game-request {
white: string,
black: string,
}
record move-request {
game-id: string,
move-str: string,
}
}
world chess-sys-v0 {
import chess;
include process-v0;
}

View File

@ -10,7 +10,7 @@ simulation-mode = []
anyhow = "1.0"
base64 = "0.22.0"
bincode = "1.3.3"
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.7.2" }
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.8.0" }
pleco = "0.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@ -8,28 +8,12 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
extern crate base64;
use crate::kinode::process::chess::{
MoveRequest, NewGameRequest, Request as ChessRequest, Response as ChessResponse,
};
const ICON: &str = include_str!("icon");
//
// Our "chess protocol" request/response format. We'll always serialize these
// to a byte vector and send them over IPC.
//
#[derive(Debug, Serialize, Deserialize)]
enum ChessRequest {
NewGame { white: String, black: String },
Move { game_id: String, move_str: String },
Resign(String),
}
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
enum ChessResponse {
NewGameAccepted,
NewGameRejected,
MoveAccepted,
MoveRejected,
}
//
// Our serializable state format.
//
@ -98,8 +82,10 @@ fn send_ws_update(our: &Address, game: &Game, open_channels: &HashSet<u32>) -> a
// Boilerplate: generate the wasm bindings for a process
wit_bindgen::generate!({
path: "wit",
world: "process",
path: "target/wit",
world: "chess-sys-v0",
generate_unused_types: true,
additional_derives: [PartialEq, serde::Deserialize, serde::Serialize],
});
// After generating bindings, use this macro to define the Component struct
// and its init() function, which the kernel will look for on startup.
@ -250,7 +236,7 @@ fn handle_chess_request(
let game_id = source_node;
match action {
ChessRequest::NewGame { white, black } => {
ChessRequest::NewGame(NewGameRequest { white, black }) => {
// Make a new game with source.node
// This will replace any existing game with source.node!
if state.games.contains_key(game_id) {
@ -277,7 +263,7 @@ fn handle_chess_request(
.body(serde_json::to_vec(&ChessResponse::NewGameAccepted)?)
.send()
}
ChessRequest::Move { ref move_str, .. } => {
ChessRequest::Move(MoveRequest { ref move_str, .. }) => {
// Get the associated game, and respond with an error if
// we don't have it in our state.
let Some(game) = state.games.get_mut(game_id) else {
@ -330,7 +316,7 @@ fn handle_local_request(
action: &ChessRequest,
) -> anyhow::Result<()> {
match action {
ChessRequest::NewGame { white, black } => {
ChessRequest::NewGame(NewGameRequest { white, black }) => {
// Create a new game. We'll enforce that one of the two players is us.
if white != &our.node && black != &our.node {
return Err(anyhow::anyhow!("cannot start a game without us!"));
@ -371,7 +357,7 @@ fn handle_local_request(
save_chess_state(&state);
Ok(())
}
ChessRequest::Move { game_id, move_str } => {
ChessRequest::Move(MoveRequest { game_id, move_str }) => {
// Make a move. We'll enforce that it's our turn. The game_id is the
// person we're playing with.
let Some(game) = state.games.get_mut(game_id) else {
@ -489,10 +475,12 @@ fn handle_http_request(
// send the other player a new game request
let Ok(msg) = Request::new()
.target((game_id, our.process.clone()))
.body(serde_json::to_vec(&ChessRequest::NewGame {
white: player_white.clone(),
black: player_black.clone(),
})?)
.body(serde_json::to_vec(&ChessRequest::NewGame(
NewGameRequest {
white: player_white.clone(),
black: player_black.clone(),
},
))?)
.send_and_await_response(5)?
else {
return Err(anyhow::anyhow!(
@ -588,10 +576,10 @@ fn handle_http_request(
// if so, update the records
let Ok(msg) = Request::new()
.target((game_id, our.process.clone()))
.body(serde_json::to_vec(&ChessRequest::Move {
.body(serde_json::to_vec(&ChessRequest::Move(MoveRequest {
game_id: game_id.to_string(),
move_str: move_str.to_string(),
})?)
}))?)
.send_and_await_response(5)?
else {
return Err(anyhow::anyhow!(

View File

@ -9,7 +9,9 @@
"mirrors": [],
"code_hashes": {
"0.2.1": ""
}
},
"wit_version": 0,
"dependencies": []
},
"external_url": "https://kinode.org",
"animation_url": ""

View File

@ -0,0 +1,23 @@
interface homepage {
/// The request format to add or remove an app from the homepage. You must have messaging
/// access to `homepage:homepage:sys` in order to perform this. Serialize using serde_json.
variant request {
/// the package and process name will come from request source.
/// the path will automatically have the process_id prepended.
/// the icon is a base64 encoded image.
add(add-request),
remove,
}
record add-request {
label: string,
icon: option<string>,
path: option<string>,
widget: option<string>,
}
}
world homepage-sys-v0 {
import homepage;
include process-v0;
}

View File

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

View File

@ -0,0 +1,19 @@
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="20" width="960" height="960" rx="480" fill="url(#paint0_linear_1955_10320)" fill-opacity="0.4"/>
<rect x="20" y="20" width="960" height="960" rx="480" stroke="url(#paint1_linear_1955_10320)" stroke-width="40"/>
<path d="M643.765 402.64L806.397 363.16L528.091 363L513.909 400.731L194 390.541L464.898 530.161L374.444 772.306L643.765 402.64Z" fill="url(#paint2_linear_1955_10320)"/>
<defs>
<linearGradient id="paint0_linear_1955_10320" x1="500" y1="0" x2="500" y2="1000" gradientUnits="userSpaceOnUse">
<stop stop-color="#F35422"/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_1955_10320" x1="782.5" y1="73.5" x2="185.5" y2="894.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F35422"/>
<stop offset="1" stop-color="#A5310C"/>
</linearGradient>
<linearGradient id="paint2_linear_1955_10320" x1="641" y1="433" x2="363" y2="772" gradientUnits="userSpaceOnUse">
<stop stop-color="#ED5121"/>
<stop offset="1" stop-color="#A7320D"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,4 @@
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1000" height="1000" rx="500" fill="#FFF5D9" fill-opacity="0.4"/>
<path d="M643.765 402.64L806.397 363.16L528.091 363L513.909 400.731L194 390.541L464.898 530.161L374.444 772.306L643.765 402.64Z" fill="#FFF5D9"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

View File

@ -0,0 +1,19 @@
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="20" width="960" height="960" rx="480" fill="url(#paint0_linear_1955_10318)" fill-opacity="0.4"/>
<rect x="20" y="20" width="960" height="960" rx="480" stroke="url(#paint1_linear_1955_10318)" stroke-width="40"/>
<path d="M643.765 402.64L806.397 363.16L528.091 363L513.909 400.731L194 390.541L464.898 530.161L374.444 772.306L643.765 402.64Z" fill="url(#paint2_linear_1955_10318)"/>
<defs>
<linearGradient id="paint0_linear_1955_10318" x1="500" y1="0" x2="500" y2="1000" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_1955_10318" x1="782.5" y1="73.5" x2="185.5" y2="894.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-color="#C9C1A9"/>
</linearGradient>
<linearGradient id="paint2_linear_1955_10318" x1="641" y1="433" x2="363" y2="772" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-color="#999382"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,24 @@
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="20" width="960" height="960" rx="480" fill="url(#paint0_linear_1955_10328)" fill-opacity="0.4"/>
<rect x="20" y="20" width="960" height="960" rx="480" stroke="url(#paint1_linear_1955_10328)" stroke-width="40"/>
<path d="M324.175 293L324 706.59H405.606L405.664 293H324.175Z" fill="url(#paint2_linear_1955_10328)"/>
<path d="M416.201 507.647L560.239 293H659.477L512.986 507.647L677 706.59H570.402L416.201 507.647Z" fill="url(#paint3_linear_1955_10328)"/>
<defs>
<linearGradient id="paint0_linear_1955_10328" x1="500" y1="0" x2="500" y2="1000" gradientUnits="userSpaceOnUse">
<stop stop-color="#F35422"/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_1955_10328" x1="782.5" y1="73.5" x2="185.5" y2="894.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F35422"/>
<stop offset="1" stop-color="#A5310C"/>
</linearGradient>
<linearGradient id="paint2_linear_1955_10328" x1="401.384" y1="294.5" x2="214.388" y2="350.686" gradientUnits="userSpaceOnUse">
<stop stop-color="#EF5321"/>
<stop offset="1" stop-color="#A7320D"/>
</linearGradient>
<linearGradient id="paint3_linear_1955_10328" x1="663.332" y1="294.5" x2="324.349" y2="619.771" gradientUnits="userSpaceOnUse">
<stop stop-color="#EF5321"/>
<stop offset="1" stop-color="#A7320D"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,5 @@
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1000" height="1000" rx="500" fill="#FFF5D9" fill-opacity="0.4"/>
<path d="M324.175 293L324 706.59H405.606L405.664 293H324.175Z" fill="#FFF5D9"/>
<path d="M416.201 507.647L560.239 293H659.477L512.986 507.647L677 706.59H570.402L416.201 507.647Z" fill="#FFF5D9"/>
</svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,24 @@
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="20" width="960" height="960" rx="480" fill="url(#paint0_linear_1955_10324)" fill-opacity="0.4"/>
<rect x="20" y="20" width="960" height="960" rx="480" stroke="url(#paint1_linear_1955_10324)" stroke-width="40"/>
<path d="M324.175 293L324 706.59H405.606L405.664 293H324.175Z" fill="url(#paint2_linear_1955_10324)"/>
<path d="M416.201 507.647L560.239 293H659.477L512.986 507.647L677 706.59H570.402L416.201 507.647Z" fill="url(#paint3_linear_1955_10324)"/>
<defs>
<linearGradient id="paint0_linear_1955_10324" x1="500" y1="0" x2="500" y2="1000" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_1955_10324" x1="782.5" y1="73.5" x2="185.5" y2="894.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-color="#C9C1A9"/>
</linearGradient>
<linearGradient id="paint2_linear_1955_10324" x1="401.037" y1="294" x2="218.907" y2="346.955" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-color="#999382"/>
</linearGradient>
<linearGradient id="paint3_linear_1955_10324" x1="662.224" y1="294" x2="323.472" y2="608.542" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-color="#999382"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,29 @@
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="20" width="960" height="960" rx="480" fill="url(#paint0_linear_1955_10341)" fill-opacity="0.4"/>
<rect x="20" y="20" width="960" height="960" rx="480" stroke="url(#paint1_linear_1955_10341)" stroke-width="40"/>
<path d="M542.456 364.853C545.899 363.941 625.589 334.618 683.085 319.231C683.535 319.111 684.206 318.408 684.642 319.465C685.048 320.44 684.364 320.662 683.807 321.016C666.395 332.269 522.297 406.948 510.86 411.499C510.575 411.613 510.296 411.752 509.948 411.911C510.455 412.322 510.86 411.999 511.246 411.892C546.626 402.442 637.103 368.435 645.85 366.125C646.401 365.979 647.11 365.365 647.566 366.422C648.002 367.435 647.762 367.967 646.8 368.568C627.622 380.568 519.208 443.627 483.809 452.912C483.606 452.962 483.41 453.07 483.201 453.089C482.536 453.171 482.302 453.507 482.448 454.165C482.6 454.874 483.074 454.728 483.549 454.646C490.999 454.646 560 438.399 573.551 436.126C574.583 435.956 575.615 435.797 576.691 435.753C553.652 452.703 512.531 468.627 486.979 480.893C487.011 481.438 487.486 481.514 487.802 481.716C501.657 490.387 515.088 499.634 527.753 510.008C543.696 523.065 555.886 539.066 565.931 556.927C576.153 575.104 584.716 594.004 591.552 613.7C591.647 613.979 591.906 614.245 591.628 614.808C590.058 614.061 588.482 613.321 586.912 612.555C578.21 608.308 569.595 603.877 560.583 600.289C546.956 594.865 532.854 591.396 518.252 589.921C510.683 589.155 503.113 588.985 495.511 589.092C480.131 589.314 464.808 588.466 449.909 584.421C425.738 577.851 408.845 562.731 398.832 539.819C390.243 520.166 384.458 499.736 381.363 478.52C379.869 468.292 378.977 458.013 378.54 447.69C378.084 437 377.673 426.31 377.255 415.62C377.23 414.987 376.933 414.271 377.85 413.873C378.705 413.506 379.236 413.911 379.825 414.392C388.452 421.48 403.433 434.88 412.022 442.006C413.098 442.899 413.68 442.892 414.687 441.88C427.978 428.55 573.645 295.118 593.28 289.041C602.76 286.106 727.327 270.344 727.536 271.021C727.77 271.774 727.112 271.92 726.707 272.16C721.491 275.236 548.468 362.061 543.095 364.207C542.854 364.302 542.633 364.435 542.405 364.555C542.424 364.656 542.437 364.751 542.456 364.853Z" fill="url(#paint2_linear_1955_10341)"/>
<path d="M376.237 671.182C379.712 667.169 381.788 662.403 383.61 657.511C387.054 648.257 387.68 638.618 387.459 628.871C387.13 614.383 383.788 600.522 378.667 587.047C372.23 570.078 363.509 554.293 353.743 539.052C340.401 518.222 325.381 498.583 310.963 478.525C301.646 465.563 292.368 452.563 284.456 438.676C278.747 428.651 274.981 417.91 273.576 406.422C271.88 392.517 273.969 379.099 279.317 366.212C286.475 348.978 298.419 336.079 315.185 327.819C327.565 321.724 340.704 319.452 354.464 320.642C366.8 321.711 379.066 323.3 391.326 325.034C400.364 326.313 409.409 325.629 418.39 324.066C429.15 322.192 439.523 318.806 450.017 315.914C475.486 308.907 500.93 301.806 526.392 294.793C539.836 291.09 553.298 287.445 566.779 283.894C572.381 282.419 578.159 281.748 583.843 280.679C584.349 280.584 584.742 280.47 584.894 281.185C585.058 281.957 584.653 282.198 584.046 282.419C580.128 283.818 576.223 285.236 572.311 286.641C524.975 303.635 477.657 320.692 430.282 337.592C415.909 342.718 403.332 350.421 393.047 361.763C387.104 368.32 382.3 375.656 378.028 383.384C372.42 393.523 366.363 403.403 360.059 413.125C351.027 427.049 346.92 442.309 347.939 458.892C348.686 470.968 352.224 482.354 356.812 493.45C363.604 509.874 372.041 525.476 380.693 540.963C386.471 551.305 392.307 561.616 398.098 571.952C399.491 574.439 400.06 577.161 400.079 579.996C400.124 586.218 400.149 592.439 400.174 598.661C400.269 622.332 400.32 646.01 400.465 669.682C400.573 687.024 401.143 704.366 401.13 721.708C401.13 723.341 401.136 724.98 401.256 726.607C401.339 727.753 400.883 728 399.813 727.993C392.009 727.955 384.205 727.974 376.401 727.974C366.433 727.974 356.471 727.949 346.502 728C345.186 728.006 344.704 727.784 344.723 726.31C345.097 692.252 345.312 658.188 344.913 624.13C344.654 602.041 343.996 579.964 344.059 557.869C344.065 554.976 344.363 553.704 345.458 552.09C347.622 555.546 349.47 559.116 351.154 562.787C358.281 578.331 365.838 593.686 372.452 609.465C375.731 617.275 379.003 625.086 380.591 633.491C382.908 645.757 381.693 657.599 376.731 669.049C376.458 669.675 376.231 670.315 375.984 670.948C375.781 671.106 375.629 671.27 375.825 671.536C375.958 671.422 376.098 671.302 376.231 671.188L376.237 671.182ZM355.388 343.421C349.983 343.364 345.401 347.687 345.331 352.908C345.255 358.522 349.717 363.2 355.255 363.314C360.528 363.421 365.23 358.839 365.281 353.541C365.338 347.845 361.072 343.484 355.382 343.421H355.388Z" fill="url(#paint3_linear_1955_10341)"/>
<path d="M719.799 727.081H718.28C685.216 727.081 652.146 727.081 619.082 727.106C617.873 727.106 617.057 726.758 616.24 725.847C602.366 710.391 588.163 695.232 573.999 680.042C557.283 662.117 540.593 644.161 524.339 625.813C517.453 618.04 510.611 610.23 503.731 602.451C503.263 601.92 502.642 601.515 502.092 601.053C502.104 600.888 502.117 600.723 502.13 600.559C504.826 600.559 507.529 600.508 510.225 600.565C535.498 601.135 558.473 609.489 580.423 621.294C600.917 632.319 619.62 645.984 637.779 660.427C654.684 673.877 670.925 688.124 687.527 701.935C697.976 710.631 708.552 719.157 719.793 727.075L719.799 727.081Z" fill="url(#paint4_linear_1955_10341)"/>
<defs>
<linearGradient id="paint0_linear_1955_10341" x1="500" y1="0" x2="500" y2="1000" gradientUnits="userSpaceOnUse">
<stop stop-color="#F35422"/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_1955_10341" x1="782.5" y1="73.5" x2="185.5" y2="894.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F35422"/>
<stop offset="1" stop-color="#A5310C"/>
</linearGradient>
<linearGradient id="paint2_linear_1955_10341" x1="720.197" y1="272.881" x2="369.838" y2="774.826" gradientUnits="userSpaceOnUse">
<stop stop-color="#EA5020"/>
<stop offset="1" stop-color="#A6320D"/>
</linearGradient>
<linearGradient id="paint3_linear_1955_10341" x1="578.354" y1="283.056" x2="92.7505" y2="758.997" gradientUnits="userSpaceOnUse">
<stop stop-color="#EA5020"/>
<stop offset="1" stop-color="#A6320D"/>
</linearGradient>
<linearGradient id="paint4_linear_1955_10341" x1="715.209" y1="601.23" x2="618.155" y2="835.904" gradientUnits="userSpaceOnUse">
<stop stop-color="#EA5020"/>
<stop offset="1" stop-color="#A6320D"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -0,0 +1,6 @@
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1000" height="1000" rx="500" fill="#FFF5D9" fill-opacity="0.4"/>
<path d="M542.456 365.853C545.899 364.941 625.589 335.618 683.085 320.231C683.535 320.111 684.206 319.408 684.642 320.465C685.048 321.44 684.364 321.662 683.807 322.016C666.395 333.269 522.297 407.948 510.86 412.499C510.575 412.613 510.296 412.752 509.948 412.911C510.455 413.322 510.86 412.999 511.246 412.892C546.626 403.442 637.103 369.435 645.85 367.125C646.401 366.979 647.11 366.365 647.566 367.422C648.002 368.435 647.762 368.967 646.8 369.568C627.622 381.568 519.208 444.627 483.809 453.912C483.606 453.962 483.41 454.07 483.201 454.089C482.536 454.171 482.302 454.507 482.448 455.165C482.6 455.874 483.074 455.728 483.549 455.646C490.999 455.646 560 439.399 573.551 437.126C574.583 436.956 575.615 436.797 576.691 436.753C553.652 453.703 512.531 469.627 486.979 481.893C487.011 482.438 487.486 482.514 487.802 482.716C501.657 491.387 515.088 500.634 527.753 511.008C543.696 524.065 555.886 540.066 565.931 557.927C576.153 576.104 584.716 595.004 591.552 614.7C591.647 614.979 591.906 615.245 591.628 615.808C590.058 615.061 588.482 614.321 586.912 613.555C578.21 609.308 569.595 604.877 560.583 601.289C546.956 595.865 532.854 592.396 518.252 590.921C510.683 590.155 503.113 589.985 495.511 590.092C480.131 590.314 464.808 589.466 449.909 585.421C425.738 578.851 408.845 563.731 398.832 540.819C390.243 521.166 384.458 500.736 381.363 479.52C379.869 469.292 378.977 459.013 378.54 448.69C378.084 438 377.673 427.31 377.255 416.62C377.23 415.987 376.933 415.271 377.85 414.873C378.705 414.506 379.236 414.911 379.825 415.392C388.452 422.48 403.433 435.88 412.022 443.006C413.098 443.899 413.68 443.892 414.687 442.88C427.978 429.55 573.645 296.118 593.28 290.041C602.76 287.106 727.327 271.344 727.536 272.021C727.77 272.774 727.112 272.92 726.707 273.16C721.491 276.236 548.468 363.061 543.095 365.207C542.854 365.302 542.633 365.435 542.405 365.555C542.424 365.656 542.437 365.751 542.456 365.853Z" fill="#FFF5D9"/>
<path d="M376.237 672.182C379.712 668.169 381.788 663.403 383.61 658.511C387.054 649.257 387.68 639.618 387.459 629.871C387.13 615.383 383.788 601.522 378.667 588.047C372.23 571.078 363.509 555.293 353.743 540.052C340.401 519.222 325.381 499.583 310.963 479.525C301.646 466.563 292.368 453.563 284.456 439.676C278.747 429.651 274.981 418.91 273.576 407.422C271.88 393.517 273.969 380.099 279.317 367.212C286.475 349.978 298.419 337.079 315.185 328.819C327.565 322.724 340.704 320.452 354.464 321.642C366.8 322.711 379.066 324.3 391.326 326.034C400.364 327.313 409.409 326.629 418.39 325.066C429.15 323.192 439.523 319.806 450.017 316.914C475.486 309.907 500.93 302.806 526.392 295.793C539.836 292.09 553.298 288.445 566.779 284.894C572.381 283.419 578.159 282.748 583.843 281.679C584.349 281.584 584.742 281.47 584.894 282.185C585.058 282.957 584.653 283.198 584.046 283.419C580.128 284.818 576.223 286.236 572.311 287.641C524.975 304.635 477.657 321.692 430.282 338.592C415.909 343.718 403.332 351.421 393.047 362.763C387.104 369.32 382.3 376.656 378.028 384.384C372.42 394.523 366.363 404.403 360.059 414.125C351.027 428.049 346.92 443.309 347.939 459.892C348.686 471.968 352.224 483.354 356.812 494.45C363.604 510.874 372.041 526.476 380.693 541.963C386.471 552.305 392.307 562.616 398.098 572.952C399.491 575.439 400.06 578.161 400.079 580.996C400.124 587.218 400.149 593.439 400.174 599.661C400.269 623.332 400.32 647.01 400.465 670.682C400.573 688.024 401.143 705.366 401.13 722.708C401.13 724.341 401.136 725.98 401.256 727.607C401.339 728.753 400.883 729 399.813 728.993C392.009 728.955 384.205 728.974 376.401 728.974C366.433 728.974 356.471 728.949 346.502 729C345.186 729.006 344.704 728.784 344.723 727.31C345.097 693.252 345.312 659.188 344.913 625.13C344.654 603.041 343.996 580.964 344.059 558.869C344.065 555.976 344.363 554.704 345.458 553.09C347.622 556.546 349.47 560.116 351.154 563.787C358.281 579.331 365.838 594.686 372.452 610.465C375.731 618.275 379.003 626.086 380.591 634.491C382.908 646.757 381.693 658.599 376.731 670.049C376.458 670.675 376.231 671.315 375.984 671.948C375.781 672.106 375.629 672.27 375.825 672.536C375.958 672.422 376.098 672.302 376.231 672.188L376.237 672.182ZM355.388 344.421C349.983 344.364 345.401 348.687 345.331 353.908C345.255 359.522 349.717 364.2 355.255 364.314C360.528 364.421 365.23 359.839 365.281 354.541C365.338 348.845 361.072 344.484 355.382 344.421H355.388Z" fill="#FFF5D9"/>
<path d="M719.799 728.081H718.28C685.216 728.081 652.146 728.081 619.082 728.106C617.873 728.106 617.057 727.758 616.24 726.847C602.366 711.391 588.163 696.232 573.999 681.042C557.283 663.117 540.593 645.161 524.339 626.813C517.453 619.04 510.611 611.23 503.731 603.451C503.263 602.92 502.642 602.515 502.092 602.053C502.104 601.888 502.117 601.723 502.13 601.559C504.826 601.559 507.529 601.508 510.225 601.565C535.498 602.135 558.473 610.489 580.423 622.294C600.917 633.319 619.62 646.984 637.779 661.427C654.684 674.877 670.925 689.124 687.527 702.935C697.976 711.631 708.552 720.157 719.793 728.075L719.799 728.081Z" fill="#FFF5D9"/>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -0,0 +1,29 @@
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="20" width="960" height="960" rx="480" fill="url(#paint0_linear_1955_10335)" fill-opacity="0.4"/>
<rect x="20" y="20" width="960" height="960" rx="480" stroke="url(#paint1_linear_1955_10335)" stroke-width="40"/>
<path d="M542.456 364.853C545.899 363.941 625.589 334.618 683.085 319.231C683.535 319.111 684.206 318.408 684.642 319.465C685.048 320.44 684.364 320.662 683.807 321.016C666.395 332.269 522.297 406.948 510.86 411.499C510.575 411.613 510.296 411.752 509.948 411.911C510.455 412.322 510.86 411.999 511.246 411.892C546.626 402.442 637.103 368.435 645.85 366.125C646.401 365.979 647.11 365.365 647.566 366.422C648.002 367.435 647.762 367.967 646.8 368.568C627.622 380.568 519.208 443.627 483.809 452.912C483.606 452.962 483.41 453.07 483.201 453.089C482.536 453.171 482.302 453.507 482.448 454.165C482.6 454.874 483.074 454.728 483.549 454.646C490.999 454.646 560 438.399 573.551 436.126C574.583 435.956 575.615 435.797 576.691 435.753C553.652 452.703 512.531 468.627 486.979 480.893C487.011 481.438 487.486 481.514 487.802 481.716C501.657 490.387 515.088 499.634 527.753 510.008C543.696 523.065 555.886 539.066 565.931 556.927C576.153 575.104 584.716 594.004 591.552 613.7C591.647 613.979 591.906 614.245 591.628 614.808C590.058 614.061 588.482 613.321 586.912 612.555C578.21 608.308 569.595 603.877 560.583 600.289C546.956 594.865 532.854 591.396 518.252 589.921C510.683 589.155 503.113 588.985 495.511 589.092C480.131 589.314 464.808 588.466 449.909 584.421C425.738 577.851 408.845 562.731 398.832 539.819C390.243 520.166 384.458 499.736 381.363 478.52C379.869 468.292 378.977 458.013 378.54 447.69C378.084 437 377.673 426.31 377.255 415.62C377.23 414.987 376.933 414.271 377.85 413.873C378.705 413.506 379.236 413.911 379.825 414.392C388.452 421.48 403.433 434.88 412.022 442.006C413.098 442.899 413.68 442.892 414.687 441.88C427.978 428.55 573.645 295.118 593.28 289.041C602.76 286.106 727.327 270.344 727.536 271.021C727.77 271.774 727.112 271.92 726.707 272.16C721.491 275.236 548.468 362.061 543.095 364.207C542.854 364.302 542.633 364.435 542.405 364.555C542.424 364.656 542.437 364.751 542.456 364.853Z" fill="url(#paint2_linear_1955_10335)"/>
<path d="M376.237 671.182C379.712 667.169 381.788 662.403 383.61 657.511C387.054 648.257 387.68 638.618 387.459 628.871C387.13 614.383 383.788 600.522 378.667 587.047C372.23 570.078 363.509 554.293 353.743 539.052C340.401 518.222 325.381 498.583 310.963 478.525C301.646 465.563 292.368 452.563 284.456 438.676C278.747 428.651 274.981 417.91 273.576 406.422C271.88 392.517 273.969 379.099 279.317 366.212C286.475 348.978 298.419 336.079 315.185 327.819C327.565 321.724 340.704 319.452 354.464 320.642C366.8 321.711 379.066 323.3 391.326 325.034C400.364 326.313 409.409 325.629 418.39 324.066C429.15 322.192 439.523 318.806 450.017 315.914C475.486 308.907 500.93 301.806 526.392 294.793C539.836 291.09 553.298 287.445 566.779 283.894C572.381 282.419 578.159 281.748 583.843 280.679C584.349 280.584 584.742 280.47 584.894 281.185C585.058 281.957 584.653 282.198 584.046 282.419C580.128 283.818 576.223 285.236 572.311 286.641C524.975 303.635 477.657 320.692 430.282 337.592C415.909 342.718 403.332 350.421 393.047 361.763C387.104 368.32 382.3 375.656 378.028 383.384C372.42 393.523 366.363 403.403 360.059 413.125C351.027 427.049 346.92 442.309 347.939 458.892C348.686 470.968 352.224 482.354 356.812 493.45C363.604 509.874 372.041 525.476 380.693 540.963C386.471 551.305 392.307 561.616 398.098 571.952C399.491 574.439 400.06 577.161 400.079 579.996C400.124 586.218 400.149 592.439 400.174 598.661C400.269 622.332 400.32 646.01 400.465 669.682C400.573 687.024 401.143 704.366 401.13 721.708C401.13 723.341 401.136 724.98 401.256 726.607C401.339 727.753 400.883 728 399.813 727.993C392.009 727.955 384.205 727.974 376.401 727.974C366.433 727.974 356.471 727.949 346.502 728C345.186 728.006 344.704 727.784 344.723 726.31C345.097 692.252 345.312 658.188 344.913 624.13C344.654 602.041 343.996 579.964 344.059 557.869C344.065 554.976 344.363 553.704 345.458 552.09C347.622 555.546 349.47 559.116 351.154 562.787C358.281 578.331 365.838 593.686 372.452 609.465C375.731 617.275 379.003 625.086 380.591 633.491C382.908 645.757 381.693 657.599 376.731 669.049C376.458 669.675 376.231 670.315 375.984 670.948C375.781 671.106 375.629 671.27 375.825 671.536C375.958 671.422 376.098 671.302 376.231 671.188L376.237 671.182ZM355.388 343.421C349.983 343.364 345.401 347.687 345.331 352.908C345.255 358.522 349.717 363.2 355.255 363.314C360.528 363.421 365.23 358.839 365.281 353.541C365.338 347.845 361.072 343.484 355.382 343.421H355.388Z" fill="url(#paint3_linear_1955_10335)"/>
<path d="M719.799 727.081H718.28C685.216 727.081 652.146 727.081 619.082 727.106C617.873 727.106 617.057 726.758 616.24 725.847C602.366 710.391 588.163 695.232 573.999 680.042C557.283 662.117 540.593 644.161 524.339 625.813C517.453 618.04 510.611 610.23 503.731 602.451C503.263 601.92 502.642 601.515 502.092 601.053C502.104 600.888 502.117 600.723 502.13 600.559C504.826 600.559 507.529 600.508 510.225 600.565C535.498 601.135 558.473 609.489 580.423 621.294C600.917 632.319 619.62 645.984 637.779 660.427C654.684 673.877 670.925 688.124 687.527 701.935C697.976 710.631 708.552 719.157 719.793 727.075L719.799 727.081Z" fill="url(#paint4_linear_1955_10335)"/>
<defs>
<linearGradient id="paint0_linear_1955_10335" x1="500" y1="0" x2="500" y2="1000" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_1955_10335" x1="782.5" y1="73.5" x2="185.5" y2="894.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-color="#C9C1A9"/>
</linearGradient>
<linearGradient id="paint2_linear_1955_10335" x1="656.606" y1="314.634" x2="320.029" y2="729.296" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-color="#999382"/>
</linearGradient>
<linearGradient id="paint3_linear_1955_10335" x1="521.743" y1="337.389" x2="80.659" y2="709.146" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-color="#999382"/>
</linearGradient>
<linearGradient id="paint4_linear_1955_10335" x1="675.699" y1="616.601" x2="576.792" y2="822.259" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF5D9"/>
<stop offset="1" stop-color="#999382"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -1,6 +1,6 @@
#![feature(let_chains)]
use kinode_process_lib::{
await_message, call_init,
await_message, call_init, get_blob,
http::{
bind_http_path, bind_http_static_path, send_response, serve_ui, HttpServerError,
HttpServerRequest, StatusCode,
@ -10,21 +10,10 @@ use kinode_process_lib::{
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
/// The request format to add or remove an app from the homepage. You must have messaging
/// access to `homepage:homepage:sys` in order to perform this. Serialize using serde_json.
#[derive(Serialize, Deserialize)]
enum HomepageRequest {
/// the package and process name will come from request source.
/// the path will automatically have the process_id prepended.
/// the icon is a base64 encoded image.
Add {
label: String,
icon: Option<String>,
path: Option<String>,
widget: Option<String>,
},
Remove,
}
use crate::kinode::process::homepage::{AddRequest, Request as HomepageRequest};
/// Fetching OS version from main package.. LMK if there's a better way...
const CARGO_TOML: &str = include_str!("../../../../Cargo.toml");
#[derive(Serialize, Deserialize)]
struct HomepageApp {
@ -33,13 +22,32 @@ struct HomepageApp {
label: String,
base64_icon: Option<String>,
widget: Option<String>,
order: Option<u16>,
}
wit_bindgen::generate!({
path: "wit",
world: "process",
path: "target/wit",
world: "homepage-sys-v0",
generate_unused_types: true,
additional_derives: [serde::Deserialize, serde::Serialize],
});
const ICON_0: &str = include_str!("./icons/bird-white.svg");
const ICON_1: &str = include_str!("./icons/bird-orange.svg");
const ICON_2: &str = include_str!("./icons/bird-plain.svg");
const ICON_3: &str = include_str!("./icons/k-orange.svg");
const ICON_4: &str = include_str!("./icons/k-plain.svg");
const ICON_5: &str = include_str!("./icons/k-white.svg");
const ICON_6: &str = include_str!("./icons/kbird-orange.svg");
const ICON_7: &str = include_str!("./icons/kbird-plain.svg");
const ICON_8: &str = include_str!("./icons/kbird-white.svg");
const ICON_9: &str = include_str!("./icons/kbranch-orange.svg");
const ICON_A: &str = include_str!("./icons/kbranch-plain.svg");
const ICON_B: &str = include_str!("./icons/kbranch-white.svg");
const ICON_C: &str = include_str!("./icons/kflower-orange.svg");
const ICON_D: &str = include_str!("./icons/kflower-plain.svg");
const ICON_E: &str = include_str!("./icons/kflower-white.svg");
call_init!(init);
fn init(our: Address) {
let mut app_data: BTreeMap<String, HomepageApp> = BTreeMap::new();
@ -74,6 +82,9 @@ fn init(our: Address) {
.expect("failed to bind to /our.js");
bind_http_path("/apps", true, false).expect("failed to bind /apps");
bind_http_path("/version", true, false).expect("failed to bind /version");
bind_http_path("/order", true, false).expect("failed to bind /order");
bind_http_path("/icons/:id", true, false).expect("failed to bind /icons/:id");
loop {
let Ok(ref message) = await_message() else {
@ -93,12 +104,12 @@ fn init(our: Address) {
// they must have messaging access to us in order to perform this.
if let Ok(request) = serde_json::from_slice::<HomepageRequest>(message.body()) {
match request {
HomepageRequest::Add {
HomepageRequest::Add(AddRequest {
label,
icon,
path,
widget,
} => {
}) => {
app_data.insert(
message.source().process.to_string(),
HomepageApp {
@ -115,6 +126,7 @@ fn init(our: Address) {
label,
base64_icon: icon,
widget,
order: None,
},
);
}
@ -134,22 +146,87 @@ fn init(our: Address) {
"Content-Type".to_string(),
"application/json".to_string(),
)])),
format!(
"[{}]",
app_data
.values()
.map(|app| serde_json::to_string(app).unwrap())
.collect::<Vec<String>>()
.join(",")
)
.as_bytes()
.to_vec(),
{
let mut apps: Vec<_> = app_data.values().collect();
apps.sort_by_key(|app| app.order.unwrap_or(255));
serde_json::to_vec(&apps).unwrap_or_else(|_| Vec::new())
},
);
}
"/version" => {
send_response(
StatusCode::OK,
Some(HashMap::new()),
version_from_cargo_toml().as_bytes().to_vec(),
);
}
"/order" => {
// POST of a list of package names.
// go through the list and update each app in app_data to have the index of its name in the list as its order
if let Some(body) = get_blob() {
let apps: Vec<String> =
serde_json::from_slice(&body.bytes).unwrap();
for (i, app) in apps.iter().enumerate() {
if let Some(app) = app_data.get_mut(app) {
app.order = Some(i as u16);
}
}
send_response(
StatusCode::OK,
Some(HashMap::from([(
"Content-Type".to_string(),
"application/json".to_string(),
)])),
vec![],
);
} else {
send_response(
StatusCode::BAD_REQUEST,
Some(HashMap::new()),
vec![],
);
}
}
"/icons/:id" => {
let id = incoming
.url_params()
.get("id")
.unwrap_or(&"0".to_string())
.clone();
let icon = match id.to_uppercase().as_str() {
"0" => ICON_0,
"1" => ICON_1,
"2" => ICON_2,
"3" => ICON_3,
"4" => ICON_4,
"5" => ICON_5,
"6" => ICON_6,
"7" => ICON_7,
"8" => ICON_8,
"9" => ICON_9,
"A" => ICON_A,
"B" => ICON_B,
"C" => ICON_C,
"D" => ICON_D,
"E" => ICON_E,
_ => ICON_0,
};
send_response(
StatusCode::OK,
Some(HashMap::from([(
"Content-Type".to_string(),
"image/svg+xml".to_string(),
)])),
icon.as_bytes().to_vec(),
);
}
_ => {
send_response(
StatusCode::OK,
Some(HashMap::new()),
Some(HashMap::from([(
"Content-Type".to_string(),
"text/plain".to_string(),
)])),
"yes hello".as_bytes().to_vec(),
);
}
@ -161,3 +238,18 @@ fn init(our: Address) {
}
}
}
fn version_from_cargo_toml() -> String {
let version = CARGO_TOML
.lines()
.find(|line| line.starts_with("version = "))
.expect("Failed to find version in Cargo.toml");
version
.split('=')
.last()
.expect("Failed to parse version from Cargo.toml")
.trim()
.trim_matches('"')
.to_string()
}

View File

@ -9,7 +9,9 @@
"mirrors": [],
"code_hashes": {
"0.1.1": ""
}
},
"wit_version": 0,
"dependencies": []
},
"external_url": "https://kinode.org",
"animation_url": ""

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -9,8 +9,8 @@
<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="/assets/index-BrbxaEm2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-iMQiSiXv.css">
<script type="module" crossorigin src="/assets/index-BVEbM5H5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BS5LP50I.css">
</head>
<body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -9,8 +9,8 @@
<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="/assets/index-BrbxaEm2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-iMQiSiXv.css">
<script type="module" crossorigin src="/assets/index-BVEbM5H5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BS5LP50I.css">
</head>
<body>

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@
"dependencies": {
"classnames": "^2.5.1",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "^18.2.0",
"react-icons": "^5.1.0",
"react-router-dom": "^6.23.0",
@ -21,6 +22,7 @@
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
@ -31,4 +33,4 @@
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,10 @@
<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>

After

Width:  |  Height:  |  Size: 431 B

View File

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 188 B

View File

@ -1,6 +1,6 @@
import classNames from "classnames"
import useHomepageStore from "../store/homepageStore"
import { isMobileCheck } from "../utilities/dimensions"
import { isMobileCheck } from "../utils/dimensions"
import AppDisplay from "./AppDisplay"
const AllApps: React.FC<{ expanded: boolean }> = ({ expanded }) => {
@ -15,7 +15,7 @@ const AllApps: React.FC<{ expanded: boolean }> = ({ expanded }) => {
})}>
{apps.length === 0
? <div>Loading apps...</div>
: apps.map(app => <AppDisplay app={app} />)}
: apps.map(app => <AppDisplay key={app.package_name} app={app} />)}
</div>
}

View File

@ -1,31 +1,31 @@
import classNames from "classnames"
import ColorDot from "./ColorDot"
import { HomepageApp } from "../store/homepageStore"
import { FaHeart, FaRegHeart } from "react-icons/fa6"
import { FaHeart, FaRegHeart, } from "react-icons/fa6"
import { useState } from "react"
import usePersistentStore from "../store/persistentStore"
import { isMobileCheck } from "../utilities/dimensions"
import { isMobileCheck } from "../utils/dimensions"
import AppIconPlaceholder from "./AppIconPlaceholder"
interface AppDisplayProps {
app: HomepageApp
app?: HomepageApp
}
const AppDisplay: React.FC<AppDisplayProps> = ({ app }) => {
const { favoriteApp } = usePersistentStore();
const { favoriteApp, favoriteApps } = usePersistentStore();
const [isHovered, setIsHovered] = useState(false)
const isMobile = isMobileCheck()
return <a
className={classNames("flex-col-center gap-2 relative hover:opacity-90 transition-opacity", {
'cursor-pointer': app.path,
'pointer-events-none': !app.path,
'cursor-pointer': app?.path,
'cursor-not-allowed': !app?.path,
})}
id={app.package_name}
href={app.path}
id={app?.package_name}
href={app?.path}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{app.base64_icon
{app?.base64_icon
? <img
src={app.base64_icon}
className={classNames('rounded', {
@ -33,16 +33,20 @@ const AppDisplay: React.FC<AppDisplayProps> = ({ app }) => {
'h-16 w-16': !isMobile
})}
/>
: <ColorDot num={app.state?.our_version || '0'} />}
<h6>{app.label}</h6>
{app.path && isHovered && <button
: <AppIconPlaceholder
text={app?.state?.our_version || '0'}
size={'small'}
className="h-16 w-16"
/>}
<h6>{app?.label}</h6>
{app?.path && isHovered && <button
className="absolute p-2 -top-2 -right-2 clear text-sm"
onClick={(e) => {
e.preventDefault()
favoriteApp(app.package_name)
}}
>
{app.is_favorite ? <FaHeart /> : <FaRegHeart />}
{favoriteApps[app.package_name]?.favorite ? <FaHeart /> : <FaRegHeart />}
</button>}
</a>
}

View File

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

View File

@ -2,28 +2,119 @@ import useHomepageStore, { HomepageApp } from "../store/homepageStore"
import AppDisplay from "./AppDisplay"
import usePersistentStore from "../store/persistentStore"
import { useEffect, useState } from "react"
import { isMobileCheck } from "../utilities/dimensions"
import { isMobileCheck } from "../utils/dimensions"
import classNames from "classnames"
import { DragDropContext, Draggable, DropResult, Droppable } from 'react-beautiful-dnd'
import { getFetchUrl } from "../utils/fetch"
const AppsDock: React.FC = () => {
const { apps } = useHomepageStore()
const { favoriteApps } = usePersistentStore()
const { favoriteApps, setFavoriteApps } = usePersistentStore()
const [dockedApps, setDockedApps] = useState<HomepageApp[]>([])
useEffect(() => {
setDockedApps(apps.filter(a => favoriteApps[a.package_name]))
let final: HomepageApp[] = []
const dockedApps = Object.entries(favoriteApps)
.filter(([_, { favorite }]) => favorite)
.map(([name, { order }]) => ({ ...apps.find(a => a.package_name === name), order }))
.filter(a => a) as HomepageApp[]
const orderedApps = dockedApps.filter(a => a.order !== undefined && a.order !== null)
const unorderedApps = dockedApps.filter(a => a.order === undefined || a.order === null)
for (let i = 0; i < orderedApps.length; i++) {
final[orderedApps[i].order!] = orderedApps[i]
}
final = final.filter(a => a)
unorderedApps.forEach(a => final.push(a))
// console.log({ final })
setDockedApps(final)
}, [apps, favoriteApps])
const isMobile = isMobileCheck()
return <div className={classNames('flex-center flex-wrap border border-orange bg-orange/25 p-2 rounded !rounded-xl', {
'gap-8 mb-4': !isMobile,
'gap-4 mb-2': isMobile
})}>
{dockedApps.length === 0
? <div>Favorite an app to pin it to your dock.</div>
: dockedApps.map(app => <AppDisplay app={app} />)}
</div>
// a little function to help us with reordering the result
const reorder = (list: HomepageApp[], startIndex: number, endIndex: number) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
const onDragEnd = (result: DropResult) => {
// dropped outside the list
if (!result.destination) {
return;
}
const items = reorder(
dockedApps,
result.source.index,
result.destination.index
);
const packageNames = items.map(app => app.package_name);
const faves = { ...favoriteApps }
packageNames.forEach((name, i) => {
// console.log('setting order for', name, 'to', i)
faves[name].order = i
})
setFavoriteApps(faves)
console.log({ favoriteApps })
fetch(getFetchUrl('/order'), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(packageNames)
})
.catch(e => console.error(e));
}
return <DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable" direction="horizontal">
{(provided, _snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={classNames('flex-center flex-wrap border border-orange bg-orange/25 p-2 rounded !rounded-xl', {
'gap-8': !isMobile && dockedApps.length > 0,
'gap-4': !isMobile && dockedApps.length === 0,
'mb-4': !isMobile,
'gap-4 mb-2': isMobile,
'flex-col': dockedApps.length === 0
})}
>
{/*dockedApps.length === 0
? <AppDisplay app={apps.find(app => app.package_name === 'app_store')!} />
: */ dockedApps.map(app => <Draggable
key={app.package_name}
draggableId={app.package_name}
index={dockedApps.indexOf(app)}
>
{(provided, _snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<AppDisplay app={app} />
</div>
)}
</Draggable>)}
{provided.placeholder}
{dockedApps.length === 0 && <div>Favorite an app to pin it to your dock.</div>}
</div>
)}
</Droppable>
</DragDropContext>
}
export default AppsDock

View File

@ -1,7 +1,7 @@
import classNames from 'classnames'
import React from 'react'
import { hexToRgb, hslToRgb, rgbToHex, rgbToHsl } from '../utils/colors'
import { isMobileCheck } from '../utilities/dimensions'
import { isMobileCheck } from '../utils/dimensions'
interface ColorDotProps extends React.HTMLAttributes<HTMLSpanElement> {
num: string,

View File

@ -1,5 +1,5 @@
import { FaX } from "react-icons/fa6"
import { isMobileCheck } from "../utilities/dimensions"
import { isMobileCheck } from "../utils/dimensions"
import classNames from "classnames"
interface Props extends React.HTMLAttributes<HTMLDivElement> {

View File

@ -3,7 +3,7 @@ import { FaEye, FaEyeSlash } from "react-icons/fa6"
import { useState } from "react"
import usePersistentStore from "../store/persistentStore"
import useHomepageStore from "../store/homepageStore"
import { isMobileCheck } from "../utilities/dimensions"
import { isMobileCheck } from "../utils/dimensions"
interface WidgetProps {
package_name: string,

View File

@ -1,7 +1,7 @@
import useHomepageStore from "../store/homepageStore"
import Widget from "./Widget"
import usePersistentStore from "../store/persistentStore"
import { isMobileCheck } from "../utilities/dimensions"
import { isMobileCheck } from "../utils/dimensions"
import classNames from "classnames"
const Widgets = () => {
@ -19,6 +19,7 @@ const Widgets = () => {
package_name={package_name}
widget={widget!}
forceLarge={_appsWithWidgets.length === 1}
key={package_name}
/>)}
</div>
}

View File

@ -1,4 +1,3 @@
import { FaCheck, FaX } from "react-icons/fa6"
import useHomepageStore from "../store/homepageStore"
import { Modal } from "./Modal"
import classNames from "classnames"
@ -14,38 +13,56 @@ const WidgetsSettingsModal = () => {
>
<div className="flex-col-center gap-4 mt-4">
{apps.filter(app => app.widget).map(({ label, package_name }) => <div className="flex items-start bg-white/10 rounded p-2 self-stretch">
<h4 className="mr-4">{label}</h4>
<div className="flex-col-center gap-4 grow">
<h4 className="mr-4 grow">{label}</h4>
<div className="flex flex-col gap-4 grow">
<div className="flex-center gap-2">
<span>Show widget</span>
<button
className="icon"
onClick={() => toggleWidgetVisibility(package_name)}
>
{!widgetSettings[package_name]?.hide ? <FaCheck /> : <FaX />}
</button>
<div className="flex relative grow">
<input
type="checkbox"
checked={!widgetSettings[package_name]?.hide}
onChange={() => toggleWidgetVisibility(package_name)}
autoFocus
/>
{!widgetSettings[package_name]?.hide && (
<span
onClick={() => toggleWidgetVisibility(package_name)}
className="checkmark"
>
&#10003;
</span>
)}
</div>
</div>
<div className="flex-center gap-2">
<span>Widget size</span>
<button
className={classNames({
'clear': widgetSettings[package_name]?.size === 'large'
})}
onClick={() => setWidgetSize(package_name, 'small')}
>
Small
</button>
<button
className={classNames({
'clear': widgetSettings[package_name]?.size !== 'large'
})}
onClick={() => setWidgetSize(package_name, 'large')}
>
Large
</button>
<div className="flex-center grow">
<button
className={classNames({
'clear': widgetSettings[package_name]?.size === 'large'
})}
onClick={() => setWidgetSize(package_name, 'small')}
>
Small
</button>
<button
className={classNames({
'clear': widgetSettings[package_name]?.size !== 'large'
})}
onClick={() => setWidgetSize(package_name, 'large')}
>
Large
</button>
</div>
</div>
</div>
</div>)}
<button
className="clear"
onClick={() => window.location.href = '/settings:settings:sys'}
>
Looking for system settings?
</button>
</div>
</Modal>
}

View File

@ -2,14 +2,17 @@ import { useEffect, useState } from 'react'
import KinodeText from '../components/KinodeText'
import KinodeBird from '../components/KinodeBird'
import useHomepageStore, { HomepageApp } from '../store/homepageStore'
import { FaChevronDown, FaChevronUp, FaScrewdriverWrench, FaV } from 'react-icons/fa6'
import { FaChevronDown, FaChevronUp, FaScrewdriverWrench } from 'react-icons/fa6'
import AppsDock from '../components/AppsDock'
import AllApps from '../components/AllApps'
import Widgets from '../components/Widgets'
import { isMobileCheck } from '../utilities/dimensions'
import { isMobileCheck } from '../utils/dimensions'
import classNames from 'classnames'
import WidgetsSettingsModal from '../components/WidgetsSettingsModal'
import valetIcon from '../../public/valet-icon.png'
import { getFetchUrl } from '../utils/fetch'
interface AppStoreApp {
package: string,
publisher: string,
@ -19,16 +22,21 @@ interface AppStoreApp {
}
function Homepage() {
const [our, setOur] = useState('')
const [version, setVersion] = useState('')
const [allAppsExpanded, setAllAppsExpanded] = useState(false)
const { setApps, isHosted, fetchHostedStatus, showWidgetsSettings, setShowWidgetsSettings } = useHomepageStore()
const isMobile = isMobileCheck()
const getAppPathsAndIcons = () => {
Promise.all([
fetch('/apps').then(res => res.json() as any as HomepageApp[]),
fetch('/main:app_store:sys/apps').then(res => res.json())
]).then(([appsData, appStoreData]) => {
console.log({ appsData, appStoreData })
fetch(getFetchUrl('/apps'), { credentials: 'include' }).then(res => res.json() as any as HomepageApp[]).catch(() => []),
fetch(getFetchUrl('/main:app_store:sys/apps'), { credentials: 'include' }).then(res => res.json()).catch(() => []),
fetch(getFetchUrl('/version'), { credentials: 'include' }).then(res => res.text()).catch(() => '')
]).then(([appsData, appStoreData, version]) => {
// console.log({ appsData, appStoreData, version })
setVersion(version)
const appz = appsData.map(app => ({
...app,
is_favorite: false, // Assuming initial state for all apps
@ -70,7 +78,7 @@ function Homepage() {
}, [our]);
useEffect(() => {
fetch('/our')
fetch(getFetchUrl('/our'), { credentials: 'include' })
.then(res => res.text())
.then(data => {
if (data.match(/^[a-zA-Z0-9\-\.]+\.[a-zA-Z]+$/)) {
@ -87,13 +95,13 @@ function Homepage() {
'top-8 left-8 right-8': !isMobile,
'top-2 left-2 right-2': isMobile
})}>
{isHosted && <a
href={`https://${our.replace('.os', '')}.hosting.kinode.net/`}
className='button icon'
>
<FaV />
</a>}
{our}
{isHosted && <img
src={valetIcon}
className='!w-12 !h-12 !p-1 button icon object-cover'
onClick={() => window.location.href = `https://${our.replace('.os', '')}.hosting.kinode.net/`}
/>}
<span>{our}</span>
<span className='bg-white/10 rounded p-1'>v{version}</span>
<button
className="icon ml-auto"
onClick={() => setShowWidgetsSettings(true)}

View File

@ -6,11 +6,11 @@ export interface HomepageApp {
path: string
label: string,
base64_icon?: string,
is_favorite: boolean,
state?: {
our_version: string
}
widget?: string
order?: number
}
export interface HomepageStore {

View File

@ -14,8 +14,12 @@ export interface PersistentStore {
toggleWidgetVisibility: (package_name: string) => void
setWidgetSize: (package_name: string, size: 'small' | 'large') => void,
favoriteApps: {
[key: string]: boolean
[key: string]: {
favorite: boolean
order?: number
}
}
setFavoriteApps: (favoriteApps: PersistentStore['favoriteApps']) => void
favoriteApp: (package_name: string) => void
}
@ -27,6 +31,7 @@ const usePersistentStore = create<PersistentStore>()(
widgetSettings: {},
favoriteApps: {},
setWidgetSettings: (widgetSettings: PersistentStore['widgetSettings']) => set({ widgetSettings }),
setFavoriteApps: (favoriteApps: PersistentStore['favoriteApps']) => set({ favoriteApps }),
toggleWidgetVisibility: (package_name: string) => {
const { widgetSettings } = get()
set({
@ -56,7 +61,10 @@ const usePersistentStore = create<PersistentStore>()(
set({
favoriteApps: {
...favoriteApps,
[package_name]: !favoriteApps[package_name]
[package_name]: {
...favoriteApps[package_name],
favorite: !favoriteApps[package_name]?.favorite
}
}
})
},

View File

@ -0,0 +1,12 @@
/**
* Prepends or strips '/api/' based on the environment.
* @param {string} path The original path.
* @return {string} The modified path.
*/
export function getFetchUrl(path: string) {
const isDevelopment = import.meta.env.DEV;
if (isDevelopment) {
return `/api${path}`;
}
return path.replace(/^\/api/, '');
}

View File

@ -1 +1,13 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string;
readonly REACT_APP_MAINNET_RPC_URL: string;
readonly REACT_APP_SEPOLIA_RPC_URL: string;
readonly VITE_NODE_URL: string;
// Add other environment variables as needed
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -8,4 +8,13 @@ export default defineConfig({
react()
],
// ...
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,9 @@
"mirrors": [],
"code_hashes": {
"0.1.0": ""
}
},
"wit_version": 0,
"dependencies": []
},
"external_url": "https://kinode.org",
"animation_url": ""

View File

@ -9,7 +9,7 @@ simulation-mode = []
[dependencies]
anyhow = "1.0"
bincode = "1.3.3"
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.7.2" }
kinode_process_lib = { git = "https://github.com/kinode-dao/process_lib", tag = "v0.8.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
url = "2.5.0"

View File

@ -2,14 +2,10 @@ use kinode_process_lib::{call_init, http, timer, Address, Request};
use serde::{Deserialize, Serialize};
wit_bindgen::generate!({
path: "wit",
world: "process",
path: "target/wit",
world: "process-v0",
});
/// Fetching OS version from main package.. LMK if there's a better way...
const CARGO_TOML: &str = include_str!("../../../../Cargo.toml");
/// A static message to display on the homepage.
const MOTD: &str = "Welcome to Kinode!";
/// 20 minutes
const REFRESH_INTERVAL: u64 = 20 * 60 * 1000;
@ -37,7 +33,7 @@ fn init(_our: Address) {
serde_json::json!({
"Add": {
"label": "KinoUpdates",
"widget": create_widget(fetch_three_most_recent_blog_posts()),
"widget": create_widget(fetch_most_recent_blog_posts(6)),
}
})
.to_string(),
@ -79,12 +75,10 @@ fn create_widget(posts: Vec<KinodeBlogPost>) -> String {
}}
</style>
</head>
<body class="text-white overflow-hidden">
<p>Kinode {}: {}</p>
<p>Recent posts from kinode.org:</p>
<body class="text-white overflow-hidden h-screen w-screen flex flex-col gap-2">
<div
id="latest-blog-posts"
class="flex flex-col p-2 gap-2 backdrop-brightness-125 rounded-xl shadow-lg h-screen w-screen overflow-y-auto"
class="flex flex-col p-2 gap-2 backdrop-brightness-125 rounded-xl shadow-lg h-screen w-screen overflow-y-auto self-stretch"
style="
scrollbar-color: transparent transparent;
scrollbar-width: none;
@ -94,8 +88,6 @@ fn create_widget(posts: Vec<KinodeBlogPost>) -> String {
</div>
</body>
</html>"#,
version_from_cargo_toml(),
MOTD,
posts
.into_iter()
.map(post_to_html_string)
@ -103,22 +95,7 @@ fn create_widget(posts: Vec<KinodeBlogPost>) -> String {
);
}
fn version_from_cargo_toml() -> String {
let version = CARGO_TOML
.lines()
.find(|line| line.starts_with("version = "))
.expect("Failed to find version in Cargo.toml");
version
.split('=')
.last()
.expect("Failed to parse version from Cargo.toml")
.trim()
.trim_matches('"')
.to_string()
}
fn fetch_three_most_recent_blog_posts() -> Vec<KinodeBlogPost> {
fn fetch_most_recent_blog_posts(n: usize) -> Vec<KinodeBlogPost> {
let blog_posts = match http::send_request_await_response(
http::Method::GET,
url::Url::parse("https://kinode.org/api/blog/posts").unwrap(),
@ -131,13 +108,14 @@ fn fetch_three_most_recent_blog_posts() -> Vec<KinodeBlogPost> {
Err(e) => panic!("Failed to fetch blog posts: {:?}", e),
};
blog_posts.into_iter().rev().take(3).collect()
blog_posts.into_iter().rev().take(n as usize).collect()
}
/// Take first 100 chars of a blog post and append "..." to the end
fn trim_content(content: &str) -> String {
if content.len() > 100 {
format!("{}...", &content[..100])
let len = 75;
if content.len() > len {
format!("{}...", &content[..len])
} else {
content.to_string()
}
@ -145,20 +123,24 @@ fn trim_content(content: &str) -> String {
fn post_to_html_string(post: KinodeBlogPost) -> String {
format!(
r#"<div class="post p-2 grow self-stretch flex items-stretch rounded-lg shadow bg-white/10 font-sans w-full">
r#"<a
class="post p-2 grow self-stretch flex items-stretch rounded-lg shadow bg-white/10 hover:bg-white/20 font-sans w-full"
href="https://kinode.org/blog/post/{}"
target="_blank"
rel="noopener noreferrer"
>
<div
class="post-image rounded mr-2 grow"
class="post-image rounded mr-2 grow self-stretch h-full"
style="background-image: url('https://kinode.org{}');"
></div>
<div class="post-info flex flex-col grow">
<h2 class="font-bold">{}</h2>
<p>{}</p>
<a href="https://kinode.org/blog/post/{}" class="text-blue-500" target="_blank" rel="noopener noreferrer">Read more</a>
</div>
</div>"#,
</a>"#,
post.slug,
post.thumbnail_image,
post.title,
trim_content(&post.content),
post.slug,
)
}

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