From c31c7c46d51e7563f019bb5308a2e2c888a80218 Mon Sep 17 00:00:00 2001 From: Kevin Yue Date: Mon, 19 Feb 2024 08:36:10 -0500 Subject: [PATCH] Implement gpgui-helper --- Cargo.lock | 40 +++--- Cargo.toml | 1 + apps/gpgui-helper/src-tauri/Cargo.toml | 6 +- apps/gpgui-helper/src-tauri/src/app.rs | 125 +++++------------ apps/gpgui-helper/src-tauri/src/cli.rs | 28 ++-- apps/gpgui-helper/src-tauri/src/downloader.rs | 87 ++++++++++++ apps/gpgui-helper/src-tauri/src/lib.rs | 2 + apps/gpgui-helper/src-tauri/src/updater.rs | 129 ++++++++++++++++++ apps/gpgui-helper/src-tauri/tauri.conf.json | 10 +- apps/gpgui-helper/src/components/App/App.tsx | 71 +++++----- apps/gpgui-helper/vite.config.ts | 2 +- apps/gpservice/Cargo.toml | 1 + apps/gpservice/src/cli.rs | 6 +- apps/gpservice/src/handlers.rs | 54 +++++++- apps/gpservice/src/ws_server.rs | 5 + crates/gpapi/Cargo.toml | 1 + crates/gpapi/src/lib.rs | 4 + crates/gpapi/src/process/command_traits.rs | 6 +- .../gpapi/src/process/gui_helper_launcher.rs | 68 +++++++++ crates/gpapi/src/process/gui_launcher.rs | 97 ++++++++----- crates/gpapi/src/process/mod.rs | 1 + crates/gpapi/src/service/request.rs | 6 + crates/gpapi/src/utils/checksum.rs | 14 ++ crates/gpapi/src/utils/mod.rs | 1 + crates/gpapi/src/utils/window.rs | 14 +- 25 files changed, 557 insertions(+), 222 deletions(-) create mode 100644 apps/gpgui-helper/src-tauri/src/downloader.rs create mode 100644 apps/gpgui-helper/src-tauri/src/updater.rs create mode 100644 crates/gpapi/src/process/gui_helper_launcher.rs create mode 100644 crates/gpapi/src/utils/checksum.rs diff --git a/Cargo.lock b/Cargo.lock index 139d879..f85ac8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,28 +126,6 @@ version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" -[[package]] -name = "async-stream" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - [[package]] name = "async-trait" version = "0.1.77" @@ -1475,6 +1453,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", + "sha256", "specta", "specta-macros", "tauri", @@ -1533,13 +1512,12 @@ name = "gpgui-helper" version = "2.0.0" dependencies = [ "anyhow", - "async-stream", - "base64 0.21.5", "clap", "compile-time", "downloader", "env_logger", "futures-util", + "gpapi", "log", "reqwest", "tauri", @@ -1561,6 +1539,7 @@ dependencies = [ "gpapi", "log", "openconnect", + "serde", "serde_json", "tokio", "tokio-util", @@ -3503,6 +3482,19 @@ dependencies = [ "digest", ] +[[package]] +name = "sha256" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18278f6a914fa3070aa316493f7d2ddfb9ac86ebc06fa3b83bffda487e9065b0" +dependencies = [ + "async-trait", + "bytes", + "hex", + "sha2", + "tokio", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 8a57d14..17124e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ dotenvy_macro = "0.15" compile-time = "0.2" serde_urlencoded = "0.7" md5="0.7" +sha256="1" # Tauri dependencies tauri = { version = "1.5" } diff --git a/apps/gpgui-helper/src-tauri/Cargo.toml b/apps/gpgui-helper/src-tauri/Cargo.toml index 4c5dc1a..839e1f6 100644 --- a/apps/gpgui-helper/src-tauri/Cargo.toml +++ b/apps/gpgui-helper/src-tauri/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true tauri-build = { version = "1.5", features = [] } [dependencies] +gpapi = { path = "../../../crates/gpapi", features = ["tauri"] } tauri = { workspace = true, features = ["window-start-dragging"] } tokio.workspace = true anyhow.workspace = true @@ -16,9 +17,10 @@ log.workspace = true clap.workspace = true compile-time.workspace = true env_logger.workspace = true -base64.workspace = true -async-stream = "0.3" futures-util.workspace = true downloader = "0.2" tempfile.workspace = true reqwest = { workspace = true, features = ["stream"] } + +[features] +custom-protocol = ["tauri/custom-protocol"] diff --git a/apps/gpgui-helper/src-tauri/src/app.rs b/apps/gpgui-helper/src-tauri/src/app.rs index a4a16ee..17820ff 100644 --- a/apps/gpgui-helper/src-tauri/src/app.rs +++ b/apps/gpgui-helper/src-tauri/src/app.rs @@ -1,34 +1,52 @@ -use std::{io::Write, time::Duration}; +use std::sync::Arc; -use futures_util::StreamExt; +use gpapi::utils::window::WindowExt; use log::info; -use tauri::{window::MenuHandle, Manager, Window}; -use tempfile::NamedTempFile; +use tauri::Manager; + +use crate::updater::{GuiUpdater, Installer, ProgressNotifier}; pub struct App { api_key: Vec, + gui_version: String, } impl App { - pub fn new(api_key: Vec) -> Self { - Self { api_key } + pub fn new(api_key: Vec, gui_version: &str) -> Self { + Self { + api_key, + gui_version: gui_version.to_string(), + } } pub fn run(&self) -> anyhow::Result<()> { + let gui_version = self.gui_version.clone(); + let api_key = self.api_key.clone(); + tauri::Builder::default() - .setup(|app| { - let win = app.get_window("main").unwrap(); - hide_menu(win.menu_handle()); + .setup(move |app| { + let win = app.get_window("main").expect("no main window"); + win.hide_menu(); - tauri::async_runtime::spawn(async move { - let win_clone = win.clone(); - download_gui(win.clone()).await; + let notifier = ProgressNotifier::new(win.clone()); + let installer = Installer::new(api_key); + let updater = Arc::new(GuiUpdater::new(gui_version, notifier, installer)); - win.listen("app://download", move |_event| { - tokio::spawn(download_gui(win_clone.clone())); - }); + let win_clone = win.clone(); + app.listen_global("app://update-done", move |_event| { + info!("Update done"); + let _ = win_clone.close(); }); + // Listen for the update event + win.listen("app://update", move |_event| { + let updater = Arc::clone(&updater); + tokio::spawn(async move { updater.update().await }); + }); + + // Update the GUI on startup + win.trigger("app://update", None); + Ok(()) }) .run(tauri::generate_context!())?; @@ -36,80 +54,3 @@ impl App { Ok(()) } } - -// Fix the bug that the menu bar is visible on Ubuntu 18.04 -fn hide_menu(menu_handle: MenuHandle) { - tokio::spawn(async move { - loop { - let menu_visible = menu_handle.is_visible().unwrap_or(false); - - if !menu_visible { - break; - } - - if menu_visible { - let _ = menu_handle.hide(); - tokio::time::sleep(Duration::from_millis(10)).await; - } - } - }); -} - -async fn download_gui(win: Window) { - let url = "https://github.com/yuezk/GlobalProtect-openconnect/releases/download/v2.0.0/globalprotect-openconnect_2.0.0_x86_64.bin.tar.gz"; - // let url = "https://free.nchc.org.tw/opensuse/distribution/leap/15.5/iso/openSUSE-Leap-15.5-DVD-x86_64-Build491.1-Media.iso"; - - let win_clone = win.clone(); - - match download(url, move |p| { - let _ = win.emit_all("app://download-progress", p); - }) - .await - { - Err(err) => { - info!("download error: {}", err); - let _ = win_clone.emit_all("app://download-error", ()); - } - Ok(file) => { - let path = file.into_temp_path(); - info!("download completed: {:?}", path); - // Close window after 300ms - tokio::time::sleep(Duration::from_millis(300 * 1000)).await; - info!("file: {:?}", path); - let _ = win_clone.close(); - } - } -} - -async fn download(url: &str, on_progress: T) -> anyhow::Result -where - T: Fn(Option) + Send + 'static, -{ - let res = reqwest::get(url).await?.error_for_status()?; - let content_length = res.content_length().unwrap_or(0); - - info!("content_length: {}", content_length); - - let mut current_length = 0; - let mut stream = res.bytes_stream(); - - let mut file = NamedTempFile::new()?; - - while let Some(item) = stream.next().await { - let chunk = item?; - let chunk_size = chunk.len() as u64; - - file.write_all(&chunk)?; - - current_length += chunk_size; - let progress = current_length as f64 / content_length as f64 * 100.0; - - if content_length > 0 { - on_progress(Some(progress)); - } else { - on_progress(None); - } - } - - Ok(file) -} diff --git a/apps/gpgui-helper/src-tauri/src/cli.rs b/apps/gpgui-helper/src-tauri/src/cli.rs index 8a8c0f0..c790485 100644 --- a/apps/gpgui-helper/src-tauri/src/cli.rs +++ b/apps/gpgui-helper/src-tauri/src/cli.rs @@ -1,5 +1,5 @@ -use base64::prelude::*; use clap::Parser; +use gpapi::utils::base64; use log::{info, LevelFilter}; use crate::app::App; @@ -9,27 +9,33 @@ const GP_API_KEY: &[u8; 32] = &[0; 32]; #[derive(Parser)] #[command(version = VERSION)] -struct Cli {} +struct Cli { + #[arg(long, help = "Read the API key from stdin")] + api_key_on_stdin: bool, + + #[arg(long, default_value = env!("CARGO_PKG_VERSION"), help = "The version of the GUI")] + gui_version: String, +} impl Cli { fn run(&self) -> anyhow::Result<()> { - #[cfg(debug_assertions)] - let api_key = GP_API_KEY.to_vec(); - #[cfg(not(debug_assertions))] let api_key = self.read_api_key()?; - - let app = App::new(api_key); + let app = App::new(api_key, &self.gui_version); app.run() } fn read_api_key(&self) -> anyhow::Result> { - let mut api_key = String::new(); - std::io::stdin().read_line(&mut api_key)?; + if self.api_key_on_stdin { + let mut api_key = String::new(); + std::io::stdin().read_line(&mut api_key)?; - let api_key = BASE64_STANDARD.decode(api_key.trim())?; + let api_key = base64::decode_to_vec(api_key.trim())?; - Ok(api_key) + Ok(api_key) + } else { + Ok(GP_API_KEY.to_vec()) + } } } diff --git a/apps/gpgui-helper/src-tauri/src/downloader.rs b/apps/gpgui-helper/src-tauri/src/downloader.rs new file mode 100644 index 0000000..bd04602 --- /dev/null +++ b/apps/gpgui-helper/src-tauri/src/downloader.rs @@ -0,0 +1,87 @@ +use std::io::Write; + +use anyhow::bail; +use futures_util::StreamExt; +use log::info; +use tempfile::NamedTempFile; +use tokio::sync::RwLock; + +type OnProgress = Box) + Send + Sync + 'static>; + +pub struct FileDownloader<'a> { + url: &'a str, + on_progress: RwLock>, +} + +impl<'a> FileDownloader<'a> { + pub fn new(url: &'a str) -> Self { + Self { + url, + on_progress: Default::default(), + } + } + + pub fn on_progress(&self, on_progress: T) + where + T: Fn(Option) + Send + Sync + 'static, + { + if let Ok(mut guard) = self.on_progress.try_write() { + *guard = Some(Box::new(on_progress)); + } else { + info!("Failed to acquire on_progress lock"); + } + } + + pub async fn download(&self) -> anyhow::Result { + let res = reqwest::get(self.url).await?.error_for_status()?; + let content_length = res.content_length().unwrap_or(0); + + info!("Content length: {}", content_length); + + let mut current_length = 0; + let mut stream = res.bytes_stream(); + + let mut file = NamedTempFile::new()?; + + while let Some(item) = stream.next().await { + let chunk = item?; + let chunk_size = chunk.len() as u64; + + file.write_all(&chunk)?; + + current_length += chunk_size; + let progress = current_length as f64 / content_length as f64 * 100.0; + + if let Some(on_progress) = &*self.on_progress.read().await { + let progress = if content_length > 0 { Some(progress) } else { None }; + + on_progress(progress); + } + } + + if content_length > 0 && current_length != content_length { + bail!("Download incomplete"); + } + + info!("Downloaded to: {:?}", file.path()); + + Ok(file) + } +} + +pub struct ChecksumFetcher<'a> { + url: &'a str, +} + +impl<'a> ChecksumFetcher<'a> { + pub fn new(url: &'a str) -> Self { + Self { url } + } + + pub async fn fetch(&self) -> anyhow::Result { + let res = reqwest::get(self.url).await?.error_for_status()?; + let checksum = res.text().await?.trim().to_string(); + + Ok(checksum) + } +} diff --git a/apps/gpgui-helper/src-tauri/src/lib.rs b/apps/gpgui-helper/src-tauri/src/lib.rs index fda83a5..4416c2f 100644 --- a/apps/gpgui-helper/src-tauri/src/lib.rs +++ b/apps/gpgui-helper/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ pub(crate) mod app; +pub(crate) mod downloader; +pub(crate) mod updater; pub mod cli; diff --git a/apps/gpgui-helper/src-tauri/src/updater.rs b/apps/gpgui-helper/src-tauri/src/updater.rs new file mode 100644 index 0000000..83275c3 --- /dev/null +++ b/apps/gpgui-helper/src-tauri/src/updater.rs @@ -0,0 +1,129 @@ +use std::sync::Arc; + +use gpapi::{ + service::request::UpdateGuiRequest, + utils::{checksum::verify_checksum, crypto::Crypto, endpoint::http_endpoint}, +}; +use log::{info, warn}; +use tauri::{Manager, Window}; + +use crate::downloader::{ChecksumFetcher, FileDownloader}; + +pub struct ProgressNotifier { + win: Window, +} + +impl ProgressNotifier { + pub fn new(win: Window) -> Self { + Self { win } + } + + fn notify(&self, progress: Option) { + let _ = self.win.emit_all("app://update-progress", progress); + } + + fn notify_error(&self) { + let _ = self.win.emit_all("app://update-error", ()); + } + + fn notify_done(&self) { + let _ = self.win.emit_and_trigger("app://update-done", ()); + } +} + +pub struct Installer { + crypto: Crypto, +} + +impl Installer { + pub fn new(api_key: Vec) -> Self { + Self { + crypto: Crypto::new(api_key), + } + } + + async fn install(&self, path: &str, checksum: &str) -> anyhow::Result<()> { + let service_endpoint = http_endpoint().await?; + + let request = UpdateGuiRequest { + path: path.to_string(), + checksum: checksum.to_string(), + }; + let payload = self.crypto.encrypt(&request)?; + + reqwest::Client::default() + .post(format!("{}/update-gui", service_endpoint)) + .body(payload) + .send() + .await? + .error_for_status()?; + + Ok(()) + } +} + +pub struct GuiUpdater { + version: String, + notifier: Arc, + installer: Installer, +} + +impl GuiUpdater { + pub fn new(version: String, notifier: ProgressNotifier, installer: Installer) -> Self { + Self { + version, + notifier: Arc::new(notifier), + installer, + } + } + + pub async fn update(&self) { + info!("Update GUI, version: {}", self.version); + + #[cfg(target_arch = "x86_64")] + let arch = "amd64"; + #[cfg(target_arch = "aarch64")] + let arch = "arm64"; + + let file_url = format!("https://github.com/yuezk/GlobalProtect-openconnect/releases/download/v{}/gpgui-linux-{}", self.version, arch); + let checksum_url = format!("{}.sha256", file_url); + + info!("Downloading file: {}", file_url); + + let dl = FileDownloader::new(&file_url); + let cf = ChecksumFetcher::new(&checksum_url); + let notifier = Arc::clone(&self.notifier); + + dl.on_progress(move |progress| notifier.notify(progress)); + + let res = tokio::try_join!(dl.download(), cf.fetch()); + + let (file, checksum) = match res { + Ok((file, checksum)) => (file, checksum), + Err(err) => { + warn!("Download error: {}", err); + self.notifier.notify_error(); + return; + } + }; + + let path = file.into_temp_path(); + let file_path = path.to_string_lossy(); + + if let Err(err) = verify_checksum(&file_path, &checksum) { + warn!("Checksum error: {}", err); + self.notifier.notify_error(); + return; + } + + info!("Checksum success"); + + if let Err(err) = self.installer.install(&file_path, &checksum).await { + warn!("Install error: {}", err); + self.notifier.notify_error(); + } else { + info!("Install success"); + self.notifier.notify_done(); + } + } +} diff --git a/apps/gpgui-helper/src-tauri/tauri.conf.json b/apps/gpgui-helper/src-tauri/tauri.conf.json index 153fd1a..1d5931e 100644 --- a/apps/gpgui-helper/src-tauri/tauri.conf.json +++ b/apps/gpgui-helper/src-tauri/tauri.conf.json @@ -2,8 +2,8 @@ "$schema": "../node_modules/@tauri-apps/cli/schema.json", "build": { "beforeDevCommand": "pnpm dev", - "beforeBuildCommand": "echo 'Skipping Vite build command...'", - "devPath": "http://localhost:1420", + "beforeBuildCommand": "pnpm build", + "devPath": "http://localhost:1421", "distDir": "../dist", "withGlobalTauri": false }, @@ -19,7 +19,7 @@ } }, "bundle": { - "active": true, + "active": false, "targets": "deb", "identifier": "com.yuezk.gpgui-helper", "icon": [ @@ -40,6 +40,10 @@ "resizable": true, "width": 500, "height": 100, + "minWidth": 500, + "minHeight": 100, + "maxWidth": 500, + "maxHeight": 100, "label": "main", "decorations": false } diff --git a/apps/gpgui-helper/src/components/App/App.tsx b/apps/gpgui-helper/src/components/App/App.tsx index 4de5d40..5170636 100644 --- a/apps/gpgui-helper/src/components/App/App.tsx +++ b/apps/gpgui-helper/src/components/App/App.tsx @@ -1,39 +1,43 @@ -import { - Box, - Button, - CssBaseline, - LinearProgress, - Typography, -} from "@mui/material"; +import { Box, Button, CssBaseline, LinearProgress, Typography } from "@mui/material"; import { appWindow } from "@tauri-apps/api/window"; +import logo from "../../assets/icon.svg"; +import { useEffect, useState } from "react"; import "./styles.css"; -import logo from "../../assets/icon.svg"; -import { useEffect, useState } from 'react'; +function useUpdateProgress() { + const [progress, setProgress] = useState(null); + + useEffect(() => { + const unlisten = appWindow.listen("app://update-progress", (event) => { + setProgress(event.payload as number); + }); + + return () => { + unlisten.then((unlisten) => unlisten()); + }; + }, []); + + return progress; +} export default function App() { const [error, setError] = useState(false); useEffect(() => { - const unlisten = appWindow.listen("app://download-error", () => { + const unlisten = appWindow.listen("app://update-error", () => { setError(true); }); return () => { unlisten.then((unlisten) => unlisten()); - } + }; }, []); - useEffect(() => { - const unlisten = appWindow.listen("app://download", () => { - setError(false); - }); - - return () => { - unlisten.then((unlisten) => unlisten()); - } - }, []); + const handleRetry = () => { + setError(false); + appWindow.emit("app://update"); + }; return ( <> @@ -54,7 +58,7 @@ export default function App() { data-tauri-drag-region /> - {error ? : } + {error ? : } @@ -63,17 +67,7 @@ export default function App() { } function DownloadIndicator() { - const [progress, setProgress] = useState(null); - - useEffect(() => { - const unlisten = appWindow.listen("app://download-progress", (event) => { - setProgress(event.payload as number); - }); - - return () => { - unlisten.then((unlisten) => unlisten()); - } - }, []); + const progress = useUpdateProgress(); return ( <> @@ -87,7 +81,7 @@ function DownloadIndicator() { ); } -function DownloadFailed() { +function DownloadFailed({ onRetry }: { onRetry: () => void }) { return ( <> @@ -98,7 +92,7 @@ function DownloadFailed() { variant="contained" color="primary" size="small" - onClick={() => appWindow.emit("app://download")} + onClick={onRetry} sx={{ textTransform: "none", }} @@ -121,10 +115,11 @@ function LinearProgressWithLabel(props: { value: number | null }) { value={value ?? 0} sx={{ py: 1.2, - '.MuiLinearProgress-bar': { - transition: "none" - } - }} /> + ".MuiLinearProgress-bar": { + transition: "none", + }, + }} + /> {value !== null && ( diff --git a/apps/gpgui-helper/vite.config.ts b/apps/gpgui-helper/vite.config.ts index 63c21a9..51bd39b 100644 --- a/apps/gpgui-helper/vite.config.ts +++ b/apps/gpgui-helper/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(async () => { clearScreen: false, // 2. tauri expects a fixed port, fail if that port is not available server: { - port: 1420, + port: 1421, strictPort: true, }, // 3. to make use of `TAURI_DEBUG` and other env variables diff --git a/apps/gpservice/Cargo.toml b/apps/gpservice/Cargo.toml index 1ac8ead..5a38524 100644 --- a/apps/gpservice/Cargo.toml +++ b/apps/gpservice/Cargo.toml @@ -13,6 +13,7 @@ tokio.workspace = true tokio-util.workspace = true axum = { workspace = true, features = ["ws"] } futures.workspace = true +serde.workspace = true serde_json.workspace = true env_logger.workspace = true log.workspace = true diff --git a/apps/gpservice/src/cli.rs b/apps/gpservice/src/cli.rs index 1141e91..309dc06 100644 --- a/apps/gpservice/src/cli.rs +++ b/apps/gpservice/src/cli.rs @@ -112,7 +112,7 @@ fn init_logger() -> Arc { let timestamp = buf.timestamp(); writeln!( buf, - "[{} {} {}] {}", + "[{} {} {}] {}", timestamp, record.level(), record.module_path().unwrap_or_default(), @@ -127,10 +127,8 @@ fn init_logger() -> Arc { async fn launch_gui(envs: Option>, api_key: Vec, mut minimized: bool) { loop { - let api_key_clone = api_key.clone(); - let gui_launcher = GuiLauncher::new() + let gui_launcher = GuiLauncher::new(env!("CARGO_PKG_VERSION"), &api_key) .envs(envs.clone()) - .api_key(api_key_clone) .minimized(minimized); match gui_launcher.launch().await { diff --git a/apps/gpservice/src/handlers.rs b/apps/gpservice/src/handlers.rs index cc36c44..1b2fa72 100644 --- a/apps/gpservice/src/handlers.rs +++ b/apps/gpservice/src/handlers.rs @@ -1,16 +1,23 @@ -use std::{borrow::Cow, ops::ControlFlow, sync::Arc}; +use std::{borrow::Cow, fs::Permissions, ops::ControlFlow, os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc}; +use anyhow::bail; use axum::{ body::Bytes, extract::{ ws::{self, CloseFrame, Message, WebSocket}, State, WebSocketUpgrade, }, + http::StatusCode, response::IntoResponse, }; use futures::{SinkExt, StreamExt}; -use gpapi::service::event::WsEvent; +use gpapi::{ + service::{event::WsEvent, request::UpdateGuiRequest}, + utils::checksum::verify_checksum, + GP_GUI_BINARY, +}; use log::{info, warn}; +use tokio::fs; use crate::ws_server::WsServerContext; @@ -26,12 +33,47 @@ pub(crate) async fn auth_data(State(ctx): State>, body: Str ctx.send_event(WsEvent::AuthData(body)).await; } -pub(crate) struct UpdateGuiPayload { - pub(crate) file: String, - pub(crate) checksum: String, +pub async fn update_gui(State(ctx): State>, body: Bytes) -> Result<(), StatusCode> { + let payload = match ctx.decrypt::(body.to_vec()) { + Ok(payload) => payload, + Err(err) => { + warn!("Failed to decrypt update payload: {}", err); + return Err(StatusCode::BAD_REQUEST); + } + }; + + info!("Update GUI: {:?}", payload); + let UpdateGuiRequest { path, checksum } = payload; + + info!("Verifying checksum"); + verify_checksum(&path, &checksum).map_err(|err| { + warn!("Failed to verify checksum: {}", err); + StatusCode::BAD_REQUEST + })?; + + info!("Installing GUI"); + install_gui(&path).await.map_err(|err| { + warn!("Failed to install GUI: {}", err); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(()) } -pub async fn update_gui(State(ctx): State>, body: Bytes) -> impl IntoResponse {} +async fn install_gui(src: &str) -> anyhow::Result<()> { + let path = PathBuf::from(GP_GUI_BINARY); + let Some(dir) = path.parent() else { + bail!("Failed to get parent directory of GUI binary"); + }; + + fs::create_dir_all(dir).await?; + + // Copy the file to the final location and make it executable + fs::copy(src, GP_GUI_BINARY).await?; + fs::set_permissions(GP_GUI_BINARY, Permissions::from_mode(0o755)).await?; + + Ok(()) +} pub(crate) async fn ws_handler(ws: WebSocketUpgrade, State(ctx): State>) -> impl IntoResponse { ws.on_upgrade(move |socket| handle_socket(socket, ctx)) diff --git a/apps/gpservice/src/ws_server.rs b/apps/gpservice/src/ws_server.rs index aa274b9..7bdb328 100644 --- a/apps/gpservice/src/ws_server.rs +++ b/apps/gpservice/src/ws_server.rs @@ -6,6 +6,7 @@ use gpapi::{ utils::{crypto::Crypto, lock_file::LockFile, redact::Redaction}, }; use log::{info, warn}; +use serde::de::DeserializeOwned; use tokio::{ net::TcpListener, sync::{mpsc, watch, RwLock}, @@ -38,6 +39,10 @@ impl WsServerContext { } } + pub fn decrypt(&self, encrypted: Vec) -> anyhow::Result { + self.crypto.decrypt(encrypted) + } + pub async fn send_event(&self, event: WsEvent) { let connections = self.connections.read().await; diff --git a/crates/gpapi/Cargo.toml b/crates/gpapi/Cargo.toml index c75fb8d..af91e7e 100644 --- a/crates/gpapi/Cargo.toml +++ b/crates/gpapi/Cargo.toml @@ -27,6 +27,7 @@ dotenvy_macro.workspace = true uzers.workspace = true serde_urlencoded.workspace = true md5.workspace = true +sha256.workspace = true tauri = { workspace = true, optional = true } clap = { workspace = true, optional = true } diff --git a/crates/gpapi/src/lib.rs b/crates/gpapi/src/lib.rs index e413989..83aaf0b 100644 --- a/crates/gpapi/src/lib.rs +++ b/crates/gpapi/src/lib.rs @@ -23,6 +23,8 @@ pub const GP_SERVICE_BINARY: &str = "/usr/bin/gpservice"; #[cfg(not(debug_assertions))] pub const GP_GUI_BINARY: &str = "/usr/bin/gpgui"; #[cfg(not(debug_assertions))] +pub const GP_GUI_HELPER_BINARY: &str = "/usr/bin/gpgui-helper"; +#[cfg(not(debug_assertions))] pub(crate) const GP_AUTH_BINARY: &str = "/usr/bin/gpauth"; #[cfg(debug_assertions)] @@ -32,4 +34,6 @@ pub const GP_SERVICE_BINARY: &str = dotenvy_macro::dotenv!("GP_SERVICE_BINARY"); #[cfg(debug_assertions)] pub const GP_GUI_BINARY: &str = dotenvy_macro::dotenv!("GP_GUI_BINARY"); #[cfg(debug_assertions)] +pub const GP_GUI_HELPER_BINARY: &str = dotenvy_macro::dotenv!("GP_GUI_HELPER_BINARY"); +#[cfg(debug_assertions)] pub(crate) const GP_AUTH_BINARY: &str = dotenvy_macro::dotenv!("GP_AUTH_BINARY"); diff --git a/crates/gpapi/src/process/command_traits.rs b/crates/gpapi/src/process/command_traits.rs index edda8d7..6bfbb18 100644 --- a/crates/gpapi/src/process/command_traits.rs +++ b/crates/gpapi/src/process/command_traits.rs @@ -12,11 +12,7 @@ pub trait CommandExt { impl CommandExt for Command { fn new_pkexec>(program: S) -> Command { let mut cmd = Command::new("pkexec"); - cmd - .arg("--disable-internal-agent") - .arg("--user") - .arg("root") - .arg(program); + cmd.arg("--user").arg("root").arg(program); cmd } diff --git a/crates/gpapi/src/process/gui_helper_launcher.rs b/crates/gpapi/src/process/gui_helper_launcher.rs new file mode 100644 index 0000000..6fe9e34 --- /dev/null +++ b/crates/gpapi/src/process/gui_helper_launcher.rs @@ -0,0 +1,68 @@ +use std::{collections::HashMap, path::PathBuf, process::Stdio}; + +use anyhow::bail; +use log::info; +use tokio::{io::AsyncWriteExt, process::Command}; + +use crate::{process::command_traits::CommandExt, utils, GP_GUI_HELPER_BINARY}; + +pub struct GuiHelperLauncher<'a> { + program: PathBuf, + envs: Option<&'a HashMap>, + api_key: &'a [u8], + gui_version: Option<&'a str>, +} + +impl<'a> GuiHelperLauncher<'a> { + pub fn new(api_key: &'a [u8]) -> Self { + Self { + program: GP_GUI_HELPER_BINARY.into(), + envs: None, + api_key, + gui_version: None, + } + } + + pub fn envs(mut self, envs: Option<&'a HashMap>) -> Self { + self.envs = envs; + self + } + + pub fn gui_version(mut self, version: Option<&'a str>) -> Self { + self.gui_version = version; + self + } + + pub async fn launch(&self) -> anyhow::Result<()> { + let mut cmd = Command::new(&self.program); + + if let Some(envs) = self.envs { + cmd.env_clear(); + cmd.envs(envs); + } + + cmd.arg("--api-key-on-stdin"); + + if let Some(gui_version) = self.gui_version { + cmd.arg("--gui-version").arg(gui_version); + } + + info!("Launching gpgui-helper"); + let mut non_root_cmd = cmd.into_non_root()?; + let mut child = non_root_cmd.kill_on_drop(true).stdin(Stdio::piped()).spawn()?; + let Some(mut stdin) = child.stdin.take() else { + bail!("Failed to open stdin"); + }; + + let api_key = utils::base64::encode(self.api_key); + tokio::spawn(async move { + stdin.write_all(api_key.as_bytes()).await.unwrap(); + drop(stdin); + }); + + let exit_status = child.wait().await?; + info!("gpgui-helper exited with: {}", exit_status); + + Ok(()) + } +} diff --git a/crates/gpapi/src/process/gui_launcher.rs b/crates/gpapi/src/process/gui_launcher.rs index 48bd166..c7e1f87 100644 --- a/crates/gpapi/src/process/gui_launcher.rs +++ b/crates/gpapi/src/process/gui_launcher.rs @@ -4,30 +4,28 @@ use std::{ process::{ExitStatus, Stdio}, }; +use anyhow::bail; +use log::info; use tokio::{io::AsyncWriteExt, process::Command}; -use crate::{utils::base64, GP_GUI_BINARY}; +use crate::{process::gui_helper_launcher::GuiHelperLauncher, utils::base64, GP_GUI_BINARY}; use super::command_traits::CommandExt; -pub struct GuiLauncher { +pub struct GuiLauncher<'a> { + version: &'a str, program: PathBuf, - api_key: Option>, + api_key: &'a [u8], minimized: bool, envs: Option>, } -impl Default for GuiLauncher { - fn default() -> Self { - Self::new() - } -} - -impl GuiLauncher { - pub fn new() -> Self { +impl<'a> GuiLauncher<'a> { + pub fn new(version: &'a str, api_key: &'a [u8]) -> Self { Self { + version, program: GP_GUI_BINARY.into(), - api_key: None, + api_key, minimized: false, envs: None, } @@ -38,17 +36,23 @@ impl GuiLauncher { self } - pub fn api_key(mut self, api_key: Vec) -> Self { - self.api_key = Some(api_key); - self - } - pub fn minimized(mut self, minimized: bool) -> Self { self.minimized = minimized; self } pub async fn launch(&self) -> anyhow::Result { + // Check if the program's version + if let Err(err) = self.check_version().await { + info!("Check version failed: {}", err); + // Download the program and replace the current one + self.download_program().await?; + } + + self.launch_program().await + } + + async fn launch_program(&self) -> anyhow::Result { let mut cmd = Command::new(&self.program); if let Some(envs) = &self.envs { @@ -56,33 +60,60 @@ impl GuiLauncher { cmd.envs(envs); } - if self.api_key.is_some() { - cmd.arg("--api-key-on-stdin"); - } + cmd.arg("--api-key-on-stdin"); if self.minimized { cmd.arg("--minimized"); } + info!("Launching gpgui"); let mut non_root_cmd = cmd.into_non_root()?; - let mut child = non_root_cmd.kill_on_drop(true).stdin(Stdio::piped()).spawn()?; + let Some(mut stdin) = child.stdin.take() else { + bail!("Failed to open stdin"); + }; - let mut stdin = child - .stdin - .take() - .ok_or_else(|| anyhow::anyhow!("Failed to open stdin"))?; - - if let Some(api_key) = &self.api_key { - let api_key = base64::encode(api_key); - tokio::spawn(async move { - stdin.write_all(api_key.as_bytes()).await.unwrap(); - drop(stdin); - }); - } + let api_key = base64::encode(self.api_key); + tokio::spawn(async move { + stdin.write_all(api_key.as_bytes()).await.unwrap(); + drop(stdin); + }); let exit_status = child.wait().await?; Ok(exit_status) } + + async fn check_version(&self) -> anyhow::Result<()> { + let cmd = Command::new(&self.program).arg("--version").output().await?; + let output = String::from_utf8_lossy(&cmd.stdout); + + // Version string: "gpgui 2.0.0 (2024-02-05)" + let Some(version) = output.split_whitespace().nth(1) else { + bail!("Failed to parse version: {}", output); + }; + + if version != self.version { + bail!("Version mismatch: expected {}, got {}", self.version, version); + } + + info!("Version check passed: {}", version); + + Ok(()) + } + + async fn download_program(&self) -> anyhow::Result<()> { + let gui_helper = GuiHelperLauncher::new(self.api_key); + + gui_helper + .envs(self.envs.as_ref()) + .gui_version(Some(self.version)) + .launch() + .await?; + + // Check the version again + self.check_version().await?; + + Ok(()) + } } diff --git a/crates/gpapi/src/process/mod.rs b/crates/gpapi/src/process/mod.rs index acb3555..5dbb18c 100644 --- a/crates/gpapi/src/process/mod.rs +++ b/crates/gpapi/src/process/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod command_traits; +pub(crate) mod gui_helper_launcher; pub mod auth_launcher; #[cfg(feature = "browser-auth")] diff --git a/crates/gpapi/src/service/request.rs b/crates/gpapi/src/service/request.rs index a457197..12e6308 100644 --- a/crates/gpapi/src/service/request.rs +++ b/crates/gpapi/src/service/request.rs @@ -135,3 +135,9 @@ pub enum WsRequest { Connect(Box), Disconnect(DisconnectRequest), } + +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateGuiRequest { + pub path: String, + pub checksum: String, +} diff --git a/crates/gpapi/src/utils/checksum.rs b/crates/gpapi/src/utils/checksum.rs new file mode 100644 index 0000000..6290040 --- /dev/null +++ b/crates/gpapi/src/utils/checksum.rs @@ -0,0 +1,14 @@ +use std::path::Path; + +use anyhow::bail; + +pub fn verify_checksum(path: &str, expected: &str) -> anyhow::Result<()> { + let file = Path::new(&path); + let checksum = sha256::try_digest(&file)?; + + if checksum != expected { + bail!("Checksum mismatch, expected: {}, actual: {}", expected, checksum); + } + + Ok(()) +} diff --git a/crates/gpapi/src/utils/mod.rs b/crates/gpapi/src/utils/mod.rs index e6ce4dd..62d6352 100644 --- a/crates/gpapi/src/utils/mod.rs +++ b/crates/gpapi/src/utils/mod.rs @@ -3,6 +3,7 @@ use reqwest::Url; pub(crate) mod xml; pub mod base64; +pub mod checksum; pub mod crypto; pub mod endpoint; pub mod env_file; diff --git a/crates/gpapi/src/utils/window.rs b/crates/gpapi/src/utils/window.rs index 35393ff..5e0b0a6 100644 --- a/crates/gpapi/src/utils/window.rs +++ b/crates/gpapi/src/utils/window.rs @@ -2,17 +2,22 @@ use std::{process::ExitStatus, time::Duration}; use anyhow::bail; use log::{info, warn}; -use tauri::{window::MenuHandle, Window}; +use tauri::Window; use tokio::process::Command; pub trait WindowExt { fn raise(&self) -> anyhow::Result<()>; + fn hide_menu(&self); } impl WindowExt for Window { fn raise(&self) -> anyhow::Result<()> { raise_window(self) } + + fn hide_menu(&self) { + hide_menu(self); + } } pub fn raise_window(win: &Window) -> anyhow::Result<()> { @@ -34,7 +39,8 @@ pub fn raise_window(win: &Window) -> anyhow::Result<()> { } // Calling window.show() on Windows will cause the menu to be shown. - hide_menu(win.menu_handle()); + // We need to hide it again. + hide_menu(win); Ok(()) } @@ -71,7 +77,9 @@ async fn wmctrl_try_raise_window(title: &str) -> anyhow::Result { Ok(exit_status) } -fn hide_menu(menu_handle: MenuHandle) { +fn hide_menu(win: &Window) { + let menu_handle = win.menu_handle(); + tokio::spawn(async move { loop { let menu_visible = menu_handle.is_visible().unwrap_or(false);