mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
Add sync-limit config UI + Restart button, fix restart teardown
- Expose the sync limit in the config panel: "Fetch all mail" checkbox (sync_limit=0) + a max-per-folder number input. - Config fields are editable while the bridge runs; Save persists anytime, a Restart button (stop+start) applies changes. - Fix the in-process restart: stop() was fire-and-forget and left IMAP/SMTP tasks holding their ports, so the next start failed to bind and aborted the new syncer before it listed folders (empty store). stop() now aborts all three tasks and awaits full teardown.
This commit is contained in:
@@ -27,6 +27,7 @@ pub struct BridgeHandle {
|
||||
log_tx: broadcast::Sender<String>,
|
||||
started_at: Option<std::time::Instant>,
|
||||
store: Option<Arc<MailStore>>,
|
||||
task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl BridgeHandle {
|
||||
@@ -38,6 +39,7 @@ impl BridgeHandle {
|
||||
log_tx,
|
||||
started_at: None,
|
||||
store: None,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +142,7 @@ impl BridgeHandle {
|
||||
let sync_limit = config.sync_limit;
|
||||
let pw = config.bridge_password.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let task = tokio::spawn(async move {
|
||||
let imap_tls = tls_acceptor.clone();
|
||||
let smtp_tls = tls_acceptor;
|
||||
|
||||
@@ -154,37 +156,52 @@ impl BridgeHandle {
|
||||
sync_limit,
|
||||
shutdown_sync_rx,
|
||||
));
|
||||
let imap_handle = tokio::spawn(imap::serve(
|
||||
let mut imap_handle = tokio::spawn(imap::serve(
|
||||
imap_port,
|
||||
store.clone(),
|
||||
backend.clone(),
|
||||
imap_tls,
|
||||
pw.clone(),
|
||||
));
|
||||
let smtp_handle = tokio::spawn(smtp::serve(smtp_port, backend.clone(), smtp_tls, pw));
|
||||
let mut smtp_handle = tokio::spawn(smtp::serve(smtp_port, backend.clone(), smtp_tls, pw));
|
||||
|
||||
tokio::select! {
|
||||
_ = rx => {
|
||||
let _ = log_tx.send("Bridge shutting down...".to_string());
|
||||
let _ = shutdown_sync_tx.send(true);
|
||||
}
|
||||
r = imap_handle => {
|
||||
r = &mut imap_handle => {
|
||||
if let Err(e) = r {
|
||||
let _ = log_tx.send(format!("IMAP server error: {e}"));
|
||||
}
|
||||
}
|
||||
r = smtp_handle => {
|
||||
r = &mut smtp_handle => {
|
||||
if let Err(e) = r {
|
||||
let _ = log_tx.send(format!("SMTP server error: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tear everything down and wait for it, so ports are released before
|
||||
// a subsequent start rebinds them. Skip awaiting a handle that already
|
||||
// resolved in the select above (re-polling it would panic).
|
||||
syncer_handle.abort();
|
||||
imap_handle.abort();
|
||||
smtp_handle.abort();
|
||||
if !syncer_handle.is_finished() {
|
||||
let _ = syncer_handle.await;
|
||||
}
|
||||
if !imap_handle.is_finished() {
|
||||
let _ = imap_handle.await;
|
||||
}
|
||||
if !smtp_handle.is_finished() {
|
||||
let _ = smtp_handle.await;
|
||||
}
|
||||
*status.write().await = BridgeStatus::Stopped;
|
||||
let _ = log_tx.send("Bridge stopped".to_string());
|
||||
});
|
||||
|
||||
self.task = Some(task);
|
||||
*self.status.write().await = BridgeStatus::Running;
|
||||
self.emit_log("Bridge is running");
|
||||
Ok(())
|
||||
@@ -194,6 +211,9 @@ impl BridgeHandle {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(task) = self.task.take() {
|
||||
let _ = task.await;
|
||||
}
|
||||
self.started_at = None;
|
||||
}
|
||||
|
||||
|
||||
@@ -435,6 +435,46 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group label.checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.form-group label.checkbox-field input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
flex: none;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group label.checkbox-field span {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--on-surface);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--on-surface-variant);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
button {
|
||||
padding: 9px 18px;
|
||||
|
||||
@@ -86,7 +86,9 @@ function App() {
|
||||
<ConfigPanel
|
||||
config={bridge.config}
|
||||
status={bridge.status}
|
||||
loading={bridge.loading}
|
||||
onSave={bridge.saveConfig}
|
||||
onRestart={bridge.restartBridge}
|
||||
/>
|
||||
)}
|
||||
{tab === "logs" && (
|
||||
|
||||
@@ -4,14 +4,18 @@ import type { Config, BridgeStatus } from "../types";
|
||||
interface Props {
|
||||
config: Config | null;
|
||||
status: BridgeStatus | null;
|
||||
loading: boolean;
|
||||
onSave: (config: Config) => Promise<void>;
|
||||
onRestart: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConfigPanel({ config, status, onSave }: Props) {
|
||||
export function ConfigPanel({ config, status, loading, onSave, onRestart }: Props) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [imapPort, setImapPort] = useState(1143);
|
||||
const [smtpPort, setSmtpPort] = useState(1025);
|
||||
const [apiUrl, setApiUrl] = useState("https://app.tuta.com");
|
||||
const [syncLimit, setSyncLimit] = useState(500);
|
||||
const [fetchAll, setFetchAll] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -20,6 +24,8 @@ export function ConfigPanel({ config, status, onSave }: Props) {
|
||||
setImapPort(config.imap_port);
|
||||
setSmtpPort(config.smtp_port);
|
||||
setApiUrl(config.api_url);
|
||||
setFetchAll(config.sync_limit === 0);
|
||||
setSyncLimit(config.sync_limit === 0 ? 500 : config.sync_limit);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
@@ -31,6 +37,7 @@ export function ConfigPanel({ config, status, onSave }: Props) {
|
||||
imap_port: imapPort,
|
||||
smtp_port: smtpPort,
|
||||
api_url: apiUrl,
|
||||
sync_limit: fetchAll ? 0 : syncLimit,
|
||||
});
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
@@ -45,7 +52,6 @@ export function ConfigPanel({ config, status, onSave }: Props) {
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isRunning}
|
||||
placeholder="your@tuta.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -56,7 +62,6 @@ export function ConfigPanel({ config, status, onSave }: Props) {
|
||||
type="number"
|
||||
value={imapPort}
|
||||
onChange={(e) => setImapPort(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -65,7 +70,6 @@ export function ConfigPanel({ config, status, onSave }: Props) {
|
||||
type="number"
|
||||
value={smtpPort}
|
||||
onChange={(e) => setSmtpPort(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,12 +79,45 @@ export function ConfigPanel({ config, status, onSave }: Props) {
|
||||
type="url"
|
||||
value={apiUrl}
|
||||
onChange={(e) => setApiUrl(e.target.value)}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleSave} disabled={isRunning || !email}>
|
||||
{saved ? "Saved!" : "Save"}
|
||||
</button>
|
||||
<div className="form-group">
|
||||
<label>Mail to sync</label>
|
||||
<label className="checkbox-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fetchAll}
|
||||
onChange={(e) => setFetchAll(e.target.checked)}
|
||||
/>
|
||||
<span>Fetch all mail (entire account, kept locally)</span>
|
||||
</label>
|
||||
{fetchAll ? (
|
||||
<small className="field-hint">
|
||||
Downloads every mail from the start — can be slow on large accounts.
|
||||
</small>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={syncLimit}
|
||||
onChange={(e) => setSyncLimit(Math.max(1, Number(e.target.value)))}
|
||||
placeholder="Max mails per folder"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isRunning && (
|
||||
<small className="field-hint">Changes apply after a restart.</small>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<button className="primary" onClick={handleSave} disabled={loading || !email}>
|
||||
{saved ? "Saved!" : "Save"}
|
||||
</button>
|
||||
{isRunning && (
|
||||
<button onClick={onRestart} disabled={loading}>
|
||||
{loading ? "Restarting…" : "Restart"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,19 @@ export function useBridge() {
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const restartBridge = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await invoke("stop_bridge");
|
||||
await invoke("start_bridge", { password: null });
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setStatus({ Error: String(e) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const clearLogs = useCallback(() => setLogs([]), []);
|
||||
|
||||
const regenerateBridgePassword = useCallback(async () => {
|
||||
@@ -94,6 +107,7 @@ export function useBridge() {
|
||||
saveConfig,
|
||||
startBridge,
|
||||
stopBridge,
|
||||
restartBridge,
|
||||
clearLogs,
|
||||
regenerateBridgePassword,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface Config {
|
||||
imap_port: number;
|
||||
smtp_port: number;
|
||||
api_url: string;
|
||||
/** Max mails synced per folder; 0 = fetch all. */
|
||||
sync_limit: number;
|
||||
}
|
||||
|
||||
export type BridgeStatus = "Stopped" | "Starting" | "Running" | { Error: string };
|
||||
|
||||
Reference in New Issue
Block a user