Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ env_logger = "0.11"
dirs = "5.0"
sha2 = "0.10"
hex = "0.4"
rand = "0.8"

[dev-dependencies]
# Use same dependencies for tests
Expand Down
15 changes: 12 additions & 3 deletions src-tauri/src/gmail.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use crate::keychain::KeychainManager;
use chrono::Utc;
use reqwest::Client;
use reqwest::{Client, StatusCode};
use serde::Deserialize;
use sha2::{Digest, Sha256};

pub const RATE_LIMIT_ERROR: &str = "RATE_LIMIT_ERROR";

const GMAIL_SCOPES: &[&str] = &["https://www.googleapis.com/auth/gmail.readonly"];
const OAUTH_REDIRECT_URI: &str = "http://localhost:8234/callback";

Expand Down Expand Up @@ -238,13 +240,20 @@ impl GmailClient {

let list_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is%3Aunread%20newer_than:1d&maxResults=25".to_string();

let list_resp: MessageListResponse = self
let response = self
.http_client
.get(&list_url)
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await
.map_err(|e| format!("Gmail API request failed: {}", e))?
.map_err(|e| format!("Gmail API request failed: {}", e))?;

// Check for rate limit error (HTTP 429)
if response.status() == StatusCode::TOO_MANY_REQUESTS {
return Err(RATE_LIMIT_ERROR.to_string());
}

let list_resp: MessageListResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse message list: {}", e))?;
Expand Down
54 changes: 54 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ use types::{AppState, ClipboardConfig, CodeEntry, PrivacyPreferences};
const DEFAULT_POLL_INTERVAL_MS: u64 = 8000;
const NOTIFICATION_COOLDOWN_MS: u64 = 3000;
const DEFAULT_CLIPBOARD_TIMEOUT_SECONDS: u64 = 30;
const BASE_BACKOFF_MS: u64 = 60_000; // 1 minute base backoff
const MAX_BACKOFF_MS: u64 = 300_000; // 5 minutes max backoff

fn get_poll_interval() -> u64 {
std::env::var("OTPBAR_POLL_INTERVAL_MS")
Expand Down Expand Up @@ -93,6 +95,8 @@ fn main() {
timeout_seconds: clipboard_timeout,
}),
privacy_preferences: tokio::sync::Mutex::new(loaded_prefs),
backoff_until: tokio::sync::Mutex::new(None),
backoff_logged: tokio::sync::Mutex::new(false),
})
.setup(|app| {
setup_menubar(app)?;
Expand Down Expand Up @@ -227,15 +231,35 @@ async fn start_polling(handle: &tauri::AppHandle) {

let handle_clone = handle.clone();
tauri::async_runtime::spawn(async move {
let mut retry_count = 0u32;

loop {
tokio::time::sleep(tokio::time::Duration::from_millis(poll_interval)).await;

let state: State<AppState> = handle_clone.state();

// Check if we're in backoff period
let now = chrono::Utc::now().timestamp_millis();
let backoff_until = *state.backoff_until.lock().await;
if let Some(until) = backoff_until {
if now < until {
// Still in backoff period, skip this poll
continue;
}
// Backoff period expired, clear it
*state.backoff_until.lock().await = None;
*state.backoff_logged.lock().await = false;
log::info!("Rate limit backoff expired, resuming normal polling");
}

let mut client_guard = state.gmail_client.lock().await;

if let Some(client) = client_guard.as_mut() {
match client.get_recent_unread().await {
Ok(messages) => {
// Reset retry count on success
retry_count = 0;

for msg in messages {
let text = format!("{} {} {}", msg.subject, msg.snippet, msg.body);
if let Some(otp_code) = otp::extract_otp(&text) {
Expand Down Expand Up @@ -315,6 +339,30 @@ async fn start_polling(handle: &tauri::AppHandle) {
}
}
}
Err(e) if e == gmail::RATE_LIMIT_ERROR => {
// Rate limit error - implement exponential backoff with jitter
let backoff_ms = calculate_backoff(retry_count);
retry_count = retry_count.saturating_add(1);

// Add jitter: +/- 25% of backoff time
let jitter_ms = (backoff_ms as f64 * 0.25 * rand::random::<f64>()) as i64
- (backoff_ms as i64 / 4);
let backoff_until = now + backoff_ms as i64 + jitter_ms;

*state.backoff_until.lock().await = Some(backoff_until);

// Only log once per backoff period
let mut logged = state.backoff_logged.lock().await;
if !*logged {
let backoff_seconds = (backoff_until - now) / 1000;
log::warn!(
"Gmail API rate limit exceeded. Backing off for ~{} seconds. Retry count: {}",
backoff_seconds,
retry_count
);
*logged = true;
}
}
Err(e) => {
log::error!("Gmail polling failed: {}", e);
}
Expand All @@ -324,6 +372,12 @@ async fn start_polling(handle: &tauri::AppHandle) {
});
}

/// Calculate exponential backoff with a maximum cap
fn calculate_backoff(retry_count: u32) -> u64 {
let backoff = BASE_BACKOFF_MS * 2u64.pow(retry_count.min(6));
backoff.min(MAX_BACKOFF_MS)
}

fn extract_sender_name(from: &str) -> String {
let re = regex::Regex::new(r"^([^<@]+)").expect("Sender name regex should be valid");
if let Some(caps) = re.captures(from) {
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,6 @@ pub struct AppState {
pub is_polling: tokio::sync::Mutex<bool>,
pub clipboard_config: tokio::sync::Mutex<ClipboardConfig>,
pub privacy_preferences: tokio::sync::Mutex<PrivacyPreferences>,
pub backoff_until: tokio::sync::Mutex<Option<i64>>,
pub backoff_logged: tokio::sync::Mutex<bool>,
}
14 changes: 9 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ function App() {
</div>
<button
onClick={handleRetry}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<RefreshCw size={16} />
Try Again
Expand Down Expand Up @@ -167,14 +167,16 @@ function App() {
<>
<button
onClick={handleShowSettings}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors group"
aria-label="Open settings"
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded px-1.5 py-1"
>
<Settings size={12} className="opacity-60 group-hover:opacity-100 transition-opacity" />
<span>Settings</span>
</button>
<button
onClick={handleShowPrivacy}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors group"
aria-label="Open privacy dashboard"
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded px-1.5 py-1"
>
<Shield size={12} className="opacity-60 group-hover:opacity-100 transition-opacity" />
<span>Privacy</span>
Expand All @@ -184,7 +186,8 @@ function App() {
{isAuthenticated && (
<button
onClick={handleLogout}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors group"
aria-label="Logout from Gmail"
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded px-1.5 py-1"
>
<LogOut size={12} className="opacity-60 group-hover:opacity-100 transition-opacity" />
<span>Logout</span>
Expand All @@ -194,7 +197,8 @@ function App() {

<button
onClick={handleQuit}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors group ml-auto"
aria-label="Quit application"
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors group ml-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded px-1.5 py-1"
>
<Power size={12} className="opacity-60 group-hover:opacity-100 transition-opacity" />
<span>Quit</span>
Expand Down
1 change: 1 addition & 0 deletions src/components/Auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const Auth: React.FC<AuthProps> = ({ onAuthSuccess }) => {
<button
onClick={handleLogin}
disabled={loading}
aria-label="Sign in with Google to connect your account"
className={cn(
"group relative w-full flex items-center justify-center gap-2 px-4 py-2.5",
"bg-foreground/90 text-background text-sm font-medium rounded-lg",
Expand Down
10 changes: 10 additions & 0 deletions src/components/CodeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,22 @@ export const CodeCard: React.FC<CodeCardProps> = ({ entry }) => {
return (
<div
onClick={handleCopy}
role="button"
tabIndex={0}
aria-label={`Copy OTP code from ${entry.provider || entry.sender}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCopy();
}
}}
className={cn(
"group relative flex items-center justify-between p-3 rounded-lg",
"bg-card/60 border border-border/30",
"cursor-pointer transition-all duration-200",
"hover:bg-card hover:border-border/50",
"shadow-inner-glow",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
copied && "bg-status-active/10 border-status-active/30"
)}
>
Expand Down
47 changes: 10 additions & 37 deletions src/components/PrivacyDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import {
Shield,
FolderOpen,
Key,
Clock,
Expand All @@ -11,28 +10,8 @@ import {
AlertCircle
} from 'lucide-react';
import { cn } from '../lib/utils';

interface PrivacyData {
dataLocations: {
configPath: string;
historyPath: string;
keychainItems: string[];
};
permissions: {
scopes: string[];
hasAccessToken: boolean;
hasRefreshToken: boolean;
};
activity: {
totalCodes: number;
lastActivity: number | null;
historyRetention: number;
};
retention: {
maxHistorySize: number;
currentSize: number;
};
}
import { tauriApi } from '../lib/tauri';
import type { PrivacyData } from '../types/tauri';

export const PrivacyDashboard: React.FC<{
onBack: () => void;
Expand All @@ -56,7 +35,7 @@ export const PrivacyDashboard: React.FC<{
try {
setLoading(true);
setError(null);
const data = await window.__OTPBAR__.getPrivacyData();
const data = await tauriApi.getPrivacyData();
setPrivacyData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load privacy data');
Expand All @@ -71,10 +50,10 @@ export const PrivacyDashboard: React.FC<{
setError(null);
setSuccessMessage(null);

await window.__OTPBAR__.clearHistory();
await tauriApi.clearHistory();

// Refresh privacy data after clearing
const updatedData = await window.__OTPBAR__.getPrivacyData();
const updatedData = await tauriApi.getPrivacyData();
setPrivacyData(updatedData);

// Show success message
Expand Down Expand Up @@ -158,21 +137,15 @@ export const PrivacyDashboard: React.FC<{
return (
<div className="flex flex-col h-full w-full overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/40 shrink-0">
<div className="flex items-center gap-3 px-4 py-3 glass-panel border-b border-border/40 shrink-0">
<button
onClick={onBack}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
aria-label="Back to main view"
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-secondary/80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<ArrowLeft size={14} />
<span>Back</span>
<ArrowLeft size={16} className="text-foreground/80" />
</button>
<div className="flex-1" />
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-secondary/80 flex items-center justify-center border border-border/50">
<Shield size={14} className="text-muted-foreground" strokeWidth={2} />
</div>
<h1 className="text-sm font-semibold text-foreground/90">Privacy Dashboard</h1>
</div>
<h1 className="font-semibold text-sm text-foreground/90">Privacy Dashboard</h1>
</div>

{/* Content */}
Expand Down
Loading