diff --git a/Cargo.toml b/Cargo.toml index 5e0532dd0..d26669263 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["screenpipe-server", "screenpipe-vision", "screenpipe-audio", "examples/apps/screen-pipe-app/src-tauri"] +members = ["screenpipe-server", "screenpipe-vision", "screenpipe-audio", "examples/apps/screen-pipe-app/src-tauri", "examples/apps/screenpipe-app-dioxus"] exclude = [] resolver = "2" diff --git a/examples/apps/screenpipe-app-dioxus/.gitignore b/examples/apps/screenpipe-app-dioxus/.gitignore new file mode 100644 index 000000000..884ebcaf5 --- /dev/null +++ b/examples/apps/screenpipe-app-dioxus/.gitignore @@ -0,0 +1,9 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +/dist/ +/static/ +/.dioxus/ + +# These are backup files generated by rustfmt +**/*.rs.bk diff --git a/examples/apps/screenpipe-app-dioxus/Cargo.toml b/examples/apps/screenpipe-app-dioxus/Cargo.toml new file mode 100644 index 000000000..502cb97ea --- /dev/null +++ b/examples/apps/screenpipe-app-dioxus/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "screenpipe-app" +version = "0.1.0" +authors = ["Louis Beaumont "] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] + +dioxus = { version = "0.5.1", features = ["desktop", "router"] } + +# Debug +tracing = "0.1.40" +dioxus-logger = "0.5.0" + +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } + +screenpipe-vision = { path = "../../../screenpipe-vision" } + +axum = "0.7.5" + +# Logging +log = "0.4.17" +env_logger = "0.11.3" + +# Tokio +tokio = { version = "1.36.0", features = ["full"] } + +# Directory management +dirs = "5.0" + +# HTTP client +reqwest = { version = "0.12", features = ["json"] } + +# Uuid +uuid = { version = "1.10.0", features = ["v4"] } + +# Sentry +sentry = "0.34.0" + +# Chrono +chrono = "0.4.3" + +anyhow = "1.0" + +# M series MacOS +[target.'cfg(target_os = "macos")'.dependencies] +screenpipe-server = { path = "../../../screenpipe-server", features = ["metal"] } +screenpipe-audio = { path = "../../../screenpipe-audio", features = ["metal"] } + +# Linux +[target.'cfg(target_os = "linux")'.dependencies] +screenpipe-server = { path = "../../../screenpipe-server", features = [] } +screenpipe-audio = { path = "../../../screenpipe-audio", features = [] } + +# Windows +[target.'cfg(target_os = "windows")'.dependencies] +screenpipe-server = { path = "../../../screenpipe-server", features = [] } +screenpipe-audio = { path = "../../../screenpipe-audio", features = [] } + +[features] +cuda = ["screenpipe-server/cuda", "screenpipe-audio/cuda"] +metal = ["screenpipe-server/metal", "screenpipe-audio/metal"] +bundle = [] \ No newline at end of file diff --git a/examples/apps/screenpipe-app-dioxus/Dioxus.toml b/examples/apps/screenpipe-app-dioxus/Dioxus.toml new file mode 100644 index 000000000..d06449881 --- /dev/null +++ b/examples/apps/screenpipe-app-dioxus/Dioxus.toml @@ -0,0 +1,43 @@ +[application] + +# App (Project) Name +name = "screenpipe-app" + +# Dioxus App Default Platform +# desktop, web +default_platform = "desktop" + +# `build` & `serve` dist path +out_dir = "dist" + +# assets file folder +asset_dir = "assets" + +[web.app] + +# HTML title tag content +title = "screenpipe-app" + +[web.watcher] + +# when watcher trigger, regenerate the `index.html` +reload_html = true + +# which files or dirs will be watcher monitoring +watch_path = ["src", "assets"] + +# include `assets` in web platform +[web.resource] + +# CSS style file + +style = ["tailwind.css"] + +# Javascript code file +script = [] + +[web.resource.dev] + +# Javascript code file +# serve: [dev-server] only +script = [] diff --git a/examples/apps/screenpipe-app-dioxus/README.md b/examples/apps/screenpipe-app-dioxus/README.md new file mode 100644 index 000000000..736b91124 --- /dev/null +++ b/examples/apps/screenpipe-app-dioxus/README.md @@ -0,0 +1,15 @@ +# Development + +1. Install npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm +2. Install the tailwind css cli: https://tailwindcss.com/docs/installation +3. Run the following command in the root of the project to start the tailwind CSS compiler: + +```bash +npx tailwindcss -i ./input.css -o ./assets/tailwind.css --watch +``` + +Run the following command in the root of the project to start the Dioxus dev server: + +```bash +dx serve --hot-reload --platform desktop +``` \ No newline at end of file diff --git a/examples/apps/screenpipe-app-dioxus/assets/header.svg b/examples/apps/screenpipe-app-dioxus/assets/header.svg new file mode 100644 index 000000000..59c96f2f2 --- /dev/null +++ b/examples/apps/screenpipe-app-dioxus/assets/header.svg @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/examples/apps/screenpipe-app-dioxus/assets/main.css b/examples/apps/screenpipe-app-dioxus/assets/main.css new file mode 100644 index 000000000..affbeb048 --- /dev/null +++ b/examples/apps/screenpipe-app-dioxus/assets/main.css @@ -0,0 +1,40 @@ +body { + background-color: #111216; +} + +#main { + margin: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif +} + +#links { + width: 400px; + text-align: left; + font-size: x-large; + color: white; + display: flex; + flex-direction: column; +} + +#links a { + color: white; + text-decoration: none; + margin-top: 20px; + margin: 10px; + border: white 1px solid; + border-radius: 5px; + padding: 10px; +} + +#links a:hover { + background-color: #1f1f1f; + cursor: pointer; +} + +#header { + max-width: 1200px; +} diff --git a/examples/apps/screenpipe-app-dioxus/input.css b/examples/apps/screenpipe-app-dioxus/input.css new file mode 100644 index 000000000..bd6213e1d --- /dev/null +++ b/examples/apps/screenpipe-app-dioxus/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/examples/apps/screenpipe-app-dioxus/src/analytics.rs b/examples/apps/screenpipe-app-dioxus/src/analytics.rs new file mode 100644 index 000000000..5d3c281bc --- /dev/null +++ b/examples/apps/screenpipe-app-dioxus/src/analytics.rs @@ -0,0 +1,145 @@ +use log::{error, info}; +use reqwest::Client; +use serde_json::json; +use std::fs; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::time::interval; +use uuid::Uuid; + +pub struct AnalyticsManager { + client: Client, + posthog_api_key: String, + distinct_id: String, + interval: Duration, + enabled: Arc>, + api_host: String, +} + +impl AnalyticsManager { + pub fn new(posthog_api_key: String, distinct_id: String, interval_hours: u64) -> Self { + Self { + client: Client::new(), + posthog_api_key, + distinct_id, + interval: Duration::from_secs(interval_hours * 3600), + enabled: Arc::new(Mutex::new(true)), + api_host: "https://eu.i.posthog.com".to_string(), + } + } + + pub async fn send_event( + &self, + event: &str, + properties: Option, + ) -> Result<(), Box> { + if !*self.enabled.lock().unwrap() { + return Ok(()); + } + + let posthog_url = format!("{}/capture/", self.api_host); + + let mut payload = json!({ + "api_key": self.posthog_api_key, + "event": event, + "properties": { + "distinct_id": self.distinct_id, + "$lib": "rust-reqwest", + "timestamp": chrono::Utc::now().to_rfc3339(), + }, + }); + + if let Some(props) = properties { + if let Some(payload_props) = payload["properties"].as_object_mut() { + payload_props.extend(props.as_object().unwrap_or(&serde_json::Map::new()).clone()); + } + } + + let response = self.client.post(posthog_url).json(&payload).send().await?; + + if !response.status().is_success() { + return Err(format!("PostHog API error: {}", response.status()).into()); + } + + Ok(()) + } + + pub async fn start_periodic_event(&self) { + let mut interval = interval(self.interval); + + loop { + interval.tick().await; + if *self.enabled.lock().unwrap() { + if let Err(e) = self.send_event("app_still_running", None).await { + error!("Failed to send periodic PostHog event: {}", e); + } + } + } + } + + pub fn toggle_analytics(&self) -> bool { + let mut enabled = self.enabled.lock().unwrap(); + *enabled = !*enabled; + *enabled + } + + pub async fn track_search(&self) -> Result<(), Box> { + if !*self.enabled.lock().unwrap() { + return Ok(()); + } + + self.send_event("search_request", None).await + } +} + +pub fn get_or_create_unique_id(app_name: &str) -> Result> { + let home_dir = dirs::home_dir().ok_or("Failed to get home directory")?; + let app_dir = home_dir.join(format!(".{}", app_name)); + let id_file = app_dir.join("unique_id"); + + if !app_dir.exists() { + fs::create_dir_all(&app_dir)?; + } + + if id_file.exists() { + Ok(fs::read_to_string(id_file)?) + } else { + let new_id = Uuid::new_v4().to_string(); + fs::write(id_file, &new_id)?; + Ok(new_id) + } +} + +pub fn start_analytics( + posthog_api_key: String, + app_name: &str, + interval_hours: u64, +) -> Result, Box> { + let distinct_id = get_or_create_unique_id(app_name)?; + let analytics_manager = Arc::new(AnalyticsManager::new( + posthog_api_key, + distinct_id, + interval_hours, + )); + + // Send initial event at boot + tokio::spawn({ + let analytics_manager = analytics_manager.clone(); + async move { + if let Err(e) = analytics_manager.send_event("app_started", None).await { + error!("Failed to send initial PostHog event: {}", e); + } + info!("Analytics started"); + } + }); + + // Start periodic events + tokio::spawn({ + let analytics_manager = analytics_manager.clone(); + async move { + analytics_manager.start_periodic_event().await; + } + }); + + Ok(analytics_manager) +} diff --git a/examples/apps/screenpipe-app-dioxus/src/logs.rs b/examples/apps/screenpipe-app-dioxus/src/logs.rs new file mode 100644 index 000000000..96aac57b6 --- /dev/null +++ b/examples/apps/screenpipe-app-dioxus/src/logs.rs @@ -0,0 +1,26 @@ +use std::io::Write; +pub struct MultiWriter { + writers: Vec>, +} + +impl MultiWriter { + pub fn new(writers: Vec>) -> Self { + MultiWriter { writers } + } +} + +impl Write for MultiWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + for writer in &mut self.writers { + writer.write_all(buf)?; + } + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + for writer in &mut self.writers { + writer.flush()?; + } + Ok(()) + } +} diff --git a/examples/apps/screenpipe-app-dioxus/src/main.rs b/examples/apps/screenpipe-app-dioxus/src/main.rs new file mode 100644 index 000000000..15090b56d --- /dev/null +++ b/examples/apps/screenpipe-app-dioxus/src/main.rs @@ -0,0 +1,290 @@ +#![cfg_attr(feature = "bundle", windows_subsystem = "windows")] + +use dioxus::desktop::{muda::*, use_wry_event_handler}; +use dioxus::prelude::*; +use dirs::home_dir; +use log::{error, info, LevelFilter}; +use logs::MultiWriter; +use screenpipe_server::DatabaseManager; +use sentry; +use std::fs::{self, File}; +use std::io::Write; +use std::ops::Deref; +use std::path::PathBuf; +use std::sync::Arc; +mod analytics; +use analytics::AnalyticsManager; +use tokio::runtime::Runtime; + +use crate::analytics::start_analytics; +mod logs; + +use screenpipe_audio::{ + default_input_device, default_output_device, list_audio_devices, DeviceControl, +}; +use screenpipe_server::{ResourceMonitor, Server}; +use std::sync::atomic::AtomicBool; +use std::time::Duration; +use tokio::sync::mpsc; + +async fn setup_server_and_recording( + db: Arc, + local_data_dir: Arc, + port: u16, + fps: f64, + audio_chunk_duration: u64, + disable_audio: bool, + memory_threshold: f64, + runtime_threshold: u64, + analytics_manager: Arc, +) { + info!("Setting up server and recording..."); + + let (audio_devices_control_sender, audio_devices_control_receiver) = mpsc::channel(64); + let mut audio_devices = Vec::new(); + let mut devices_status = std::collections::HashMap::new(); + + if !disable_audio { + info!("Initializing audio devices..."); + let all_audio_devices = list_audio_devices().unwrap_or_default(); + + for device in all_audio_devices { + let device_control = DeviceControl { + is_running: false, + is_paused: false, + }; + info!("Audio device: {:?}", device.to_string()); + devices_status.insert(device, device_control); + } + + if let Ok(input_device) = default_input_device() { + info!("Default input device found: {:?}", input_device.to_string()); + audio_devices.push(Arc::new(input_device.clone())); + devices_status.get_mut(&input_device).unwrap().is_running = true; + } + if let Ok(output_device) = default_output_device() { + info!( + "Default output device found: {:?}", + output_device.to_string() + ); + audio_devices.push(Arc::new(output_device.clone())); + devices_status.get_mut(&output_device).unwrap().is_running = true; + } + + if audio_devices.is_empty() { + error!("No audio devices available. Audio recording will be disabled."); + } else { + info!("Using audio devices:"); + for device in &audio_devices { + info!(" {}", device); + let device_clone = device.deref().clone(); + let sender_clone = audio_devices_control_sender.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(15)).await; + let _ = sender_clone + .send(( + device_clone, + DeviceControl { + is_running: true, + is_paused: false, + }, + )) + .await; + }); + } + } + } else { + info!("Audio recording is disabled"); + } + + // Start resource monitoring + ResourceMonitor::new(memory_threshold, runtime_threshold, false) + .start_monitoring(Duration::from_secs(10)); + + let db_record = db.clone(); + let db_server = db.clone(); + + let (_control_tx, control_rx) = mpsc::channel(64); + let vision_control = Arc::new(AtomicBool::new(true)); + let vision_control_server_clone = vision_control.clone(); + + // Spawn continuous recording task + let _recording_task = tokio::spawn({ + let local_data_dir = local_data_dir.clone(); + async move { + let audio_chunk_duration = Duration::from_secs(audio_chunk_duration); + screenpipe_server::start_continuous_recording( + db_record, + local_data_dir, + fps, + audio_chunk_duration, + control_rx, + vision_control, + audio_devices_control_receiver, + ) + .await + } + }); + + // API plugin for analytics + let api_plugin = move |req: &axum::http::Request| { + if req.uri().path() == "/search" { + let analytics_manager = analytics_manager.clone(); + tokio::spawn(async move { + if let Err(e) = analytics_manager.track_search().await { + error!("Failed to track search request: {}", e); + } + }); + } + }; + + // Spawn server task + tokio::spawn(async move { + let server = Server::new( + db_server, + std::net::SocketAddr::from(([0, 0, 0, 0], port)), + vision_control_server_clone, + audio_devices_control_sender, + ); + info!("Starting server..."); + + if let Err(e) = server.start(devices_status, api_plugin).await { + error!("Failed to start server: {}", e); + } + }); + + info!("Server started on http://localhost:{}", port); +} + +async fn initialize_database(local_data_dir: Arc) -> Arc { + Arc::new( + DatabaseManager::new(&format!("{}/db.sqlite", local_data_dir)) + .await + .unwrap(), + ) +} + +fn get_local_dir(custom_path: Option) -> anyhow::Result { + let default_path = home_dir() + .ok_or("Failed to get home directory") + .unwrap() + .join(".screenpipe"); + + let local_data_dir = custom_path.map(PathBuf::from).unwrap_or(default_path); + + fs::create_dir_all(&local_data_dir)?; + Ok(local_data_dir) +} + +async fn start_screenpipe() -> anyhow::Result<()> { + // Initialize Sentry + let _guard = sentry::init(("https://cf682877173997afc8463e5ca2fbe3c7@o4507617161314304.ingest.us.sentry.io/4507617170161664", sentry::ClientOptions { + release: sentry::release_name!(), + ..Default::default() + })); + + // Set up logging + let mut builder = env_logger::Builder::new(); + builder + .filter(None, LevelFilter::Info) + .filter_module("tokenizers", LevelFilter::Error) + .filter_module("rusty_tesseract", LevelFilter::Error) + .filter_module("symphonia", LevelFilter::Error); + + let base_dir = get_local_dir(None)?; + fs::create_dir_all(&base_dir)?; + + let log_file = File::create(format!("{}/screenpipe.log", base_dir.to_string_lossy()))?; + let multi_writer = MultiWriter::new(vec![ + Box::new(log_file) as Box, + Box::new(std::io::stdout()) as Box, + ]); + + builder.target(env_logger::Target::Pipe(Box::new(multi_writer))); + builder.format_timestamp_secs().init(); + + // Initialize local data directory and database + let local_data_dir = Arc::new( + get_local_dir(None) + .unwrap() + .join("data") + .to_string_lossy() + .into_owned(), + ); + let db = initialize_database(local_data_dir.clone()).await; + + // Start analytics + let posthog_api_key = "phc_Bt8GoTBPgkCpDrbaIZzJIEYt0CrJjhBiuLaBck1clce".to_string(); + let app_name = "screenpipe"; + let interval_hours = 1; + let analytics_manager = start_analytics(posthog_api_key, app_name, interval_hours).unwrap(); + + // Setup server and recording + setup_server_and_recording( + db, + local_data_dir, + 3000, // default port + 30.0, // default fps + 5, // default audio_chunk_duration + false, // default disable_audio + 80.0, // default memory_threshold + 3600, // default runtime_threshold + analytics_manager, + ) + .await; + + Ok(()) +} + +fn main() { + // Create a Tokio runtime + let rt = Runtime::new().unwrap(); + + // Start Screenpipe in the background + if let Err(e) = rt.block_on(start_screenpipe()) { + eprintln!("Failed to start Screenpipe: {}", e); + std::process::exit(1); + } + // Create a menu bar that only contains the edit menu + let menu = Menu::new(); + let edit_menu = Submenu::new("Edit", true); + + edit_menu + .append_items(&[ + &PredefinedMenuItem::undo(None), + &PredefinedMenuItem::redo(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::cut(None), + &PredefinedMenuItem::copy(None), + &PredefinedMenuItem::paste(None), + &PredefinedMenuItem::select_all(None), + &MenuItem::with_id("switch-text", "Switch text", true, None), + ]) + .unwrap(); + + menu.append(&edit_menu).unwrap(); + + // Create a desktop config that overrides the default menu with the custom menu + let config = dioxus::desktop::Config::new().with_menu(menu); + + // Launch the app with the custom menu + LaunchBuilder::new().with_cfg(config).launch(app) +} + +fn app() -> Element { + let mut text = use_signal(String::new); + // You can use the `use_muda_event_handler` hook to run code when a menu event is triggered. + use_wry_event_handler(move |muda_event, _| { + // if muda_event.id() == "switch-text" { + // text.set("Switched to text".to_string()); + // } + }); + + rsx! { + div { + h1 { "Welcome to screen | ⭐️" } + // p { "Text: {text}" } + p { "It's running! Check examples on Github on how to use your data now :)" } + } + } +} diff --git a/examples/apps/screenpipe-app-dioxus/tailwind.config.js b/examples/apps/screenpipe-app-dioxus/tailwind.config.js new file mode 100644 index 000000000..2a69d5803 --- /dev/null +++ b/examples/apps/screenpipe-app-dioxus/tailwind.config.js @@ -0,0 +1,9 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + mode: "all", + content: ["./src/**/*.{rs,html,css}", "./dist/**/*.html"], + theme: { + extend: {}, + }, + plugins: [], +};