mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
group of bug fixes
This commit is contained in:
1 parent
cfc93fc97d
commit
bcd5eb9ebc
30 files changed
+973
-238
No files matched your search
+163
-16
@@ -15,6 +15,7 @@ use tauri::{Emitter, Manager};
|
||||
|
||||
const FOLDER_NAME: &str = "margin";
|
||||
const DICTIONARY_NAME: &str = "custom-dictionary.txt";
|
||||
const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive.file";
|
||||
const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file";
|
||||
const AUTH_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
@@ -58,6 +59,7 @@ pub struct Session {
|
||||
access_expiry: u64,
|
||||
email: Option<String>,
|
||||
folder_id: Option<String>,
|
||||
scopes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
@@ -68,6 +70,8 @@ struct BackupState {
|
||||
folder_id: Option<String>,
|
||||
last_backup: Option<u64>,
|
||||
#[serde(default)]
|
||||
scopes: Option<String>,
|
||||
#[serde(default)]
|
||||
files: HashMap<String, FileRecord>,
|
||||
}
|
||||
|
||||
@@ -84,6 +88,7 @@ pub struct Status {
|
||||
email: Option<String>,
|
||||
last_backup: Option<u64>,
|
||||
pending: bool,
|
||||
needs_reauth: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -92,6 +97,15 @@ pub struct RestoreResult {
|
||||
restored: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncOutcome {
|
||||
uploaded: usize,
|
||||
downloaded: usize,
|
||||
#[serde(flatten)]
|
||||
status: Status,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupOutcome {
|
||||
@@ -164,6 +178,13 @@ fn urlencode(s: &str) -> String {
|
||||
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
|
||||
}
|
||||
|
||||
fn has_drive_scope(scope: &str) -> bool {
|
||||
scope.split_whitespace().any(|s| s == DRIVE_SCOPE)
|
||||
}
|
||||
|
||||
const REAUTH_MESSAGE: &str =
|
||||
"margin needs the \"See, edit, create and delete only the specific Google Drive files you use with this app\" permission. Connect again and tick that box.";
|
||||
|
||||
fn drive_escape(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('\'', "\\'")
|
||||
}
|
||||
@@ -260,6 +281,8 @@ struct TokenResponse {
|
||||
refresh_token: Option<String>,
|
||||
#[serde(default)]
|
||||
expires_in: u64,
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -484,7 +507,29 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
|
||||
}
|
||||
let refresh = refresh.ok_or("Not connected to Google Drive.")?;
|
||||
let creds = load_credentials()?;
|
||||
let tokens = refresh_access_token(&creds, &refresh).await?;
|
||||
let tokens = match refresh_access_token(&creds, &refresh).await {
|
||||
Ok(tokens) => tokens,
|
||||
Err(e) => {
|
||||
if e.contains("invalid_grant") {
|
||||
forget_credentials(app, state);
|
||||
return Err(
|
||||
"Google Drive access has expired. Connect again to keep backing up.".to_string()
|
||||
);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if let Some(granted) = &tokens.scope {
|
||||
if !has_drive_scope(granted) {
|
||||
let mut session = state.0.lock().unwrap();
|
||||
session.scopes = Some(granted.clone());
|
||||
drop(session);
|
||||
let mut stored = load_state(app);
|
||||
stored.scopes = Some(granted.clone());
|
||||
let _ = save_state(app, &stored);
|
||||
return Err(REAUTH_MESSAGE.to_string());
|
||||
}
|
||||
}
|
||||
let access_token = tokens.access_token.clone();
|
||||
{
|
||||
let mut session = state.0.lock().unwrap();
|
||||
@@ -493,10 +538,18 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
|
||||
if let Some(rotated) = &tokens.refresh_token {
|
||||
session.refresh_token = Some(rotated.clone());
|
||||
}
|
||||
if let Some(granted) = &tokens.scope {
|
||||
session.scopes = Some(granted.clone());
|
||||
}
|
||||
}
|
||||
if let Some(rotated) = &tokens.refresh_token {
|
||||
if tokens.refresh_token.is_some() || tokens.scope.is_some() {
|
||||
let mut stored = load_state(app);
|
||||
stored.refresh_token = Some(rotated.clone());
|
||||
if let Some(rotated) = &tokens.refresh_token {
|
||||
stored.refresh_token = Some(rotated.clone());
|
||||
}
|
||||
if let Some(granted) = &tokens.scope {
|
||||
stored.scopes = Some(granted.clone());
|
||||
}
|
||||
let _ = save_state(app, &stored);
|
||||
}
|
||||
Ok(access_token)
|
||||
@@ -592,17 +645,19 @@ fn compute_pending(app: &tauri::AppHandle, stored: &BackupState) -> bool {
|
||||
}
|
||||
|
||||
fn status_inner(app: &tauri::AppHandle, state: &GDriveState) -> Status {
|
||||
let (connected, email) = {
|
||||
let (connected, email, scopes) = {
|
||||
let session = state.0.lock().unwrap();
|
||||
(session.refresh_token.is_some(), session.email.clone())
|
||||
(session.refresh_token.is_some(), session.email.clone(), session.scopes.clone())
|
||||
};
|
||||
let stored = load_state(app);
|
||||
let pending = connected && compute_pending(app, &stored);
|
||||
let granted = scopes.or_else(|| stored.scopes.clone());
|
||||
Status {
|
||||
connected,
|
||||
email: email.or(stored.email),
|
||||
last_backup: stored.last_backup,
|
||||
pending,
|
||||
needs_reauth: connected && granted.map(|s| !has_drive_scope(&s)).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,6 +668,21 @@ pub fn init_session(app: &tauri::AppHandle) {
|
||||
session.refresh_token = stored.refresh_token;
|
||||
session.email = stored.email;
|
||||
session.folder_id = stored.folder_id;
|
||||
session.scopes = stored.scopes;
|
||||
}
|
||||
|
||||
fn forget_credentials(app: &tauri::AppHandle, state: &GDriveState) {
|
||||
{
|
||||
let mut session = state.0.lock().unwrap();
|
||||
session.refresh_token = None;
|
||||
session.access_token = None;
|
||||
session.access_expiry = 0;
|
||||
session.scopes = None;
|
||||
}
|
||||
let mut stored = load_state(app);
|
||||
stored.refresh_token = None;
|
||||
stored.scopes = None;
|
||||
let _ = save_state(app, &stored);
|
||||
}
|
||||
|
||||
async fn complete_auth(
|
||||
@@ -631,6 +701,15 @@ async fn complete_auth(
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let tokens = exchange_code(&creds, &code, &redirect, &verifier).await?;
|
||||
let granted = tokens.scope.clone().unwrap_or_default();
|
||||
if !has_drive_scope(&granted) {
|
||||
let _ = HTTP
|
||||
.post("https://oauth2.googleapis.com/revoke")
|
||||
.form(&[("token", tokens.access_token.as_str())])
|
||||
.send()
|
||||
.await;
|
||||
return Err(REAUTH_MESSAGE.to_string());
|
||||
}
|
||||
let email = fetch_email(&tokens.access_token).await?;
|
||||
let folder_id = ensure_folder(&tokens.access_token).await?;
|
||||
|
||||
@@ -644,6 +723,7 @@ async fn complete_auth(
|
||||
session.access_expiry = now() + tokens.expires_in.saturating_sub(60);
|
||||
session.email = Some(email.clone());
|
||||
session.folder_id = Some(folder_id.clone());
|
||||
session.scopes = Some(granted.clone());
|
||||
}
|
||||
|
||||
let mut stored = load_state(app);
|
||||
@@ -652,6 +732,7 @@ async fn complete_auth(
|
||||
}
|
||||
stored.email = Some(email);
|
||||
stored.folder_id = Some(folder_id);
|
||||
stored.scopes = Some(granted);
|
||||
save_state(app, &stored)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -711,10 +792,12 @@ pub async fn gdrive_disconnect(app: tauri::AppHandle, state: tauri::State<'_, GD
|
||||
session.access_token = None;
|
||||
session.access_expiry = 0;
|
||||
session.email = None;
|
||||
session.scopes = None;
|
||||
}
|
||||
let mut stored = load_state(&app);
|
||||
stored.refresh_token = None;
|
||||
stored.email = None;
|
||||
stored.scopes = None;
|
||||
save_state(&app, &stored)?;
|
||||
Ok(status_inner(&app, &state))
|
||||
}
|
||||
@@ -724,12 +807,13 @@ pub async fn gdrive_status(app: tauri::AppHandle, state: tauri::State<'_, GDrive
|
||||
Ok(status_inner(&app, &state))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
|
||||
let access_token = valid_access_token(&app, &state).await?;
|
||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
||||
let files = collect_local_files(&app)?;
|
||||
let mut stored = load_state(&app);
|
||||
async fn push_local_files(
|
||||
app: &tauri::AppHandle,
|
||||
access_token: &str,
|
||||
folder_id: &str,
|
||||
stored: &mut BackupState,
|
||||
) -> Result<usize, String> {
|
||||
let files = collect_local_files(app)?;
|
||||
let mut uploaded = 0;
|
||||
for (name, bytes) in &files {
|
||||
let hash = hash_bytes(bytes);
|
||||
@@ -739,18 +823,27 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let drive_id = match &existing {
|
||||
Some(record) => Some(record.drive_id.clone()),
|
||||
None => find_file(&access_token, &folder_id, name).await?.map(|file| file.id),
|
||||
let drive_id = match existing.filter(|record| !record.drive_id.is_empty()) {
|
||||
Some(record) => Some(record.drive_id),
|
||||
None => find_file(access_token, folder_id, name).await?.map(|file| file.id),
|
||||
};
|
||||
let result = upload_file(&access_token, &folder_id, name, bytes, drive_id).await?;
|
||||
let result = upload_file(access_token, folder_id, name, bytes, drive_id).await?;
|
||||
stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id });
|
||||
uploaded += 1;
|
||||
}
|
||||
Ok(uploaded)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
|
||||
let access_token = valid_access_token(&app, &state).await?;
|
||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
||||
let mut stored = load_state(&app);
|
||||
let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?;
|
||||
if uploaded > 0 {
|
||||
stored.last_backup = Some(now());
|
||||
save_state(&app, &stored)?;
|
||||
}
|
||||
save_state(&app, &stored)?;
|
||||
Ok(BackupOutcome {
|
||||
uploaded,
|
||||
status: Status {
|
||||
@@ -758,6 +851,60 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
|
||||
email: stored.email.clone(),
|
||||
last_backup: stored.last_backup,
|
||||
pending: false,
|
||||
needs_reauth: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gdrive_sync(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<SyncOutcome, String> {
|
||||
let access_token = valid_access_token(&app, &state).await?;
|
||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
||||
let remote = list_in_folder(&access_token, &folder_id).await?;
|
||||
let mut stored = load_state(&app);
|
||||
let mut downloaded = 0;
|
||||
|
||||
for file in &remote {
|
||||
let is_dictionary = file.name == DICTIONARY_NAME;
|
||||
if !is_dictionary && !safe_book_name(&file.name) {
|
||||
continue;
|
||||
}
|
||||
let destination = if is_dictionary {
|
||||
dictionary_path(&app)?
|
||||
} else {
|
||||
crate::library::library_dir(&app)?.join(&file.name)
|
||||
};
|
||||
if destination.exists() {
|
||||
let record = stored
|
||||
.files
|
||||
.entry(file.name.clone())
|
||||
.or_insert_with(|| FileRecord { hash: String::new(), drive_id: file.id.clone() });
|
||||
record.drive_id = file.id.clone();
|
||||
continue;
|
||||
}
|
||||
let bytes = download_file(&access_token, &file.id).await?;
|
||||
crate::project::atomic_write(&destination, &bytes, false)?;
|
||||
stored.files.insert(
|
||||
file.name.clone(),
|
||||
FileRecord { hash: hash_bytes(&bytes), drive_id: file.id.clone() },
|
||||
);
|
||||
downloaded += 1;
|
||||
}
|
||||
|
||||
let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?;
|
||||
if uploaded > 0 || downloaded > 0 {
|
||||
stored.last_backup = Some(now());
|
||||
}
|
||||
save_state(&app, &stored)?;
|
||||
Ok(SyncOutcome {
|
||||
uploaded,
|
||||
downloaded,
|
||||
status: Status {
|
||||
connected: true,
|
||||
email: stored.email.clone(),
|
||||
last_backup: stored.last_backup,
|
||||
pending: false,
|
||||
needs_reauth: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+40
-3
@@ -97,6 +97,12 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
|
||||
app_submenu.insert(&settings, 3)?;
|
||||
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?;
|
||||
}
|
||||
if let Some(window) = find_submenu("Window") {
|
||||
let show_window = MenuItemBuilder::with_id("show-window", "Open Window")
|
||||
.accelerator("CmdOrCtrl+Shift+M")
|
||||
.build(handle)?;
|
||||
window.append_items(&[&PredefinedMenuItem::separator(handle)?, &show_window])?;
|
||||
}
|
||||
if let Some(view) = find_submenu("View") {
|
||||
let toggle_chapters = MenuItemBuilder::with_id("toggle-chapters", "Toggle Chapters")
|
||||
.accelerator("CmdOrCtrl+\\")
|
||||
@@ -177,10 +183,14 @@ pub fn run() {
|
||||
) {
|
||||
app.emit("menu-action", event.id().0.as_str()).ok();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if event.id().0.as_str() == "show-window" {
|
||||
open_main_window(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
builder
|
||||
let app = builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
epub::package_epub,
|
||||
epub::unzip_epub,
|
||||
@@ -200,9 +210,36 @@ pub fn run() {
|
||||
gdrive::gdrive_disconnect,
|
||||
gdrive::gdrive_status,
|
||||
gdrive::gdrive_backup,
|
||||
gdrive::gdrive_sync,
|
||||
gdrive::gdrive_restore,
|
||||
gdrive::gdrive_list_backups
|
||||
])
|
||||
.run(context)
|
||||
.expect("error while running margin");
|
||||
.build(context)
|
||||
.expect("error while building margin");
|
||||
|
||||
app.run(|_app, _event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
match &_event {
|
||||
tauri::RunEvent::ExitRequested { code: None, api, .. } => api.prevent_exit(),
|
||||
tauri::RunEvent::Reopen { has_visible_windows: false, .. } => open_main_window(_app),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn open_main_window(app: &tauri::AppHandle) {
|
||||
use tauri::Manager;
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
return;
|
||||
}
|
||||
let Some(config) = app.config().app.windows.first().cloned() else {
|
||||
return;
|
||||
};
|
||||
if let Ok(builder) = tauri::WebviewWindowBuilder::from_config(app, &config) {
|
||||
let _ = builder.build();
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
books.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
|
||||
Ok(books)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user