diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml new file mode 100644 index 0000000..4b40900 --- /dev/null +++ b/.github/workflows/build-binaries.yml @@ -0,0 +1,291 @@ +name: Build Binaries + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build-linux-appimage: + name: Build Linux AppImage (x64) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Build release binary + run: cargo build --release --target x86_64-unknown-linux-gnu + + - name: Install AppImage tools + run: | + wget -O /tmp/appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x /tmp/appimagetool + + - name: Create AppImage structure + run: | + mkdir -p AppDir/usr/bin + mkdir -p AppDir/usr/share/applications + mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + + # Copy binary + cp target/x86_64-unknown-linux-gnu/release/pubky-wiki AppDir/usr/bin/ + + # Create desktop file + cat > AppDir/usr/share/applications/pubky-wiki.desktop << 'EOF' + [Desktop Entry] + Name=Pubky Wiki + Exec=pubky-wiki + Icon=pubky-wiki + Type=Application + Categories=Utility; + EOF + + # Create a simple icon (placeholder - would be replaced with actual icon) + touch AppDir/usr/share/icons/hicolor/256x256/apps/pubky-wiki.png + + # Create AppRun + cat > AppDir/AppRun << 'EOF' + #!/bin/bash + SELF=$(readlink -f "$0") + HERE=${SELF%/*} + export PATH="${HERE}/usr/bin/:${PATH}" + exec "${HERE}/usr/bin/pubky-wiki" "$@" + EOF + chmod +x AppDir/AppRun + + # Symlink desktop file and icon to root + ln -s usr/share/applications/pubky-wiki.desktop AppDir/ + ln -s usr/share/icons/hicolor/256x256/apps/pubky-wiki.png AppDir/ + + - name: Build AppImage + run: | + ARCH=x86_64 /tmp/appimagetool --appimage-extract-and-run AppDir pubky-wiki-x86_64.AppImage + + - name: Upload AppImage artifact + uses: actions/upload-artifact@v4 + with: + name: pubky-wiki-linux-x86_64-appimage + path: pubky-wiki-x86_64.AppImage + + build-linux-arm-appimage: + name: Build Linux AppImage (ARM64) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-unknown-linux-gnu + + - name: Install cross-compilation tools + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + + - name: Setup ARM64 repository sources + run: | + # Add ARM64 architecture + sudo dpkg --add-architecture arm64 + + # Configure sources to use ports.ubuntu.com for ARM64 packages + sudo sed -i 's/deb http/deb [arch=amd64] http/g' /etc/apt/sources.list + echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble main universe multiverse restricted" | sudo tee -a /etc/apt/sources.list + echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-updates main universe multiverse restricted" | sudo tee -a /etc/apt/sources.list + echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-security main universe multiverse restricted" | sudo tee -a /etc/apt/sources.list + + - name: Install dependencies + run: | + sudo sed -i '/security.ubuntu.com.*arm64/d' /etc/apt/sources.list + sudo apt-get update + sudo apt-get install -y libgtk-3-dev:arm64 libxcb-render0-dev:arm64 libxcb-shape0-dev:arm64 libxcb-xfixes0-dev:arm64 libxkbcommon-dev:arm64 libssl-dev:arm64 + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-arm64-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-arm64-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-arm64-${{ hashFiles('**/Cargo.lock') }} + + - name: Configure cross-compilation + run: | + mkdir -p .cargo + cat >> .cargo/config.toml << 'EOF' + [target.aarch64-unknown-linux-gnu] + linker = "aarch64-linux-gnu-gcc" + EOF + + - name: Build release binary + run: cargo build --release --target aarch64-unknown-linux-gnu + + - name: Install AppImage tools + run: | + wget -O /tmp/appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage + chmod +x /tmp/appimagetool + + - name: Create AppImage structure + run: | + mkdir -p AppDir/usr/bin + mkdir -p AppDir/usr/share/applications + mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + + # Copy binary + cp target/aarch64-unknown-linux-gnu/release/pubky-wiki AppDir/usr/bin/ + + # Create desktop file + cat > AppDir/usr/share/applications/pubky-wiki.desktop << 'EOF' + [Desktop Entry] + Name=Pubky Wiki + Exec=pubky-wiki + Icon=pubky-wiki + Type=Application + Categories=Utility; + EOF + + # Create a simple icon (placeholder - would be replaced with actual icon) + touch AppDir/usr/share/icons/hicolor/256x256/apps/pubky-wiki.png + + # Create AppRun + cat > AppDir/AppRun << 'EOF' + #!/bin/bash + SELF=$(readlink -f "$0") + HERE=${SELF%/*} + export PATH="${HERE}/usr/bin/:${PATH}" + exec "${HERE}/usr/bin/pubky-wiki" "$@" + EOF + chmod +x AppDir/AppRun + + # Symlink desktop file and icon to root + ln -s usr/share/applications/pubky-wiki.desktop AppDir/ + ln -s usr/share/icons/hicolor/256x256/apps/pubky-wiki.png AppDir/ + + - name: Build AppImage + run: | + ARCH=aarch64 /tmp/appimagetool --appimage-extract-and-run AppDir pubky-wiki-aarch64.AppImage + + - name: Upload AppImage artifact + uses: actions/upload-artifact@v4 + with: + name: pubky-wiki-linux-aarch64-appimage + path: pubky-wiki-aarch64.AppImage + + build-macos-arm: + name: Build macOS Binary (arm64) + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Build release binary + run: cargo build --release --target aarch64-apple-darwin + + - name: Upload macOS binary artifact + uses: actions/upload-artifact@v4 + with: + name: pubky-wiki-macos-arm64 + path: target/aarch64-apple-darwin/release/pubky-wiki + + build-windows-x64: + name: Build Windows Binary (x64) + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Build release binary + run: cargo build --release --target x86_64-pc-windows-msvc + + - name: Upload Windows binary artifact + uses: actions/upload-artifact@v4 + with: + name: pubky-wiki-windows-x64 + path: target/x86_64-pc-windows-msvc/release/pubky-wiki.exe diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8afbd64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea + +# Generated by Cargo +debug +target +.cargo + +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3fc8b8e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "pubky-wiki" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1" +eframe = "0.33" +egui = "0.33" +egui_commonmark = "0.22" +image = "0.25" +log = "0.4" +qrcode = "0.14" +pubky = "0.6.0-rc.6" +tokio = { version = "1", features = ["full"] } +tracing-subscriber = "0.3" +uuid = { version = "1", features = ["v4"] } diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..f77d6af Binary files /dev/null and b/assets/logo.png differ diff --git a/doc/TESTING.md b/doc/TESTING.md new file mode 100644 index 0000000..6e41525 --- /dev/null +++ b/doc/TESTING.md @@ -0,0 +1,16 @@ +# Test Suite + +## Create a Staging user + +Get a invite code on Staging: + +``` +curl -X GET \ +"https://admin.homeserver.staging.pubky.app/generate_signup_token" \ + -H "X-Admin-Password: voyage tuition cabin arm stock guitar soon salute" +``` + +On Pubky Ring, register an account + +- with this invite code +- with the Staging Homeserver PK: ufibwbmed6jeq9k4p583go95wofakh9fwpp4k734trq79pd9u1uy diff --git a/src/create_wiki.rs b/src/create_wiki.rs new file mode 100644 index 0000000..6d4a008 --- /dev/null +++ b/src/create_wiki.rs @@ -0,0 +1,78 @@ +use crate::{create_wiki_post, utils::extract_title, AuthState, PubkyApp, ViewState}; + +use eframe::egui::{Context, Ui}; +use pubky::PubkySession; + +pub(crate) fn update(app: &mut PubkyApp, session: &PubkySession, _ctx: &Context, ui: &mut Ui) { + ui.label(egui::RichText::new("Create New Wiki Page").size(20.0).strong()); + ui.add_space(25.0); + + // Textarea for wiki content + ui.label(egui::RichText::new("Content:").size(16.0)); + ui.add_space(12.0); + + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut app.edit_wiki_content) + .desired_width(f32::INFINITY) + .desired_rows(15) + .font(egui::TextStyle::Monospace), + ); + }); + + ui.add_space(25.0); + + ui.horizontal(|ui| { + // Save button for creating new page + let save_button = ui.add_sized( + [120.0, 35.0], + egui::Button::new(egui::RichText::new("💾 Save").size(15.0)) + ); + if save_button.clicked() { + let session_clone = session.clone(); + let content = app.edit_wiki_content.clone(); + let state_clone = app.state.clone(); + let filename = app.forked_from_page_id.as_deref(); + + let create_wiki_post_fut = create_wiki_post(&session_clone, &content, filename); + match app.rt.block_on(create_wiki_post_fut) { + Ok(wiki_page_path) => { + log::info!("Created wiki post at: {}", wiki_page_path); + + // Convert path to pubky URL format for the file_cache list + if let Ok(mut state) = state_clone.lock() { + if let AuthState::Authenticated { + ref session, + ref mut file_cache, + .. + } = *state + { + let own_user_pk = session.info().public_key().to_string(); + let file_url = format!("pubky://{own_user_pk}{wiki_page_path}"); + let file_title = extract_title(&content); + file_cache.insert(file_url, file_title.into()); + } + } + } + Err(e) => log::error!("Failed to create wiki post: {e}"), + } + + app.edit_wiki_content.clear(); + app.forked_from_page_id = None; + app.view_state = ViewState::WikiList; + } + + ui.add_space(10.0); + let cancel_button = ui.add_sized( + [120.0, 35.0], + egui::Button::new(egui::RichText::new("Cancel").size(15.0)) + ); + if cancel_button.clicked() { + app.edit_wiki_content.clear(); + app.forked_from_page_id = None; + app.view_state = ViewState::WikiList; + } + }); +} diff --git a/src/edit_wiki.rs b/src/edit_wiki.rs new file mode 100644 index 0000000..4837797 --- /dev/null +++ b/src/edit_wiki.rs @@ -0,0 +1,103 @@ +use crate::{delete_wiki_post, update_wiki_post, AuthState, PubkyApp, ViewState}; + +use eframe::egui::{Context, Ui}; +use pubky::PubkySession; + +pub(crate) fn update(app: &mut PubkyApp, session: &PubkySession, _ctx: &Context, ui: &mut Ui) { + ui.label(egui::RichText::new("Edit Wiki Page").size(20.0).strong()); + ui.add_space(25.0); + + // Textarea for wiki content + ui.label(egui::RichText::new("Content:").size(16.0)); + ui.add_space(12.0); + + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut app.edit_wiki_content) + .desired_width(f32::INFINITY) + .desired_rows(15) + .font(egui::TextStyle::Monospace), + ); + }); + + ui.add_space(25.0); + + ui.horizontal(|ui| { + let update_button = ui.add_sized( + [120.0, 35.0], + egui::Button::new(egui::RichText::new("✓ Update").size(15.0)) + ); + if update_button.clicked() { + let session_clone = session.clone(); + let content = app.edit_wiki_content.clone(); + let page_id = app.selected_wiki_page_id.clone(); + + let update_wiki_post_fut = update_wiki_post(&session_clone, &page_id, &content); + match app.rt.block_on(update_wiki_post_fut) { + Ok(_) => { + log::info!("Updated wiki post: {}", page_id); + // Update the selected content to reflect changes + app.selected_wiki_content = content; + } + Err(e) => log::error!("Failed to update wiki post: {e}"), + } + + app.edit_wiki_content.clear(); + app.view_state = ViewState::WikiList; + app.needs_refresh = true; + } + + ui.add_space(10.0); + // Delete button for editing existing page + let delete_button = ui.add_sized( + [120.0, 35.0], + egui::Button::new(egui::RichText::new("🗑 Delete").size(15.0).color(egui::Color32::from_rgb(200, 80, 80))) + ); + if delete_button.clicked() { + let session_clone = session.clone(); + let page_id = app.selected_wiki_page_id.clone(); + let state_clone = app.state.clone(); + + let delete_wiki_post_fut = delete_wiki_post(&session_clone, &page_id); + match app.rt.block_on(delete_wiki_post_fut) { + Ok(_) => { + log::info!("Deleted wiki post: {}", page_id); + + // Remove from file_urls list + if let Ok(mut state) = state_clone.lock() { + if let AuthState::Authenticated { + ref session, + ref mut file_cache, + .. + } = *state + { + let own_user_pk = session.info().public_key().to_string(); + let file_url = format!("pubky://{own_user_pk}/pub/wiki.app/{page_id}"); + file_cache.remove(&file_url); + } + } + } + Err(e) => log::error!("Failed to delete wiki post: {e}"), + } + + app.edit_wiki_content.clear(); + app.selected_wiki_page_id.clear(); + app.selected_wiki_content.clear(); + app.selected_wiki_fork_urls.clear(); + app.view_state = ViewState::WikiList; + app.needs_refresh = true; + } + + ui.add_space(10.0); + let cancel_button = ui.add_sized( + [120.0, 35.0], + egui::Button::new(egui::RichText::new("Cancel").size(15.0)) + ); + if cancel_button.clicked() { + app.edit_wiki_content.clear(); + app.view_state = ViewState::WikiList; + } + }); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e55ad17 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,472 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use anyhow::{anyhow, Result}; +use eframe::egui; +use egui_commonmark::*; +use pubky::{Capabilities, Pubky, PubkyAuthFlow, PubkySession, PublicStorage}; +use tokio::runtime::Runtime; +use uuid::Uuid; + +use crate::utils::{extract_title, generate_qr_image, get_list}; + +mod create_wiki; +mod edit_wiki; +mod utils; +mod view_wiki; + +const APP_NAME: &str = "Pubky Wiki"; + +fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let rt = Runtime::new()?; + let app = PubkyApp::new(rt); + + // Load icon + let icon = load_icon()?; + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([600.0, 700.0]) + .with_title(APP_NAME) + .with_icon(icon), + ..Default::default() + }; + + eframe::run_native(APP_NAME, options, Box::new(|_cc| Ok(Box::new(app)))) + .map_err(|e| anyhow!("{e}")) +} + +fn load_icon() -> Result { + // Embed the icon at compile time to avoid runtime file I/O + let icon_bytes = include_bytes!("../assets/logo.png"); + let image = image::load_from_memory(icon_bytes) + .map_err(|e| anyhow!("Failed to load icon: {e}"))? + .into_rgba8(); + + let (width, height) = image.dimensions(); + let rgba = image.into_raw(); + + Ok(egui::IconData { + rgba, + width: width as u32, + height: height as u32, + }) +} + +fn load_logo_image() -> Option { + // Embed the logo at compile time to avoid runtime file I/O + let logo_bytes = include_bytes!("../assets/logo.png"); + let image = image::load_from_memory(logo_bytes).ok()?.into_rgba8(); + let size = [image.width() as usize, image.height() as usize]; + let pixels = image.into_raw(); + + Some(egui::ColorImage::from_rgba_unmultiplied(size, &pixels)) +} + +#[derive(Clone)] +pub(crate) enum AuthState { + Initializing, + ShowingQR { + auth_url: String, + }, + Authenticated { + session: PubkySession, + pub_storage: PublicStorage, + /// Map file URL to file title + file_cache: HashMap, + }, + Error(String), +} + +#[derive(Clone, PartialEq)] +pub(crate) enum ViewState { + WikiList, + CreateWiki, + ViewWiki, + EditWiki, +} + +pub(crate) struct PubkyApp { + pub(crate) state: Arc>, + qr_texture: Option, + logo_texture: Option, + logo_image: Option, + pub(crate) view_state: ViewState, + /// Content for the Edit Wiki view + pub(crate) edit_wiki_content: String, + pub(crate) selected_wiki_fork_urls: Vec, + pub(crate) selected_wiki_page_id: String, + pub(crate) selected_wiki_content: String, + pub(crate) selected_wiki_user_id: String, + pub(crate) needs_refresh: bool, + cache: CommonMarkCache, + rt: Arc, + pub(crate) show_copy_tooltip: bool, + /// Page ID from which content is being forked (when forking) + pub(crate) forked_from_page_id: Option, +} + +impl PubkyApp { + fn new(rt: Runtime) -> Self { + let state = Arc::new(Mutex::new(AuthState::Initializing)); + + // Start the auth flow in a background task + let state_clone = state.clone(); + + let rt_arc = Arc::new(rt); + let rt_arc_clone = rt_arc.clone(); + std::thread::spawn(move || { + let initialize_auth_fut = initialize_auth(); + match rt_arc_clone.block_on(initialize_auth_fut) { + Ok((pubky, flow, auth_url)) => { + *state_clone.lock().unwrap() = AuthState::ShowingQR { + auth_url: auth_url.clone(), + }; + + // Poll for authentication + let await_approval_fut = flow.await_approval(); + match rt_arc_clone.block_on(await_approval_fut) { + Ok(session) => { + Self::fetch_files_and_update( + &session, + &pubky.public_storage(), + rt_arc_clone, + state_clone, + ); + } + Err(e) => { + *state_clone.lock().unwrap() = + AuthState::Error(format!("Authentication failed: {e}")); + } + } + } + Err(e) => { + *state_clone.lock().unwrap() = + AuthState::Error(format!("Failed to initialize: {e}")); + } + } + }); + + // Load logo image + let logo_image = load_logo_image(); + + Self { + state, + qr_texture: None, + logo_texture: None, + logo_image, + view_state: ViewState::WikiList, + edit_wiki_content: String::new(), + selected_wiki_page_id: String::new(), + selected_wiki_content: String::new(), + selected_wiki_user_id: String::new(), + selected_wiki_fork_urls: vec![], + needs_refresh: false, + cache: CommonMarkCache::default(), + rt: rt_arc, + show_copy_tooltip: false, + forked_from_page_id: None, + } + } + + /// Fetch the list of files and their titles, then update the state with the file cache + fn fetch_files_and_update( + session: &PubkySession, + pub_storage: &PublicStorage, + rt_arc_clone: Arc, + state_clone: Arc>, + ) { + let mut file_cache = HashMap::new(); + + match get_list(session, "/pub/wiki.app/", rt_arc_clone.clone()) { + Ok(file_urls) => { + for file_url in &file_urls { + // Synchronously fetch the content + let get_path_fut = pub_storage.get(file_url); + match rt_arc_clone.block_on(get_path_fut) { + Ok(response) => { + let response_text_fut = response.text(); + match rt_arc_clone.block_on(response_text_fut) { + Ok(content) => { + let file_title = extract_title(&content); + + file_cache.insert(file_url.into(), file_title.into()); + } + Err(e) => log::error!("Error reading content: {e}"), + } + } + Err(e) => log::error!("Error fetching path {file_url}: {e}"), + } + } + } + Err(e) => log::error!("Failed to list files: {e}"), + } + + *state_clone.lock().unwrap() = AuthState::Authenticated { + session: session.clone(), + pub_storage: pub_storage.clone(), + file_cache, + }; + } + + fn navigate_to_view_wiki_page( + &mut self, + user_pk: &str, + page_id: &str, + session: &PubkySession, + pub_storage: &PublicStorage, + ) { + self.selected_wiki_user_id = user_pk.to_string(); + self.selected_wiki_page_id = page_id.to_string(); + self.selected_wiki_fork_urls = self.discover_fork_urls(session, pub_storage, page_id); + self.selected_wiki_content.clear(); + + self.view_state = ViewState::ViewWiki; + } + + fn navigate_to_edit_selected_wiki_page(&mut self) { + self.edit_wiki_content = self.selected_wiki_content.clone(); + self.view_state = ViewState::EditWiki; + } + + fn get_my_follows(&self, session: &PubkySession) -> Vec { + get_list(session, "/pub/pubky.app/follows/", self.rt.clone()) + .inspect_err(|e| log::error!("Failed to get follows: {e}")) + .map(|list| { + list.iter() + .map(|path| path.split('/').last().unwrap_or(&path).to_string()) + .collect() + }) + .unwrap_or_default() + } + + fn discover_fork_urls( + &self, + session: &PubkySession, + pub_storage: &PublicStorage, + page_id: &str, + ) -> Vec { + let follows = self.get_my_follows(session); + + let mut result = vec![]; + + // Add the current user's version as a fork (root version) + let own_pk = session.info().public_key().to_string(); + result.push(format!("{own_pk}/{page_id}")); + + for follow_pk in follows { + let fork_path = format!("pubky://{follow_pk}/pub/wiki.app/{page_id}"); + log::info!("fork_path = {fork_path}"); + let exists_fut = pub_storage.get(fork_path); + + match self.rt.block_on(exists_fut) { + Ok(_) => result.push(format!("{follow_pk}/{page_id}")), + Err(e) => log::error!("Failed to check if file exists: {e}"), + } + } + result + } +} + +impl eframe::App for PubkyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(30.0); + + // Display logo + if self.logo_texture.is_none() { + if let Some(logo_image) = &self.logo_image { + self.logo_texture = Some(ui.ctx().load_texture( + "logo", + logo_image.clone(), + Default::default(), + )); + } + } + + if let Some(texture) = &self.logo_texture { + let logo_size = egui::vec2(80.0, 80.0); + ui.add(egui::Image::from_texture(texture).max_size(logo_size)); + ui.add_space(15.0); + } + + ui.heading(egui::RichText::new(APP_NAME).size(24.0).strong()); + ui.add_space(30.0); + + let state = self.state.lock().unwrap().clone(); + + match state { + AuthState::Initializing => { + ui.add_space(20.0); + ui.spinner(); + ui.add_space(10.0); + ui.label(egui::RichText::new("Initializing authentication...").size(16.0)); + } + AuthState::ShowingQR { ref auth_url } => { + ui.label(egui::RichText::new("Scan this QR code with your Pubky app to login:").size(16.0)); + ui.add_space(25.0); + + // Generate and display QR code + if self.qr_texture.is_none() { + if let Some(qr_image) = generate_qr_image(auth_url) { + self.qr_texture = Some(ui.ctx().load_texture( + "qr_code", + qr_image, + Default::default(), + )); + } + } + + if let Some(texture) = &self.qr_texture { + // Constrain QR code size to fit within window + let max_size = egui::vec2(300.0, 300.0); + ui.add(egui::Image::from_texture(texture).max_size(max_size)); + } + + ui.add_space(15.0); + ui.label(egui::RichText::new("Waiting for authentication...").italics()); + ui.add_space(5.0); + ui.spinner(); + } + AuthState::Authenticated { + session, + ref pub_storage, + ref file_cache, + } => { + // Check if we need to refresh the files cache + if self.needs_refresh { + let state_clone = self.state.clone(); + + Self::fetch_files_and_update( + &session, + pub_storage, + self.rt.clone(), + state_clone, + ); + + self.needs_refresh = false; + } + + let own_pk = session.info().public_key(); + + // Show different views based on view_state + match self.view_state { + ViewState::WikiList => { + ui.add_space(10.0); + let create_button = ui.add_sized( + [200.0, 40.0], + egui::Button::new(egui::RichText::new("✨ Create New Wiki Page").size(16.0)) + ); + if create_button.clicked() { + self.view_state = ViewState::CreateWiki; + } + ui.add_space(30.0); + + ui.label(egui::RichText::new("My Wiki Posts").size(18.0).strong()); + ui.add_space(15.0); + + // List all wiki posts as buttons + egui::ScrollArea::vertical().show(ui, |ui| { + if file_cache.is_empty() { + ui.add_space(10.0); + ui.label(egui::RichText::new("No wiki posts yet. Create your first one!").italics().color(egui::Color32::GRAY)); + } else { + let pk = own_pk.to_string(); + for (file_url, file_title) in file_cache { + // Extract just the filename from the URL + let file_name = + file_url.split('/').last().unwrap_or(file_url); + + ui.horizontal(|ui| { + if ui.button(egui::RichText::new(file_name).monospace()).clicked() { + self.navigate_to_view_wiki_page( + &pk, + file_name, + &session, + pub_storage, + ); + } + + ui.label(egui::RichText::new(file_title).strong()); + }); + ui.add_space(5.0); + } + } + }); + } + ViewState::CreateWiki => create_wiki::update(self, &session, ctx, ui), + ViewState::EditWiki => edit_wiki::update(self, &session, ctx, ui), + ViewState::ViewWiki => { + view_wiki::update(self, &session, &pub_storage, ctx, ui) + } + } + } + AuthState::Error(ref error) => { + ui.colored_label(egui::Color32::RED, "Error"); + ui.add_space(10.0); + ui.label(error); + } + } + }); + }); + } +} + +async fn initialize_auth() -> Result<(Pubky, PubkyAuthFlow, String)> { + let pubky = Pubky::new()?; + let caps = Capabilities::builder().write("/pub/wiki.app/").finish(); + let flow = pubky.start_auth_flow(&caps)?; + let auth_url = flow.authorization_url().to_string(); + + Ok((pubky, flow, auth_url)) +} + +pub(crate) async fn create_wiki_post( + session: &PubkySession, + content: &str, + filename: Option<&str>, +) -> Result { + let path = if let Some(fname) = filename { + format!("/pub/wiki.app/{}", fname) + } else { + format!("/pub/wiki.app/{}", Uuid::new_v4()) + }; + + // Create the post with the provided content + session.storage().put(&path, content.to_string()).await?; + + log::info!("Created post at path: {}", path); + + Ok(path) +} + +pub(crate) async fn update_wiki_post( + session: &PubkySession, + page_id: &str, + content: &str, +) -> Result<()> { + let path = format!("/pub/wiki.app/{}", page_id); + + // Update the post with the provided content + session.storage().put(&path, content.to_string()).await?; + + log::info!("Updated post at path: {}", path); + + Ok(()) +} + +pub(crate) async fn delete_wiki_post(session: &PubkySession, page_id: &str) -> Result<()> { + let path = format!("/pub/wiki.app/{}", page_id); + + // Delete the post + session.storage().delete(&path).await?; + + log::info!("Deleted post at path: {}", path); + + Ok(()) +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..e032d72 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; + +use pubky::PubkySession; +use qrcode::QrCode; +use tokio::runtime::Runtime; + +pub fn generate_qr_image(url: &str) -> Option { + let qr = QrCode::new(url.as_bytes()).ok()?; + let qr_image = qr.render::>().build(); + + let (width, height) = qr_image.dimensions(); + let scale = 2; // Scale QR code to fit within window + let scaled_width = (width * scale) as usize; + let scaled_height = (height * scale) as usize; + + let mut pixels = Vec::with_capacity(scaled_width * scaled_height); + + for y in 0..scaled_height { + for x in 0..scaled_width { + let orig_x = x as u32 / scale; + let orig_y = y as u32 / scale; + let pixel = qr_image.get_pixel(orig_x, orig_y); + let color = if pixel[0] < 128 { + egui::Color32::BLACK + } else { + egui::Color32::WHITE + }; + pixels.push(color); + } + } + + Some(egui::ColorImage::new([scaled_width, scaled_height], pixels)) +} + +/// In this context, the title is the readable text on the 1st line +pub fn extract_title(input: &str) -> &str { + // Get the first line by splitting on newlines and taking the first element + let first_line = input.lines().next().unwrap_or(""); + first_line.trim_start_matches("# ") +} + +pub fn extract_details_wiki_url(url: &str) -> Option<(String, String)> { + // Split once on '/' and collect the two parts. + let mut parts = url.splitn(2, '/'); + + let first = parts.next()?.trim(); + let second = parts.next()?.trim(); + + // Ensure both parts are present and not empty. + if first.is_empty() || second.is_empty() { + log::warn!("Invalid Pubky Wiki link: {url}"); + return None; + } + + Some((first.to_string(), second.to_string())) +} + +/// List files from the homeserver +pub fn get_list( + session: &PubkySession, + folder_path: &str, + rt: Arc, +) -> anyhow::Result> { + let session_storage = session.storage(); + let session_storage_list_fut = session_storage.list(folder_path).unwrap().send(); + + log::info!("listing {folder_path}"); + + let mut result_list = vec![]; + for entry in rt.block_on(session_storage_list_fut)? { + result_list.push(entry.to_pubky_url()); + } + + Ok(result_list) +} diff --git a/src/view_wiki.rs b/src/view_wiki.rs new file mode 100644 index 0000000..8e3d66f --- /dev/null +++ b/src/view_wiki.rs @@ -0,0 +1,175 @@ +use crate::{utils::extract_details_wiki_url, PubkyApp, ViewState}; + +use eframe::egui::{Context, Ui}; +use egui::CollapsingHeader; +use egui_commonmark::CommonMarkViewer; +use pubky::{PubkySession, PublicStorage}; + +pub(crate) fn update( + app: &mut PubkyApp, + session: &PubkySession, + pub_storage: &PublicStorage, + ctx: &Context, + ui: &mut Ui, +) { + ui.label(egui::RichText::new("View Wiki Post").size(20.0).strong()); + ui.add_space(25.0); + + CollapsingHeader::new(egui::RichText::new("📋 Page Details").size(15.0)).show(ui, |ui| { + ui.add_space(5.0); + ui.label(egui::RichText::new(format!("Page ID: {}", &app.selected_wiki_page_id)).monospace()); + ui.label(egui::RichText::new(format!("User ID: {}", &app.selected_wiki_user_id)).monospace()); + }); + + ui.add_space(10.0); + let fork_links = app.selected_wiki_fork_urls.clone(); + CollapsingHeader::new(egui::RichText::new(format!("🔀 Available Forks ({})", fork_links.len())).size(15.0)).show(ui, |ui| { + ui.add_space(5.0); + for fork_link in fork_links { + if let Some((user_pk, page_id)) = extract_details_wiki_url(&fork_link) { + let mut btn_label = format!("Fork: {user_pk}"); + + if &app.selected_wiki_user_id == &user_pk { + btn_label = format!("{btn_label} (current)"); + } + + if ui.button(btn_label).clicked() { + app.navigate_to_view_wiki_page(&user_pk, &page_id, session, pub_storage); + } + } + } + }); + + ui.add_space(15.0); + // Add "Share Page Link" button with tooltip support + let share_button = ui.add_sized( + [180.0, 35.0], + egui::Button::new(egui::RichText::new("🔗 Share Page Link").size(15.0)) + ); + + // Show tooltip when hovering after copy + if app.show_copy_tooltip { + share_button.show_tooltip_text("Copied"); + } + + if share_button.clicked() { + let user_id = &app.selected_wiki_user_id; + let page_id = &app.selected_wiki_page_id; + ctx.copy_text(format!("[link]({user_id}/{page_id})")); + app.show_copy_tooltip = true; + } + + // Reset tooltip if button is not being hovered + if !share_button.hovered() && app.show_copy_tooltip { + app.show_copy_tooltip = false; + } + + ui.add_space(15.0); + + // Display content in a scrollable area + ui.separator(); + ui.add_space(15.0); + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + // Try to fetch content if empty + if app.selected_wiki_content.is_empty() + && !app.selected_wiki_page_id.is_empty() + && !app.selected_wiki_user_id.is_empty() + { + let public_storage_clone = pub_storage.clone(); + let path_clone = app.selected_wiki_page_id.clone(); + let user_id = app.selected_wiki_user_id.clone(); + + let path = format!("pubky{user_id}/pub/wiki.app/{path_clone}"); + + // Synchronously fetch the content + let get_path_fut = public_storage_clone.get(&path); + let fetched_content = match app.rt.block_on(get_path_fut) { + Ok(response) => match app.rt.block_on(response.text()) { + Ok(text) => text, + Err(e) => format!("Error reading content: {e}"), + }, + Err(e) => format!("Error fetching path {path}: {e}"), + }; + app.selected_wiki_content = fetched_content; + } + + egui::ScrollArea::vertical().show(ui, |ui| { + CommonMarkViewer::new().max_image_width(Some(512)).show( + ui, + &mut app.cache, + &app.selected_wiki_content.as_str(), + ); + }); + + // Intercept link clicks by checking the output commands + let clicked_urls: Vec = ui.ctx().output_mut(|o| { + let mut urls = Vec::new(); + // Drain commands to prevent external opening and capture URLs + o.commands.retain(|cmd| { + if let egui::output::OutputCommand::OpenUrl(open_url) = cmd { + log::info!("Intercepted link click: {}", open_url.url); + urls.push(open_url.url.to_string()); + false // Remove this command to prevent external opening + } else { + true // Keep other commands + } + }); + urls + }); + + // Navigate to clicked URLs + for url in clicked_urls { + if let Some((user_pk, page_id)) = extract_details_wiki_url(&url) { + app.navigate_to_view_wiki_page(&user_pk, &page_id, session, pub_storage); + } + } + }); + + ui.add_space(25.0); + + // Check if this is the user's own page + let pk = session.info().public_key(); + let is_own_page = app.selected_wiki_user_id == pk.to_string(); + + ui.horizontal(|ui| { + // Show Edit button only for own pages + if is_own_page { + let edit_button = ui.add_sized( + [120.0, 35.0], + egui::Button::new(egui::RichText::new("✏ Edit").size(15.0)) + ); + if edit_button.clicked() { + app.navigate_to_edit_selected_wiki_page(); + } + ui.add_space(10.0); + } + + // Fork button - available for only when viewing other user's pages + if !is_own_page { + let fork_button = ui.add_sized( + [120.0, 35.0], + egui::Button::new(egui::RichText::new("🍴 Fork").size(15.0)) + ); + if fork_button.clicked() { + app.edit_wiki_content = app.selected_wiki_content.clone(); + app.forked_from_page_id = Some(app.selected_wiki_page_id.clone()); + app.view_state = ViewState::CreateWiki; + } + ui.add_space(10.0); + } + + // Go back button + let back_button = ui.add_sized( + [120.0, 35.0], + egui::Button::new(egui::RichText::new("← Back").size(15.0)) + ); + if back_button.clicked() { + app.selected_wiki_page_id.clear(); + app.selected_wiki_content.clear(); + app.selected_wiki_fork_urls.clear(); + app.view_state = ViewState::WikiList; + } + }); +} diff --git a/wiky/README.md b/wiky/README.md new file mode 100644 index 0000000..fc5331c --- /dev/null +++ b/wiky/README.md @@ -0,0 +1,64 @@ +# Pubky Decentralized Wiki + +A decentralized wiki application built on the Pubky protocol, enabling users to create, edit, and share wiki pages in a truly decentralized manner. + +## Description + +Pubky Wiki is a desktop application that leverages the Pubky decentralized protocol to provide a censorship-resistant, user-owned wiki platform. Built with Rust and egui, it offers a native GUI experience for creating and managing wiki content that is stored on your own Pubky homeserver. Users authenticate via QR code scanning with the Pubky Ring app, ensuring secure and decentralized identity management. + +Key features: + +- **Stop arguing, start forking**: Multiple valid perspectives can coexist and be discoverable +- **Decentralized Storage**: Wiki pages are stored on the page author's homeserver, not on centralized servers +- **User Ownership**: You control your content through your Pubky identity + +## Use-Cases + +- **Personal Knowledge Gardens** - Create your own wiki pages on any topic, stored on your Pubky Homeserver, building an interconnected web of knowledge you fully control +- **Fork Controversial Topics** - Clone any wiki page to your homeserver and present alternative perspectives on contested subjects (scientific debates, historical events, political issues) +- **Academic Research Collaboration** - Researchers can fork and extend each other's work, creating branching interpretations while maintaining clear provenance and attribution +- **Bias-Aware Journalism** - Report on news events with multiple perspectives visible through your social graph, showing which of your trusted contacts have different versions +- **Community-Curated Documentation** - Technical communities can maintain multiple valid approaches to problems, with users discovering versions through their web of trust rather than central authority +- **Educational Content Evolution** - Students and teachers fork course materials to adapt them for different contexts, creating a living curriculum that improves through iteration +- **Censorship-Resistant Publishing** - Publish sensitive information that survives takedown attempts by existing across multiple homeservers in your network +- **Credible Exit from Platforms** - Take your entire wiki content with you when switching homeservers, maintaining all links and discoverability without platform lock-in +- **Trust-Weighted Discovery** - Browse topics and automatically see which versions your friends, colleagues, or follows have endorsed through their forks +- **Semantic Link Networks** - Build associative trails between related concepts across different authors' wikis, creating emergent knowledge graphs + +## Get started (example) + +Start the app and create a wiki page with + +``` +# My Favorite Links + +[Carol's page on Bitcoin](6ookcbkiyn8ced651eu6rqgm5o1prorajzxgyhg4bxkkcfduzo4y/8143c732-35a6-4a86-96f5-3ffb17b80ad0) + +[Alice's page on Veganism](77femca644769gt9gwkzsg6g4hxmpc9s6ciqapce9by89e4yhpso/64597e9a-f0be-4408-a99e-9ddda72e578e) + +[Alice describes Lugano](77femca644769gt9gwkzsg6g4hxmpc9s6ciqapce9by89e4yhpso/19b5888e-d5a1-4e79-a551-2a7509a63b1c) +``` + +Browse the links, fork any page, or create new pages. + +## Downloads + +You can find binaries here: https://github.com/ok300/hackathon-2025/releases/tag/v0.1 + +If you have problems run the macOS binary: + +* extract it +* try to run it (you might see a popup saying it can't be run because it's not signed) +* go to Settings > Privacy & Security > Security + * it will say "pubky-wiki tried to run, but was blocked" + * click on Open Anyway + +Alternatively, you can run from source code. Clone the repo and: + +``` +cargo run +``` + +## License + +MIT