Implement gpgui-helper

This commit is contained in:
Kevin Yue
2024-02-19 08:36:10 -05:00
parent 426989350e
commit c31c7c46d5
25 changed files with 557 additions and 222 deletions

View File

@@ -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"]

View File

@@ -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<u8>,
gui_version: String,
}
impl App {
pub fn new(api_key: Vec<u8>) -> Self {
Self { api_key }
pub fn new(api_key: Vec<u8>, 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<T>(url: &str, on_progress: T) -> anyhow::Result<NamedTempFile>
where
T: Fn(Option<f64>) + 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)
}

View File

@@ -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<Vec<u8>> {
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())
}
}
}

View File

@@ -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<dyn Fn(Option<f64>) + Send + Sync + 'static>;
pub struct FileDownloader<'a> {
url: &'a str,
on_progress: RwLock<Option<OnProgress>>,
}
impl<'a> FileDownloader<'a> {
pub fn new(url: &'a str) -> Self {
Self {
url,
on_progress: Default::default(),
}
}
pub fn on_progress<T>(&self, on_progress: T)
where
T: Fn(Option<f64>) + 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<NamedTempFile> {
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<String> {
let res = reqwest::get(self.url).await?.error_for_status()?;
let checksum = res.text().await?.trim().to_string();
Ok(checksum)
}
}

View File

@@ -1,3 +1,5 @@
pub(crate) mod app;
pub(crate) mod downloader;
pub(crate) mod updater;
pub mod cli;

View File

@@ -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<f64>) {
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<u8>) -> 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<ProgressNotifier>,
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();
}
}
}

View File

@@ -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
}

View File

@@ -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<number | null>(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
/>
<Box flex={1} ml={2}>
{error ? <DownloadFailed /> : <DownloadIndicator />}
{error ? <DownloadFailed onRetry={handleRetry} /> : <DownloadIndicator />}
</Box>
</Box>
</Box>
@@ -63,17 +67,7 @@ export default function App() {
}
function DownloadIndicator() {
const [progress, setProgress] = useState<number | null>(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 (
<>
<Typography variant="h1" fontSize="1rem" data-tauri-drag-region>
@@ -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",
},
}}
/>
</Box>
{value !== null && (
<Box sx={{ minWidth: 35, textAlign: "right", ml: 1 }}>

View File

@@ -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