mirror of
https://github.com/Stremio/stremio-shell-ng.git
synced 2026-04-19 09:52:02 +00:00
Performance Improvements. Updater. Signing.
This commit is contained in:
parent
ef8c26c93e
commit
44e4d68974
13 changed files with 1633 additions and 322 deletions
1064
Cargo.lock
generated
1064
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -33,6 +33,13 @@ win32job = "2"
|
|||
parse-display = "0.9"
|
||||
flume = "0.11"
|
||||
whoami = "1.5"
|
||||
anyhow = "1"
|
||||
semver = "1"
|
||||
sha2 = "0.10"
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "blocking"] }
|
||||
rand = "0.8"
|
||||
url = { version = "2", features = ["serde"] }
|
||||
|
||||
|
||||
[build-dependencies]
|
||||
winres = "0.1"
|
||||
|
|
|
|||
BIN
certificates/developer_id_Installer.p12
Normal file
BIN
certificates/developer_id_Installer.p12
Normal file
Binary file not shown.
BIN
certificates/developer_id_app.p12
Normal file
BIN
certificates/developer_id_app.p12
Normal file
Binary file not shown.
|
|
@ -50,6 +50,8 @@ WizardImageFile={#SourcePath}..\images\windows-installer.bmp
|
|||
WizardSmallImageFile={#SourcePath}..\images\windows-installer-header.bmp
|
||||
SetupIconFile={#SourcePath}..\images\stremio.ico
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName},0
|
||||
SignTool=stremiosign
|
||||
SignedUninstaller=yes
|
||||
|
||||
[Code]
|
||||
function InitializeSetup: Boolean;
|
||||
|
|
@ -159,11 +161,11 @@ Name: "assoctorrent"; Description: "Associate {#MyAppName} with .torrent files"
|
|||
|
||||
[Files]
|
||||
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
||||
Source: "{#MyAppExeLocation}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#SourcePath}..\mpv.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#SourcePath}..\bin\ffmpeg.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#SourcePath}..\bin\ffprobe.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#SourcePath}..\bin\stremio-runtime.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#MyAppExeLocation}"; DestDir: "{app}"; Flags: ignoreversion sign
|
||||
Source: "{#SourcePath}..\mpv.dll"; DestDir: "{app}"; Flags: ignoreversion sign
|
||||
Source: "{#SourcePath}..\bin\ffmpeg.exe"; DestDir: "{app}"; Flags: ignoreversion sign
|
||||
Source: "{#SourcePath}..\bin\ffprobe.exe"; DestDir: "{app}"; Flags: ignoreversion sign
|
||||
Source: "{#SourcePath}..\bin\stremio-runtime.exe"; DestDir: "{app}"; Flags: ignoreversion sign
|
||||
Source: "{#SourcePath}..\server.js"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Registry]
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ if not exist "%mypath%..\target\release\stremio-shell-ng.exe" (
|
|||
)
|
||||
|
||||
:: Compile the installer
|
||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "%mypath%Stremio.iss"
|
||||
:: "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "%mypath%Stremio.iss"
|
||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "/Sstremiosign=$qC:\Program Files (x86)\Windows Kits\10\bin\10.0.17763.0\x86\signtool.exe$q sign /f $q${{ github.workspace }}\certificates\smartcode-20211118-20241118.pfx$q /p ${{ secrets.WIN_CERT_PASSWORD }} /v $f" "%mypath%Stremio.iss"
|
||||
34
src/main.rs
34
src/main.rs
|
|
@ -1,24 +1,28 @@
|
|||
#![windows_subsystem = "windows"]
|
||||
#![cfg_attr(all(not(test), not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#[macro_use]
|
||||
extern crate bitflags;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::exit;
|
||||
use std::{io::Write, path::Path, process::exit};
|
||||
use url::Url;
|
||||
use whoami::username;
|
||||
|
||||
use clap::Parser;
|
||||
use native_windows_gui::{self as nwg, NativeUi};
|
||||
mod stremio_app;
|
||||
use crate::stremio_app::{stremio_server::StremioServer, MainWindow, PipeClient};
|
||||
|
||||
const DEV_ENDPOINT: &str = "http://127.0.0.1:11470";
|
||||
const WEB_ENDPOINT: &str = "https://app.strem.io/shell-v4.4/";
|
||||
const STA_ENDPOINT: &str = "https://staging.strem.io/";
|
||||
use crate::stremio_app::{
|
||||
constants::{DEV_ENDPOINT, IPC_PATH, STA_ENDPOINT, WEB_ENDPOINT},
|
||||
stremio_server::StremioServer,
|
||||
MainWindow, PipeClient,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(version)]
|
||||
struct Opt {
|
||||
command: Option<String>,
|
||||
#[clap(
|
||||
long,
|
||||
help = "Start the app only in system tray and keep the window hidden"
|
||||
)]
|
||||
start_hidden: bool,
|
||||
#[clap(long, help = "Enable dev tools when pressing F12")]
|
||||
dev_tools: bool,
|
||||
#[clap(long, help = "Disable the server and load the WebUI from localhost")]
|
||||
|
|
@ -27,6 +31,12 @@ struct Opt {
|
|||
staging: bool,
|
||||
#[clap(long, default_value = WEB_ENDPOINT, help = "Override the WebUI URL")]
|
||||
webui_url: String,
|
||||
#[clap(long, help = "Ovveride autoupdater endpoint")]
|
||||
autoupdater_endpoint: Option<Url>,
|
||||
#[clap(long, help = "Forces reinstalling current version")]
|
||||
force_update: bool,
|
||||
#[clap(long, help = "Check for RC updates")]
|
||||
release_candidate: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
|
@ -55,7 +65,7 @@ fn main() {
|
|||
};
|
||||
|
||||
// Single application IPC
|
||||
let mut commands_path = "//./pipe/com.stremio5.".to_string();
|
||||
let mut commands_path = IPC_PATH.to_string();
|
||||
// Append the username so it works per User
|
||||
commands_path.push_str(&username());
|
||||
let socket_path = Path::new(&commands_path);
|
||||
|
|
@ -83,6 +93,10 @@ fn main() {
|
|||
commands_path: Some(commands_path),
|
||||
webui_url,
|
||||
dev_tools: opt.development || opt.dev_tools,
|
||||
start_hidden: opt.start_hidden,
|
||||
autoupdater_endpoint: opt.autoupdater_endpoint,
|
||||
force_update: opt.force_update,
|
||||
release_candidate: opt.release_candidate,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("Failed to build UI");
|
||||
|
|
|
|||
|
|
@ -1,280 +1,367 @@
|
|||
use crate::stremio_app::PipeServer;
|
||||
use native_windows_derive::NwgUi;
|
||||
use native_windows_gui as nwg;
|
||||
use serde_json;
|
||||
use std::cell::RefCell;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::str;
|
||||
use std::thread;
|
||||
use winapi::um::winuser::WS_EX_TOPMOST;
|
||||
|
||||
use crate::stremio_app::ipc::{RPCRequest, RPCResponse};
|
||||
use crate::stremio_app::splash::SplashImage;
|
||||
use crate::stremio_app::stremio_player::Player;
|
||||
use crate::stremio_app::stremio_wevbiew::WebView;
|
||||
use crate::stremio_app::systray::SystemTray;
|
||||
use crate::stremio_app::window_helper::WindowStyle;
|
||||
|
||||
#[derive(Default, NwgUi)]
|
||||
pub struct MainWindow {
|
||||
pub command: String,
|
||||
pub commands_path: Option<String>,
|
||||
pub webui_url: String,
|
||||
pub dev_tools: bool,
|
||||
pub saved_window_style: RefCell<WindowStyle>,
|
||||
#[nwg_resource]
|
||||
pub embed: nwg::EmbedResource,
|
||||
#[nwg_resource(source_embed: Some(&data.embed), source_embed_str: Some("MAINICON"))]
|
||||
pub window_icon: nwg::Icon,
|
||||
#[nwg_control(icon: Some(&data.window_icon), title: "Stremio", flags: "MAIN_WINDOW|VISIBLE")]
|
||||
#[nwg_events( OnWindowClose: [Self::on_quit(SELF, EVT_DATA)], OnInit: [Self::on_init], OnPaint: [Self::on_paint], OnMinMaxInfo: [Self::on_min_max(SELF, EVT_DATA)], OnWindowMinimize: [Self::transmit_window_state_change], OnWindowMaximize: [Self::transmit_window_state_change] )]
|
||||
pub window: nwg::Window,
|
||||
#[nwg_partial(parent: window)]
|
||||
#[nwg_events((tray_exit, OnMenuItemSelected): [nwg::stop_thread_dispatch()], (tray_show_hide, OnMenuItemSelected): [Self::on_show_hide], (tray_topmost, OnMenuItemSelected): [Self::on_toggle_topmost]) ]
|
||||
pub tray: SystemTray,
|
||||
#[nwg_partial(parent: window)]
|
||||
pub webview: WebView,
|
||||
#[nwg_partial(parent: window)]
|
||||
pub player: Player,
|
||||
#[nwg_partial(parent: window)]
|
||||
pub splash_screen: SplashImage,
|
||||
#[nwg_control]
|
||||
#[nwg_events(OnNotice: [Self::on_toggle_fullscreen_notice] )]
|
||||
pub toggle_fullscreen_notice: nwg::Notice,
|
||||
#[nwg_control]
|
||||
#[nwg_events(OnNotice: [nwg::stop_thread_dispatch()] )]
|
||||
pub quit_notice: nwg::Notice,
|
||||
#[nwg_control]
|
||||
#[nwg_events(OnNotice: [Self::on_hide_splash_notice] )]
|
||||
pub hide_splash_notice: nwg::Notice,
|
||||
#[nwg_control]
|
||||
#[nwg_events(OnNotice: [Self::on_focus_notice] )]
|
||||
pub focus_notice: nwg::Notice,
|
||||
}
|
||||
|
||||
impl MainWindow {
|
||||
const MIN_WIDTH: i32 = 1000;
|
||||
const MIN_HEIGHT: i32 = 600;
|
||||
fn transmit_window_full_screen_change(&self, prevent_close: bool) {
|
||||
let web_channel = self.webview.channel.borrow();
|
||||
let (web_tx, _) = web_channel
|
||||
.as_ref()
|
||||
.expect("Cannont obtain communication channel for the Web UI");
|
||||
let web_tx_app = web_tx.clone();
|
||||
let full_screen = {
|
||||
self.saved_window_style
|
||||
.try_borrow()
|
||||
.ok()
|
||||
.map(|saved_style| saved_style.full_screen)
|
||||
};
|
||||
if let Some(full_screen) = full_screen {
|
||||
web_tx_app
|
||||
.send(RPCResponse::visibility_change(
|
||||
self.window.visible(),
|
||||
prevent_close as u32,
|
||||
full_screen,
|
||||
))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
fn transmit_window_state_change(&self) {
|
||||
if let (Some(hwnd), Ok(web_channel), Ok(style)) = (
|
||||
self.window.handle.hwnd(),
|
||||
self.webview.channel.try_borrow(),
|
||||
self.saved_window_style.try_borrow(),
|
||||
) {
|
||||
let state = style.clone().get_window_state(hwnd);
|
||||
drop(style);
|
||||
let (web_tx, _) = web_channel
|
||||
.as_ref()
|
||||
.expect("Cannont obtain communication channel for the Web UI");
|
||||
let web_tx_app = web_tx.clone();
|
||||
web_tx_app.send(RPCResponse::state_change(state)).ok();
|
||||
} else {
|
||||
eprintln!("Cannot obtain window handle or communication channel");
|
||||
}
|
||||
}
|
||||
fn on_init(&self) {
|
||||
self.webview.endpoint.set(self.webui_url.clone()).ok();
|
||||
self.webview.dev_tools.set(self.dev_tools).ok();
|
||||
if let Some(hwnd) = self.window.handle.hwnd() {
|
||||
if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() {
|
||||
saved_style.center_window(hwnd, Self::MIN_WIDTH, Self::MIN_HEIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
self.tray.tray_show_hide.set_checked(true);
|
||||
|
||||
let player_channel = self.player.channel.borrow();
|
||||
let (player_tx, player_rx) = player_channel
|
||||
.as_ref()
|
||||
.expect("Cannont obtain communication channel for the Player");
|
||||
let player_tx = player_tx.clone();
|
||||
let player_rx = player_rx.clone();
|
||||
|
||||
let web_channel = self.webview.channel.borrow();
|
||||
let (web_tx, web_rx) = web_channel
|
||||
.as_ref()
|
||||
.expect("Cannont obtain communication channel for the Web UI");
|
||||
let web_tx_player = web_tx.clone();
|
||||
let web_tx_web = web_tx.clone();
|
||||
let web_tx_arg = web_tx.clone();
|
||||
let web_rx = web_rx.clone();
|
||||
let command_clone = self.command.clone();
|
||||
|
||||
// Single application IPC
|
||||
let socket_path = Path::new(
|
||||
self.commands_path
|
||||
.as_ref()
|
||||
.expect("Cannot initialie the single application IPC"),
|
||||
);
|
||||
if let Ok(mut listener) = PipeServer::bind(socket_path) {
|
||||
thread::spawn(move || loop {
|
||||
if let Ok(mut stream) = listener.accept() {
|
||||
let mut buf = vec![];
|
||||
stream.read_to_end(&mut buf).ok();
|
||||
if let Ok(s) = str::from_utf8(&buf) {
|
||||
// ['open-media', url]
|
||||
web_tx_arg.send(RPCResponse::open_media(s.to_string())).ok();
|
||||
println!("{}", s);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Read message from player
|
||||
thread::spawn(move || loop {
|
||||
player_rx
|
||||
.iter()
|
||||
.map(|msg| web_tx_player.send(msg))
|
||||
.for_each(drop);
|
||||
}); // thread
|
||||
|
||||
let toggle_fullscreen_sender = self.toggle_fullscreen_notice.sender();
|
||||
let quit_sender = self.quit_notice.sender();
|
||||
let hide_splash_sender = self.hide_splash_notice.sender();
|
||||
let focus_sender = self.focus_notice.sender();
|
||||
thread::spawn(move || loop {
|
||||
if let Some(msg) = web_rx
|
||||
.recv()
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<RPCRequest>(&s).ok())
|
||||
{
|
||||
match msg.get_method() {
|
||||
// The handshake. Here we send some useful data to the WEB UI
|
||||
None if msg.is_handshake() => {
|
||||
web_tx_web.send(RPCResponse::get_handshake()).ok();
|
||||
}
|
||||
Some("win-set-visibility") => toggle_fullscreen_sender.notice(),
|
||||
Some("quit") => quit_sender.notice(),
|
||||
Some("app-ready") => {
|
||||
hide_splash_sender.notice();
|
||||
web_tx_web
|
||||
.send(RPCResponse::visibility_change(true, 1, false))
|
||||
.ok();
|
||||
let command_ref = command_clone.clone();
|
||||
if !command_ref.is_empty() {
|
||||
web_tx_web.send(RPCResponse::open_media(command_ref)).ok();
|
||||
}
|
||||
}
|
||||
Some("app-error") => {
|
||||
hide_splash_sender.notice();
|
||||
if let Some(arg) = msg.get_params() {
|
||||
// TODO: Make this modal dialog
|
||||
eprintln!("Web App Error: {}", arg);
|
||||
}
|
||||
}
|
||||
Some("open-external") => {
|
||||
if let Some(arg) = msg.get_params() {
|
||||
// FIXME: THIS IS NOT SAFE BY ANY MEANS
|
||||
// open::that("calc").ok(); does exactly that
|
||||
let arg = arg.as_str().unwrap_or("");
|
||||
let arg_lc = arg.to_lowercase();
|
||||
if arg_lc.starts_with("http://")
|
||||
|| arg_lc.starts_with("https://")
|
||||
|| arg_lc.starts_with("rtp://")
|
||||
|| arg_lc.starts_with("rtps://")
|
||||
|| arg_lc.starts_with("ftp://")
|
||||
|| arg_lc.starts_with("ipfs://")
|
||||
{
|
||||
open::that(arg).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("win-focus") => {
|
||||
focus_sender.notice();
|
||||
}
|
||||
Some(player_command) if player_command.starts_with("mpv-") => {
|
||||
let resp_json = serde_json::to_string(
|
||||
&msg.args.expect("Cannot have method without args"),
|
||||
)
|
||||
.expect("Cannot build response");
|
||||
player_tx.send(resp_json).ok();
|
||||
}
|
||||
Some(unknown) => {
|
||||
eprintln!("Unsupported command {}({:?})", unknown, msg.get_params())
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
} // recv
|
||||
}); // thread
|
||||
}
|
||||
fn on_min_max(&self, data: &nwg::EventData) {
|
||||
let data = data.on_min_max();
|
||||
data.set_min_size(Self::MIN_WIDTH, Self::MIN_HEIGHT);
|
||||
}
|
||||
fn on_paint(&self) {
|
||||
if self.splash_screen.visible() {
|
||||
self.splash_screen.resize(self.window.size());
|
||||
}
|
||||
}
|
||||
fn on_toggle_fullscreen_notice(&self) {
|
||||
if let Some(hwnd) = self.window.handle.hwnd() {
|
||||
if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() {
|
||||
saved_style.toggle_full_screen(hwnd);
|
||||
self.tray.tray_topmost.set_enabled(!saved_style.full_screen);
|
||||
self.tray
|
||||
.tray_topmost
|
||||
.set_checked((saved_style.ex_style as u32 & WS_EX_TOPMOST) == WS_EX_TOPMOST);
|
||||
drop(saved_style);
|
||||
self.transmit_window_full_screen_change(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on_hide_splash_notice(&self) {
|
||||
self.splash_screen.hide();
|
||||
}
|
||||
fn on_focus_notice(&self) {
|
||||
self.window.set_visible(true);
|
||||
if let Some(hwnd) = self.window.handle.hwnd() {
|
||||
if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() {
|
||||
saved_style.set_active(hwnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on_toggle_topmost(&self) {
|
||||
if let Some(hwnd) = self.window.handle.hwnd() {
|
||||
if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() {
|
||||
saved_style.toggle_topmost(hwnd);
|
||||
self.tray
|
||||
.tray_topmost
|
||||
.set_checked((saved_style.ex_style as u32 & WS_EX_TOPMOST) == WS_EX_TOPMOST);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on_show_hide(&self) {
|
||||
self.window.set_visible(!self.window.visible());
|
||||
self.tray.tray_show_hide.set_checked(self.window.visible());
|
||||
self.transmit_window_state_change();
|
||||
}
|
||||
fn on_quit(&self, data: &nwg::EventData) {
|
||||
if let nwg::EventData::OnWindowClose(data) = data {
|
||||
data.close(false);
|
||||
}
|
||||
self.window.set_visible(false);
|
||||
self.tray.tray_show_hide.set_checked(self.window.visible());
|
||||
self.transmit_window_full_screen_change(false);
|
||||
nwg::stop_thread_dispatch();
|
||||
}
|
||||
}
|
||||
use native_windows_derive::NwgUi;
|
||||
use native_windows_gui as nwg;
|
||||
use rand::Rng;
|
||||
use serde_json;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
process::{self, Command},
|
||||
str,
|
||||
sync::{Arc, Mutex},
|
||||
thread, time,
|
||||
};
|
||||
use url::Url;
|
||||
use winapi::um::winuser::WS_EX_TOPMOST;
|
||||
|
||||
use crate::stremio_app::{
|
||||
constants::{APP_NAME, UPDATE_ENDPOINT, UPDATE_INTERVAL, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH},
|
||||
ipc::{RPCRequest, RPCResponse},
|
||||
splash::SplashImage,
|
||||
stremio_player::Player,
|
||||
stremio_wevbiew::WebView,
|
||||
systray::SystemTray,
|
||||
updater,
|
||||
window_helper::WindowStyle,
|
||||
PipeServer,
|
||||
};
|
||||
|
||||
#[derive(Default, NwgUi)]
|
||||
pub struct MainWindow {
|
||||
pub command: String,
|
||||
pub commands_path: Option<String>,
|
||||
pub webui_url: String,
|
||||
pub dev_tools: bool,
|
||||
pub start_hidden: bool,
|
||||
pub autoupdater_endpoint: Option<Url>,
|
||||
pub force_update: bool,
|
||||
pub release_candidate: bool,
|
||||
pub autoupdater_setup_file: Arc<Mutex<Option<PathBuf>>>,
|
||||
pub saved_window_style: RefCell<WindowStyle>,
|
||||
#[nwg_resource]
|
||||
pub embed: nwg::EmbedResource,
|
||||
#[nwg_resource(source_embed: Some(&data.embed), source_embed_str: Some("MAINICON"))]
|
||||
pub window_icon: nwg::Icon,
|
||||
#[nwg_control(icon: Some(&data.window_icon), title: APP_NAME, flags: "MAIN_WINDOW")]
|
||||
#[nwg_events( OnWindowClose: [Self::on_quit(SELF, EVT_DATA)], OnInit: [Self::on_init], OnPaint: [Self::on_paint], OnMinMaxInfo: [Self::on_min_max(SELF, EVT_DATA)], OnWindowMinimize: [Self::transmit_window_state_change], OnWindowMaximize: [Self::transmit_window_state_change] )]
|
||||
pub window: nwg::Window,
|
||||
#[nwg_partial(parent: window)]
|
||||
#[nwg_events((tray_exit, OnMenuItemSelected): [nwg::stop_thread_dispatch()], (tray_show_hide, OnMenuItemSelected): [Self::on_show_hide], (tray_topmost, OnMenuItemSelected): [Self::on_toggle_topmost]) ]
|
||||
pub tray: SystemTray,
|
||||
#[nwg_partial(parent: window)]
|
||||
pub webview: WebView,
|
||||
#[nwg_partial(parent: window)]
|
||||
pub player: Player,
|
||||
#[nwg_partial(parent: window)]
|
||||
pub splash_screen: SplashImage,
|
||||
#[nwg_control]
|
||||
#[nwg_events(OnNotice: [Self::on_toggle_fullscreen_notice] )]
|
||||
pub toggle_fullscreen_notice: nwg::Notice,
|
||||
#[nwg_control]
|
||||
#[nwg_events(OnNotice: [nwg::stop_thread_dispatch()] )]
|
||||
pub quit_notice: nwg::Notice,
|
||||
#[nwg_control]
|
||||
#[nwg_events(OnNotice: [Self::on_hide_splash_notice] )]
|
||||
pub hide_splash_notice: nwg::Notice,
|
||||
#[nwg_control]
|
||||
#[nwg_events(OnNotice: [Self::on_focus_notice] )]
|
||||
pub focus_notice: nwg::Notice,
|
||||
}
|
||||
|
||||
impl MainWindow {
|
||||
fn transmit_window_full_screen_change(&self, prevent_close: bool) {
|
||||
let web_channel = self.webview.channel.borrow();
|
||||
let (web_tx, _) = web_channel
|
||||
.as_ref()
|
||||
.expect("Cannont obtain communication channel for the Web UI");
|
||||
let web_tx_app = web_tx.clone();
|
||||
let full_screen = {
|
||||
self.saved_window_style
|
||||
.try_borrow()
|
||||
.ok()
|
||||
.map(|saved_style| saved_style.full_screen)
|
||||
};
|
||||
if let Some(full_screen) = full_screen {
|
||||
web_tx_app
|
||||
.send(RPCResponse::visibility_change(
|
||||
self.window.visible(),
|
||||
prevent_close as u32,
|
||||
full_screen,
|
||||
))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
fn transmit_window_state_change(&self) {
|
||||
if let (Some(hwnd), Ok(web_channel), Ok(style)) = (
|
||||
self.window.handle.hwnd(),
|
||||
self.webview.channel.try_borrow(),
|
||||
self.saved_window_style.try_borrow(),
|
||||
) {
|
||||
let state = style.clone().get_window_state(hwnd);
|
||||
drop(style);
|
||||
let (web_tx, _) = web_channel
|
||||
.as_ref()
|
||||
.expect("Cannont obtain communication channel for the Web UI");
|
||||
let web_tx_app = web_tx.clone();
|
||||
web_tx_app.send(RPCResponse::state_change(state)).ok();
|
||||
} else {
|
||||
eprintln!("Cannot obtain window handle or communication channel");
|
||||
}
|
||||
}
|
||||
fn on_init(&self) {
|
||||
self.webview.endpoint.set(self.webui_url.clone()).ok();
|
||||
self.webview.dev_tools.set(self.dev_tools).ok();
|
||||
if let Some(hwnd) = self.window.handle.hwnd() {
|
||||
if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() {
|
||||
saved_style.center_window(hwnd, WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
self.window.set_visible(!self.start_hidden);
|
||||
self.tray.tray_show_hide.set_checked(!self.start_hidden);
|
||||
|
||||
let player_channel = self.player.channel.borrow();
|
||||
let (player_tx, player_rx) = player_channel
|
||||
.as_ref()
|
||||
.expect("Cannont obtain communication channel for the Player");
|
||||
let player_tx = player_tx.clone();
|
||||
let player_rx = player_rx.clone();
|
||||
|
||||
let web_channel = self.webview.channel.borrow();
|
||||
let (web_tx, web_rx) = web_channel
|
||||
.as_ref()
|
||||
.expect("Cannont obtain communication channel for the Web UI");
|
||||
let web_tx_player = web_tx.clone();
|
||||
let web_tx_web = web_tx.clone();
|
||||
let web_tx_arg = web_tx.clone();
|
||||
let web_tx_upd = web_tx.clone();
|
||||
let web_rx = web_rx.clone();
|
||||
let command_clone = self.command.clone();
|
||||
|
||||
// Single application IPC
|
||||
let socket_path = Path::new(
|
||||
self.commands_path
|
||||
.as_ref()
|
||||
.expect("Cannot initialie the single application IPC"),
|
||||
);
|
||||
|
||||
let autoupdater_endpoint = self.autoupdater_endpoint.clone();
|
||||
let force_update = self.force_update;
|
||||
let release_candidate = self.release_candidate;
|
||||
let autoupdater_setup_file = self.autoupdater_setup_file.clone();
|
||||
thread::spawn(move || loop {
|
||||
let current_version = env!("CARGO_PKG_VERSION")
|
||||
.parse()
|
||||
.expect("Should always be valid");
|
||||
let updater_endpoint = if let Some(ref endpoint) = autoupdater_endpoint {
|
||||
endpoint.clone()
|
||||
} else {
|
||||
let mut rng = rand::thread_rng();
|
||||
let index = rng.gen_range(0..UPDATE_ENDPOINT.len());
|
||||
let mut url = Url::parse(UPDATE_ENDPOINT[index]).unwrap();
|
||||
if release_candidate {
|
||||
url.query_pairs_mut().append_pair("rc", "true");
|
||||
}
|
||||
url
|
||||
};
|
||||
let updater = updater::Updater::new(current_version, &updater_endpoint, force_update);
|
||||
|
||||
match updater.autoupdate() {
|
||||
Ok(Some(update)) => {
|
||||
println!("New version ready to install v{}", update.version);
|
||||
let mut autoupdater_setup_file = autoupdater_setup_file.lock().unwrap();
|
||||
*autoupdater_setup_file = Some(update.file.clone());
|
||||
web_tx_upd.send(RPCResponse::update_available()).ok();
|
||||
}
|
||||
Ok(None) => println!("No new updates found"),
|
||||
Err(e) => eprintln!("Failed to fetch updates: {e}"),
|
||||
}
|
||||
|
||||
thread::sleep(time::Duration::from_secs(UPDATE_INTERVAL));
|
||||
}); // thread
|
||||
|
||||
if let Ok(mut listener) = PipeServer::bind(socket_path) {
|
||||
thread::spawn(move || loop {
|
||||
if let Ok(mut stream) = listener.accept() {
|
||||
let mut buf = vec![];
|
||||
stream.read_to_end(&mut buf).ok();
|
||||
if let Ok(s) = str::from_utf8(&buf) {
|
||||
// ['open-media', url]
|
||||
web_tx_arg.send(RPCResponse::open_media(s.to_string())).ok();
|
||||
println!("{}", s);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Read message from player
|
||||
thread::spawn(move || loop {
|
||||
player_rx
|
||||
.iter()
|
||||
.map(|msg| web_tx_player.send(msg))
|
||||
.for_each(drop);
|
||||
}); // thread
|
||||
|
||||
let toggle_fullscreen_sender = self.toggle_fullscreen_notice.sender();
|
||||
let quit_sender = self.quit_notice.sender();
|
||||
let hide_splash_sender = self.hide_splash_notice.sender();
|
||||
let focus_sender = self.focus_notice.sender();
|
||||
let autoupdater_setup_mutex = self.autoupdater_setup_file.clone();
|
||||
thread::spawn(move || loop {
|
||||
if let Some(msg) = web_rx
|
||||
.recv()
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<RPCRequest>(&s).ok())
|
||||
{
|
||||
match msg.get_method() {
|
||||
// The handshake. Here we send some useful data to the WEB UI
|
||||
None if msg.is_handshake() => {
|
||||
web_tx_web.send(RPCResponse::get_handshake()).ok();
|
||||
}
|
||||
Some("win-set-visibility") => toggle_fullscreen_sender.notice(),
|
||||
Some("quit") => quit_sender.notice(),
|
||||
Some("app-ready") => {
|
||||
hide_splash_sender.notice();
|
||||
web_tx_web
|
||||
.send(RPCResponse::visibility_change(true, 1, false))
|
||||
.ok();
|
||||
let command_ref = command_clone.clone();
|
||||
if !command_ref.is_empty() {
|
||||
web_tx_web.send(RPCResponse::open_media(command_ref)).ok();
|
||||
}
|
||||
}
|
||||
Some("app-error") => {
|
||||
hide_splash_sender.notice();
|
||||
if let Some(arg) = msg.get_params() {
|
||||
// TODO: Make this modal dialog
|
||||
eprintln!("Web App Error: {}", arg);
|
||||
}
|
||||
}
|
||||
Some("open-external") => {
|
||||
if let Some(arg) = msg.get_params() {
|
||||
// FIXME: THIS IS NOT SAFE BY ANY MEANS
|
||||
// open::that("calc").ok(); does exactly that
|
||||
let arg = arg.as_str().unwrap_or("");
|
||||
let arg_lc = arg.to_lowercase();
|
||||
if arg_lc.starts_with("http://")
|
||||
|| arg_lc.starts_with("https://")
|
||||
|| arg_lc.starts_with("rtp://")
|
||||
|| arg_lc.starts_with("rtps://")
|
||||
|| arg_lc.starts_with("ftp://")
|
||||
|| arg_lc.starts_with("ipfs://")
|
||||
{
|
||||
open::that(arg).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("win-focus") => {
|
||||
focus_sender.notice();
|
||||
}
|
||||
Some("autoupdater-notif-clicked") => {
|
||||
// We've shown the "Update Available" notification
|
||||
// and the user clicked on "Restart And Update"
|
||||
let autoupdater_setup_file =
|
||||
autoupdater_setup_mutex.lock().unwrap().clone();
|
||||
match autoupdater_setup_file {
|
||||
Some(file_path) => {
|
||||
println!("Running the setup at {:?}", file_path);
|
||||
|
||||
let command = Command::new(file_path)
|
||||
.args([
|
||||
"/SILENT",
|
||||
"/NOCANCEL",
|
||||
"/FORCECLOSEAPPLICATIONS",
|
||||
"/TASKS=runapp",
|
||||
])
|
||||
.stdout(process::Stdio::null())
|
||||
.stderr(process::Stdio::null())
|
||||
.spawn();
|
||||
|
||||
match command {
|
||||
Ok(process) => {
|
||||
println!("Updater started. (PID {:?})", process.id());
|
||||
quit_sender.notice();
|
||||
}
|
||||
Err(err) => eprintln!("Updater couldn't be started: {err}"),
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
println!("Cannot obtain the setup file path");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(player_command) if player_command.starts_with("mpv-") => {
|
||||
let resp_json = serde_json::to_string(
|
||||
&msg.args.expect("Cannot have method without args"),
|
||||
)
|
||||
.expect("Cannot build response");
|
||||
player_tx.send(resp_json).ok();
|
||||
}
|
||||
Some(unknown) => {
|
||||
eprintln!("Unsupported command {}({:?})", unknown, msg.get_params())
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
} // recv
|
||||
}); // thread
|
||||
}
|
||||
fn on_min_max(&self, data: &nwg::EventData) {
|
||||
let data = data.on_min_max();
|
||||
data.set_min_size(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT);
|
||||
}
|
||||
fn on_paint(&self) {
|
||||
if self.splash_screen.visible() {
|
||||
self.splash_screen.resize(self.window.size());
|
||||
} else {
|
||||
self.webview.fit_to_window(self.window.handle.hwnd());
|
||||
}
|
||||
}
|
||||
fn on_toggle_fullscreen_notice(&self) {
|
||||
if let Some(hwnd) = self.window.handle.hwnd() {
|
||||
if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() {
|
||||
saved_style.toggle_full_screen(hwnd);
|
||||
self.tray.tray_topmost.set_enabled(!saved_style.full_screen);
|
||||
self.tray
|
||||
.tray_topmost
|
||||
.set_checked((saved_style.ex_style as u32 & WS_EX_TOPMOST) == WS_EX_TOPMOST);
|
||||
self.transmit_window_full_screen_change(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on_hide_splash_notice(&self) {
|
||||
self.splash_screen.hide();
|
||||
}
|
||||
fn on_focus_notice(&self) {
|
||||
self.window.set_visible(true);
|
||||
if let Some(hwnd) = self.window.handle.hwnd() {
|
||||
if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() {
|
||||
saved_style.set_active(hwnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on_toggle_topmost(&self) {
|
||||
if let Some(hwnd) = self.window.handle.hwnd() {
|
||||
if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() {
|
||||
saved_style.toggle_topmost(hwnd);
|
||||
self.tray
|
||||
.tray_topmost
|
||||
.set_checked((saved_style.ex_style as u32 & WS_EX_TOPMOST) == WS_EX_TOPMOST);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on_show_hide(&self) {
|
||||
self.window.set_visible(!self.window.visible());
|
||||
self.tray.tray_show_hide.set_checked(self.window.visible());
|
||||
self.transmit_window_state_change();
|
||||
}
|
||||
fn on_quit(&self, data: &nwg::EventData) {
|
||||
if let nwg::EventData::OnWindowClose(data) = data {
|
||||
data.close(false);
|
||||
}
|
||||
self.window.set_visible(false);
|
||||
self.tray.tray_show_hide.set_checked(self.window.visible());
|
||||
self.transmit_window_full_screen_change(false);
|
||||
// Terminates the app regardless if the user is set exit on window closed or not
|
||||
// nwg::stop_thread_dispatch();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
src/stremio_app/constants.rs
Normal file
13
src/stremio_app/constants.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
pub const APP_NAME: &str = "Stremio";
|
||||
pub const IPC_PATH: &str = "//./pipe/com.stremio5.";
|
||||
pub const DEV_ENDPOINT: &str = "http://127.0.0.1:11470";
|
||||
pub const WEB_ENDPOINT: &str = "https://app.strem.io/shell-v4.4/";
|
||||
pub const STA_ENDPOINT: &str = "https://staging.strem.io/";
|
||||
pub const WINDOW_MIN_WIDTH: i32 = 1000;
|
||||
pub const WINDOW_MIN_HEIGHT: i32 = 600;
|
||||
pub const UPDATE_INTERVAL: u64 = 12 * 60 * 60;
|
||||
pub const UPDATE_ENDPOINT: [&str; 3] = [
|
||||
"https://www.strem.io/updater/check?product=stremio-shell-ng",
|
||||
"https://www.stremio.com/updater/check?product=stremio-shell-ng",
|
||||
"https://www.stremio.net/updater/check?product=stremio-shell-ng",
|
||||
];
|
||||
|
|
@ -102,4 +102,7 @@ impl RPCResponse {
|
|||
pub fn open_media(url: String) -> String {
|
||||
Self::response_message(Some(json!(["open-media", url])))
|
||||
}
|
||||
pub fn update_available() -> String {
|
||||
Self::response_message(Some(json!(["autoupdater-show-notif"])))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,3 +10,5 @@ pub mod splash;
|
|||
pub mod systray;
|
||||
pub mod window_helper;
|
||||
pub use named_pipe::{PipeClient, PipeServer};
|
||||
pub mod constants;
|
||||
pub mod updater;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ use winapi::um::winuser::{GetClientRect, WM_SETFOCUS};
|
|||
pub struct WebView {
|
||||
pub endpoint: Rc<OnceCell<String>>,
|
||||
pub dev_tools: Rc<OnceCell<bool>>,
|
||||
controller: Rc<OnceCell<Controller>>,
|
||||
pub controller: Rc<OnceCell<Controller>>,
|
||||
pub channel: ipc::Channel,
|
||||
notice: nwg::Notice,
|
||||
compute: RefCell<Option<thread::JoinHandle<()>>>,
|
||||
|
|
@ -26,17 +26,25 @@ pub struct WebView {
|
|||
}
|
||||
|
||||
impl WebView {
|
||||
fn resize_to_window_bounds_and_show(controller: Option<&Controller>, hwnd: Option<HWND>) {
|
||||
pub fn fit_to_window(&self, hwnd: Option<HWND>) {
|
||||
if let Some(hwnd) = hwnd {
|
||||
unsafe {
|
||||
let mut rect = mem::zeroed();
|
||||
GetClientRect(hwnd, &mut rect);
|
||||
self.controller
|
||||
.get()
|
||||
.and_then(|controller| controller.put_bounds(rect).ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resize_to_window_bounds(controller: Option<&Controller>, hwnd: Option<HWND>) {
|
||||
if let (Some(controller), Some(hwnd)) = (controller, hwnd) {
|
||||
unsafe {
|
||||
let mut rect = mem::zeroed();
|
||||
GetClientRect(hwnd, &mut rect);
|
||||
controller.put_bounds(rect).ok();
|
||||
}
|
||||
controller.put_is_visible(true).ok();
|
||||
controller
|
||||
.move_focus(webview2::MoveFocusReason::Programmatic)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -62,7 +70,7 @@ impl PartialUi for WebView {
|
|||
let endpoint = data.endpoint.clone();
|
||||
let dev_tools = data.dev_tools.clone();
|
||||
let result = webview2::EnvironmentBuilder::new()
|
||||
.with_additional_browser_arguments("--disable-web-security --disable-gpu --autoplay-policy=no-user-gesture-required")
|
||||
.with_additional_browser_arguments("--disable-web-security --autoplay-policy=no-user-gesture-required --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection")
|
||||
.build(move |env| {
|
||||
env.expect("Cannot obtain webview environment")
|
||||
.create_controller(hwnd, move |controller| {
|
||||
|
|
@ -114,7 +122,12 @@ impl PartialUi for WebView {
|
|||
Ok(())
|
||||
}).expect("Cannot add D&D handler");
|
||||
|
||||
WebView::resize_to_window_bounds_and_show(Some(&controller), Some(hwnd));
|
||||
WebView::resize_to_window_bounds(Some(&controller), Some(hwnd));
|
||||
controller.put_is_visible(true).ok();
|
||||
controller
|
||||
.move_focus(webview2::MoveFocusReason::Programmatic)
|
||||
.ok();
|
||||
|
||||
controller_clone
|
||||
.set(controller)
|
||||
.expect("Cannot update the controller");
|
||||
|
|
@ -160,14 +173,10 @@ impl PartialUi for WebView {
|
|||
&self,
|
||||
evt: nwg::Event,
|
||||
_evt_data: &nwg::EventData,
|
||||
handle: nwg::ControlHandle,
|
||||
_handle: nwg::ControlHandle,
|
||||
) {
|
||||
use nwg::Event as E;
|
||||
match evt {
|
||||
E::OnPaint => {
|
||||
// TODO: somehow debounce this
|
||||
WebView::resize_to_window_bounds_and_show(self.controller.get(), handle.hwnd());
|
||||
}
|
||||
E::OnWindowMinimize => {
|
||||
if let Some(controller) = self.controller.get() {
|
||||
controller.put_is_visible(false).ok();
|
||||
|
|
|
|||
135
src/stremio_app/updater.rs
Normal file
135
src/stremio_app/updater.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
use std::{
|
||||
io::{Read, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use semver::{Version, VersionReq};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Update {
|
||||
/// The new version that we update to
|
||||
pub version: Version,
|
||||
pub file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Updater {
|
||||
pub current_version: Version,
|
||||
pub next_version: VersionReq,
|
||||
pub endpoint: Url,
|
||||
pub force_update: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateResponse {
|
||||
version_desc: Url,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileItem {
|
||||
// name: String,
|
||||
pub url: Url,
|
||||
pub checksum: String,
|
||||
os: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Descriptor {
|
||||
version: String,
|
||||
files: Vec<FileItem>,
|
||||
}
|
||||
|
||||
impl Updater {
|
||||
pub fn new(current_version: Version, updater_endpoint: &Url, force_update: bool) -> Self {
|
||||
Self {
|
||||
next_version: VersionReq::parse(&format!(">{current_version}"))
|
||||
.expect("Version is type-safe"),
|
||||
current_version,
|
||||
endpoint: updater_endpoint.clone(),
|
||||
force_update,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the latest update from the update server.
|
||||
pub fn autoupdate(&self) -> Result<Option<Update>, anyhow::Error> {
|
||||
// Check for updates
|
||||
println!("Fetching updates for v{}", self.current_version);
|
||||
println!("Using updater endpoint {}", &self.endpoint);
|
||||
let update_response =
|
||||
reqwest::blocking::get(self.endpoint.clone())?.json::<UpdateResponse>()?;
|
||||
let update_descriptor =
|
||||
reqwest::blocking::get(update_response.version_desc)?.json::<Descriptor>()?;
|
||||
|
||||
if update_response.version != update_descriptor.version {
|
||||
return Err(anyhow!("Mismatched update versions"));
|
||||
}
|
||||
let installer = update_descriptor
|
||||
.files
|
||||
.iter()
|
||||
.find(|file_item| file_item.os == std::env::consts::OS)
|
||||
.context("No update for this OS")?;
|
||||
let version = Version::parse(update_descriptor.version.as_str())?;
|
||||
if !self.force_update && !self.next_version.matches(&version) {
|
||||
return Err(anyhow!(
|
||||
"No new releases found that match the requirement of `{}`",
|
||||
self.next_version
|
||||
));
|
||||
}
|
||||
println!("Found update v{}", version);
|
||||
|
||||
// Download the new setup file
|
||||
let mut installer_response = reqwest::blocking::get(installer.url.clone())?;
|
||||
let size = installer_response.content_length();
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut sha256 = Sha256::new();
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let file_name = std::path::Path::new(installer.url.path())
|
||||
.file_name()
|
||||
.context("Invalid file name")?
|
||||
.to_str()
|
||||
.context("The path is not valid UTF-8")?
|
||||
.to_string();
|
||||
|
||||
let dest = temp_dir.join(file_name);
|
||||
|
||||
println!("Downloading {} to {}", installer.url, dest.display());
|
||||
|
||||
let mut chunk = [0u8; 8192];
|
||||
let mut file = std::fs::File::create(&dest)?;
|
||||
loop {
|
||||
let chunk_size = installer_response.read(&mut chunk)?;
|
||||
if chunk_size == 0 {
|
||||
break;
|
||||
}
|
||||
sha256.update(&chunk[..chunk_size]);
|
||||
file.write_all(&chunk[..chunk_size])?;
|
||||
if let Some(size) = size {
|
||||
downloaded += chunk_size as u64;
|
||||
print!("\rProgress: {}%", downloaded * 100 / size);
|
||||
} else {
|
||||
print!(".");
|
||||
}
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
println!();
|
||||
let actual_sha256 = format!("{:x}", sha256.finalize());
|
||||
if actual_sha256 != installer.checksum {
|
||||
std::fs::remove_file(dest)?;
|
||||
return Err(anyhow::anyhow!("Checksum verification failed"));
|
||||
}
|
||||
println!("Checksum verified.");
|
||||
|
||||
let update = Some(Update {
|
||||
version,
|
||||
file: dest,
|
||||
});
|
||||
Ok(update)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue