Skip to content

Commit 1093f6d

Browse files
committed
add auto updating
1 parent 385efed commit 1093f6d

9 files changed

Lines changed: 805 additions & 41 deletions

File tree

Cargo.lock

Lines changed: 546 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ block2 = "0.6.2"
1313
crossbeam-channel = "0.5.15"
1414
emojis = "0.8.0"
1515
global-hotkey = "0.7.0"
16+
hex = "0.4.3"
1617
iced = { version = "0.14.0", features = ["image", "tokio"] }
1718
icns = "0.3.1"
1819
image = { version = "0.25.9", features = ["tiff"] }
@@ -31,8 +32,11 @@ rayon = "1.11.0"
3132
rfd = "0.17.2"
3233
serde = { version = "1.0.228", features = ["derive"] }
3334
serde_json = "1.0.149"
35+
sha2 = "0.11.0"
36+
tempfile = "3.27.0"
3437
tokio = { version = "1.48.0", features = ["full"] }
3538
toml = "0.9.8"
3639
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
3740
tray-icon = "0.21.3"
3841
url = { version = "2.5.8", default-features = false }
42+
zip = "8.5.1"

src/app.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ pub enum SetConfigFields {
128128
PlaceHolder(String),
129129
SearchUrl(String),
130130
ClipboardHistory(bool),
131+
SetAutoUpdate(bool),
131132
HapticFeedback(bool),
132133
ShowMenubarIcon(bool),
133134
SetPage(MainPage),

src/app/pages/settings.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,19 @@ pub fn settings_page(config: Config) -> Element<'static, Message> {
129129
notice_item(theme.clone(), "If you want rustcast to start on login"),
130130
]);
131131

132+
let theme_clone = theme.clone();
133+
let auto_update = settings_item_row([
134+
settings_hint_text(theme.clone(), "Auto update"),
135+
checkbox(config.clone().auto_update)
136+
.style(move |_, _| settings_checkbox_style(&theme_clone))
137+
.on_toggle(move |input| Message::SetConfig(SetConfigFields::SetAutoUpdate(input)))
138+
.into(),
139+
notice_item(
140+
theme.clone(),
141+
"If rustcast should automatically update itself",
142+
),
143+
]);
144+
132145
let theme_clone = theme.clone();
133146
let haptic = Row::from_iter([
134147
settings_hint_text(theme.clone(), "Haptic feedback"),
@@ -422,6 +435,7 @@ pub fn settings_page(config: Config) -> Element<'static, Message> {
422435
search.into(),
423436
debounce.into(),
424437
start_at_login.into(),
438+
auto_update.into(),
425439
haptic.into(),
426440
tray_icon.into(),
427441
clipboard_history.into(),

src/app/tile.rs

Lines changed: 3 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod update;
44

55
use crate::app::apps::App;
66
use crate::app::{ArrowKey, Message, Move, Page};
7+
use crate::autoupdate::new_version_available;
78
use crate::clipboard::ClipBoardContentType;
89
use crate::config::{Config, Shelly};
910
use crate::debounce::Debouncer;
@@ -32,7 +33,6 @@ use tray_icon::TrayIcon;
3233

3334
use std::collections::HashMap;
3435
use std::fmt::Debug;
35-
use std::str::FromStr;
3636
use std::time::Duration;
3737

3838
/// This is a wrapper around the sender to disable dropping
@@ -597,46 +597,9 @@ fn handle_recipient() -> impl futures::Stream<Item = Message> {
597597

598598
fn handle_version_and_rankings() -> impl futures::Stream<Item = Message> {
599599
stream::channel(100, async |mut output| {
600-
let current_version = format!("\"{}\"", option_env!("APP_VERSION").unwrap_or(""));
601-
602-
if current_version.is_empty() {
603-
println!("empty version");
604-
return;
605-
}
606-
607-
let req = minreq::Request::new(
608-
minreq::Method::Get,
609-
"https://api.github.com/repos/RustCastLabs/rustcast/releases/latest",
610-
)
611-
.with_header("User-Agent", "rustcast-update-checker")
612-
.with_header("Accept", "application/vnd.github+json")
613-
.with_header("X-GitHub-Api-Version", "2022-11-28");
614-
615600
loop {
616-
let resp = req
617-
.clone()
618-
.send()
619-
.and_then(|x| x.as_str().map(serde_json::Value::from_str));
620-
621-
info!("Made a req for latest version");
622-
623-
if let Ok(Ok(val)) = resp {
624-
let new_ver = val
625-
.get("name")
626-
.map(|x| x.to_string())
627-
.unwrap_or("".to_string());
628-
629-
// new_ver is in the format "\"v0.0.0\""
630-
// note that it is encapsulated in double quotes
631-
if new_ver.trim() != current_version
632-
&& !new_ver.is_empty()
633-
&& new_ver.starts_with("\"v")
634-
{
635-
info!("new version available: {new_ver}");
636-
output.send(Message::UpdateAvailable).await.ok();
637-
}
638-
} else {
639-
warn!("Error getting resp");
601+
if new_version_available().is_some() {
602+
output.send(Message::UpdateAvailable).await.ok();
640603
}
641604
tokio::time::sleep(Duration::from_secs(30)).await;
642605
output.send(Message::SaveRanking).await.ok();

src/app/tile/update.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ use crate::app::menubar::menu_builder;
3131
use crate::app::menubar::menu_icon;
3232
use crate::app::tile::AppIndex;
3333
use crate::app::{Message, Page, tile::Tile};
34+
use crate::autoupdate::download_latest_app;
35+
use crate::autoupdate::relaunch_app;
3436
use crate::calculator::Expr;
3537
use crate::commands::Function;
3638
use crate::config::Config;
@@ -96,6 +98,13 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task<Message> {
9698

9799
Message::UpdateAvailable => {
98100
tile.update_available = true;
101+
102+
if tile.config.auto_update {
103+
thread::spawn(|| {
104+
download_latest_app().ok();
105+
relaunch_app();
106+
});
107+
}
99108
Task::done(Message::ReloadConfig)
100109
}
101110

@@ -796,6 +805,9 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task<Message> {
796805
SetConfigFields::HapticFeedback(haptic_feedback) => {
797806
final_config.haptic_feedback = haptic_feedback
798807
}
808+
SetConfigFields::SetAutoUpdate(au) => {
809+
final_config.auto_update = au;
810+
}
799811
SetConfigFields::ShowMenubarIcon(show) => final_config.show_trayicon = show,
800812
SetConfigFields::SetThemeFields(SetConfigThemeFields::Font(fnt)) => {
801813
final_config.theme.font = Some(fnt)

src/autoupdate.rs

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
use std::str::FromStr;
2+
3+
use log::{error, info};
4+
use sha2::{Digest, Sha256};
5+
6+
pub struct ReleaseInfo {
7+
pub version: String,
8+
pub zip_url: String,
9+
pub sha256: String,
10+
}
11+
12+
pub fn get_latest_release() -> Option<ReleaseInfo> {
13+
let req = minreq::Request::new(
14+
minreq::Method::Get,
15+
"https://api.github.com/repos/RustCastLabs/rustcast/releases/latest",
16+
)
17+
.with_header("User-Agent", "rustcast-update-checker")
18+
.with_header("Accept", "application/vnd.github+json")
19+
.with_header("X-GitHub-Api-Version", "2022-11-28");
20+
21+
let resp = req
22+
.send()
23+
.and_then(|x| x.as_str().map(serde_json::Value::from_str));
24+
25+
if let Ok(Ok(val)) = resp {
26+
let version = val.get("name")?.as_str()?.to_string();
27+
28+
let assets = val.get("assets")?.as_array()?;
29+
30+
let mut zip_url = None;
31+
let mut sha256 = None;
32+
33+
for asset in assets {
34+
let name = asset.get("name")?.as_str()?;
35+
let url = asset.get("browser_download_url")?.as_str()?.to_string();
36+
37+
if name == "Rustcast-universal-macos.app.zip" {
38+
zip_url = Some(url);
39+
40+
sha256 = asset
41+
.get("digest")
42+
.and_then(|d| d.as_str())
43+
.and_then(|d| d.strip_prefix("sha256:"))
44+
.map(|d| d.to_string());
45+
}
46+
}
47+
48+
Some(ReleaseInfo {
49+
version,
50+
zip_url: zip_url?,
51+
sha256: sha256?,
52+
})
53+
} else {
54+
None
55+
}
56+
}
57+
58+
pub fn new_version_available() -> Option<ReleaseInfo> {
59+
info!("Checking for new version");
60+
let info = get_latest_release()?;
61+
info!("Got latest info");
62+
let current = option_env!("APP_VERSION").unwrap_or("");
63+
64+
if info.version != current {
65+
Some(info)
66+
} else {
67+
None
68+
}
69+
}
70+
71+
pub fn verify_sha256(file_path: &std::path::Path, expected_hex: &str) -> std::io::Result<bool> {
72+
let bytes = std::fs::read(file_path)?;
73+
let digest = Sha256::digest(&bytes);
74+
let actual_hex = hex::encode(digest);
75+
Ok(actual_hex == expected_hex)
76+
}
77+
78+
pub fn download_latest_app() -> Result<std::path::PathBuf, ()> {
79+
let info = get_latest_release().ok_or_else(|| {
80+
error!("Could not get latest release info");
81+
})?;
82+
83+
info!("got latest release");
84+
85+
let tmp = tempfile::tempdir().map_err(|e| {
86+
error!("Could not create temporary directory: {e}");
87+
})?;
88+
89+
info!("created temp dir");
90+
91+
let zip_path = tmp.path().join("Rustcast-universal-macos.app.zip");
92+
93+
info!("zip path: {:?}", zip_path);
94+
let resp = minreq::get(&info.zip_url)
95+
.with_header("User-Agent", "rustcast-update-checker")
96+
.send()
97+
.map_err(|e| {
98+
error!("Could not download update: {e}");
99+
})?;
100+
101+
info!("downloaded zip");
102+
103+
std::fs::write(&zip_path, resp.as_bytes()).map_err(|e| {
104+
error!("Could not write zip to disk: {e}");
105+
})?;
106+
107+
info!("wrote zip to disk");
108+
109+
let ok = verify_sha256(&zip_path, &info.sha256).map_err(|e| {
110+
error!("Could not verify sha256: {e}");
111+
})?;
112+
113+
info!("verified sha256");
114+
115+
if !ok {
116+
error!("SHA256 mismatch — aborting update");
117+
return Err(());
118+
}
119+
120+
let zip_file = std::fs::File::open(&zip_path).map_err(|e| {
121+
error!("Could not open zip: {e}");
122+
})?;
123+
124+
info!("opened zip");
125+
126+
let mut archive = zip::ZipArchive::new(zip_file).map_err(|e| {
127+
error!("Could not read zip archive: {e}");
128+
})?;
129+
130+
info!("read zip archive. contents:");
131+
132+
archive.extract(tmp.path()).map_err(|e| {
133+
error!("Could not extract zip: {e}");
134+
})?;
135+
136+
if let Ok(entries) = std::fs::read_dir(tmp.path()) {
137+
for entry in entries.flatten() {
138+
info!(" extracted entry: {:?}", entry.file_name());
139+
}
140+
}
141+
142+
let extracted_app = tmp.path().join("target/release/macos/Rustcast.app");
143+
144+
info!("found extracted app at: {:?}", extracted_app);
145+
146+
let dest = get_app_path().ok_or_else(|| {
147+
error!("Could not determine current app path");
148+
})?;
149+
150+
info!("Installing update over {:?}", dest);
151+
152+
if dest.exists() {
153+
std::fs::remove_dir_all(&dest).map_err(|e| {
154+
error!("Could not remove existing app: {e}");
155+
})?;
156+
}
157+
158+
move_or_copy(&extracted_app, &dest).map_err(|e| {
159+
error!("Could not move app into place: {e}");
160+
})?;
161+
162+
info!("Successful update");
163+
164+
Ok(dest)
165+
}
166+
167+
fn move_or_copy(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
168+
match std::fs::rename(src, dst) {
169+
Ok(()) => Ok(()),
170+
Err(_) => {
171+
copy_dir_recursive(src, dst)?;
172+
std::fs::remove_dir_all(src)
173+
}
174+
}
175+
}
176+
177+
fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
178+
std::fs::create_dir_all(dst)?;
179+
for entry in std::fs::read_dir(src)? {
180+
let entry = entry?;
181+
let dst_path = dst.join(entry.file_name());
182+
if entry.file_type()?.is_dir() {
183+
copy_dir_recursive(&entry.path(), &dst_path)?;
184+
} else {
185+
std::fs::copy(entry.path(), dst_path)?;
186+
}
187+
}
188+
Ok(())
189+
}
190+
191+
pub fn relaunch_app() {
192+
let app_path = match get_app_path() {
193+
Some(p) => p,
194+
None => {
195+
error!("Could not determine current app path for relaunch");
196+
return;
197+
}
198+
};
199+
200+
match std::process::Command::new("open").arg(&app_path).spawn() {
201+
Ok(_) => {
202+
info!("Relaunching app at {:?}", app_path);
203+
std::thread::sleep(std::time::Duration::from_millis(500));
204+
std::process::exit(0);
205+
}
206+
Err(e) => {
207+
error!("Could not relaunch app: {e}");
208+
}
209+
}
210+
}
211+
212+
pub fn get_app_path() -> Option<std::path::PathBuf> {
213+
let exe = std::env::current_exe().ok()?;
214+
215+
let mut path = exe.as_path();
216+
loop {
217+
if path.extension().and_then(|e| e.to_str()) == Some("app") {
218+
return Some(path.to_path_buf());
219+
}
220+
path = path.parent()?;
221+
}
222+
}

src/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub struct Config {
3434
pub search_dirs: Vec<String>,
3535
pub log_path: String,
3636
pub debounce_delay: u64,
37+
pub auto_update: bool,
3738
}
3839

3940
impl Default for Config {
@@ -49,6 +50,7 @@ impl Default for Config {
4950
search_url: "https://duckduckgo.com/search?q=%s".to_string(),
5051
cbhist: true,
5152
haptic_feedback: false,
53+
auto_update: true,
5254
show_trayicon: true,
5355
main_page: MainPage::default(),
5456
search_dirs: vec!["~".to_string()],

0 commit comments

Comments
 (0)