Skip to content

Commit d98d862

Browse files
committed
feat: add backup and restore functionality with encryption options
- Implemented BackupSection component for managing backup packages. - Added functionality to export and import data in CSV and JSON formats. - Introduced options for encrypted backups with passphrase support. - Enhanced user experience with validation and preview of backup packages. - Updated WidgetCenter to handle new 'coming soon' feature for official widgets. - Added legacy data detection and import functions in the Tauri API. - Defined LegacyDataInfo type in the types module for better type safety.
1 parent 5f1110a commit d98d862

55 files changed

Lines changed: 994 additions & 501 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
55

66
---
77

8-
## [2.0.0] - 2026-06-12
8+
## [2.0.0] - 2026-07-17
99

1010
### Added
1111

@@ -45,6 +45,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4545
- **Local API server governance** — hardened all API server routes to enforce widget-scoped API token scopes and reject unscoped or revoked tokens.
4646
- **Offline test harness scripts** — added `scripts/offline-journey-tests.sh` and `scripts/migration-rehearsal.sh` for local critical-journey and migration validation.
4747
- **VS Code extension sidebar home page redesign** — added connection status badge, focus-mode indicator, today's VS Code time card, language/project breakdown with progress bars, top desktop apps, and cleaner action buttons; sidebar API calls now include `X-Api-Token` and `X-Client-Id` headers for local API governance compatibility.
48+
- **Legacy 1.x data import prompt** — when the default profile is empty and a legacy 1.x database (e.g. upgraded from 1.4.4) is detected, Settings now shows a one-time dialog asking whether to import the existing data. An "Import legacy data" button is also shown below the current profile card.
49+
- **Backend commands for legacy data import** — added `detect_legacy_data` and `import_legacy_data` commands to support the new import flow.
50+
4851

4952
### Changed
5053

@@ -71,9 +74,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
7174
- **`getProjectDisplayName` helper** in `src/utils/format.ts` with unit tests: prefers `project_name`, falls back to the basename of `project_path`, and finally returns the localized `dashboard:unknownProject` label.
7275
- **`dashboard:unknownProject`** i18n key across all desktop locales (`en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`).
7376
- **`unknownProject`** runtime string to the VS Code extension i18n module, used by the extension dashboard panel when a project name is missing.
77+
- **Profile switching UI entry removed** — the profile switch buttons in Settings > Profiles are no longer shown. The `switch_profile` backend command remains available for API/internal use.
78+
- **Legacy data migration is now user-confirmed** — startup no longer silently imports legacy 1.x data into the default profile. Importing now requires explicit confirmation via the new prompt or button, and is applied on the next restart through a pending-import flag.
79+
- **Desktop pet widget temporarily disabled** — the pet widget is marked as “Coming Soon ” in the Widget Center; its title, description, and add button are grayed out and disabled. A backend guard in `create_widget` also rejects `widget_type == "pet"` to prevent creation through any path.
80+
- **Profile creation button disabled** — the “Create profile” button in Settings > Profiles is temporarily disabled and the creation dialog is hidden.
7481

7582
### Fixed
7683

84+
- **Backup & Restore UI/UX** — rewrote the Settings page into a clear export/import card layout, added explicit file-selection feedback, a step-by-step validate-then-restore flow, and native notifications on success.
85+
- **Restore feedback** — restoring a backup now shows a clear status message instead of failing silently; encrypted backups correctly prompt for the passphrase before applying.
86+
- **Native confirmation dialogs in Settings** — replaced `window.confirm` with `@tauri-apps/plugin-dialog` `confirm()` to prevent `dialog.confirm not allowed` errors in the Tauri webview.
7787
- **Settings excluded apps could not be unchecked after saving** — fixed a path-normalization mismatch: the backend stores ignored app paths lowercased, but the frontend compared original-case paths, causing excluded apps to vanish from the list. The list now renders ignored apps directly with case-insensitive matching so they remain visible and togglable.
7888
- **VS Code extension sidebar title showed raw `%timelens.homeView.name%`** — fixed invalid JSON in `vscode-extension/package.nls.json` and `vscode-extension/package.nls.zh-CN.json` (missing comma after `timelens.apiToken.description`), which prevented VS Code from resolving all `%...%` placeholder strings in `package.json`.
7989
- **Profile switching reliability** — fixed a bug where switching profiles could result in a "connection refused" error or missing profiles because `current_profile_id` was stored inside the encrypted profile database.

src-tauri/src/commands/data_reliability_cmd.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use chrono::{Duration, Local, NaiveDate};
66
use rusqlite::params;
77
use serde::{Deserialize, Serialize};
88
use sha2::{Digest, Sha256};
9-
use tauri::{Manager, State};
9+
use tauri::{AppHandle, Manager, State};
1010
use zip::{write::FileOptions, CompressionMethod, ZipArchive, ZipWriter};
1111

1212
use crate::commands::storage_cmd::DbState;
@@ -2501,6 +2501,67 @@ pub fn create_profile(
25012501
})
25022502
}
25032503

2504+
#[derive(Debug, Serialize)]
2505+
pub struct LegacyDataInfo {
2506+
pub available: bool,
2507+
pub source_path: Option<String>,
2508+
pub default_profile_empty: bool,
2509+
pub current_profile_is_default: bool,
2510+
pub can_import: bool,
2511+
}
2512+
2513+
#[tauri::command]
2514+
pub fn detect_legacy_data(app: AppHandle) -> Result<LegacyDataInfo, String> {
2515+
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
2516+
let app_state_conn = db::migrations::open_app_state_db(&data_dir)
2517+
.map_err(|e| format!("Failed to open app state database: {}", e))?;
2518+
let current_profile_id = db::migrations::current_profile_id_from_app_state(&app_state_conn);
2519+
2520+
let default_db = db::migrations::db_path_for_profile(
2521+
&data_dir,
2522+
db::migrations::DEFAULT_PROFILE_ID,
2523+
);
2524+
let default_profile_empty = if default_db.exists() {
2525+
match db::open(&default_db) {
2526+
Ok(conn) => {
2527+
let count: i64 = conn
2528+
.query_row("SELECT COUNT(1) FROM app_usage", [], |r| r.get(0))
2529+
.unwrap_or(0);
2530+
count == 0
2531+
}
2532+
Err(_) => {
2533+
// If we cannot open the default profile (e.g. it is encrypted),
2534+
// treat it as non-empty so we do not risk overwriting data.
2535+
false
2536+
}
2537+
}
2538+
} else {
2539+
true
2540+
};
2541+
2542+
let legacy = crate::legacy_db_path();
2543+
let current_profile_is_default = current_profile_id == db::migrations::DEFAULT_PROFILE_ID;
2544+
Ok(LegacyDataInfo {
2545+
available: legacy.is_some(),
2546+
source_path: legacy.as_ref().map(|p| p.to_string_lossy().to_string()),
2547+
default_profile_empty,
2548+
current_profile_is_default,
2549+
can_import: legacy.is_some() && default_profile_empty && current_profile_is_default,
2550+
})
2551+
}
2552+
2553+
#[tauri::command]
2554+
pub fn import_legacy_data(app: AppHandle) -> Result<(), String> {
2555+
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
2556+
let conn = db::migrations::open_app_state_db(&data_dir)
2557+
.map_err(|e| e.to_string())?;
2558+
db::set_setting(&conn, crate::PENDING_LEGACY_IMPORT_KEY, "1")
2559+
.map_err(|e| e.to_string())?;
2560+
log::info!("User requested legacy data import; restarting to apply");
2561+
app.restart();
2562+
}
2563+
2564+
25042565
#[tauri::command]
25052566
pub fn switch_profile(
25062567
profile_id: String,

src-tauri/src/commands/widget_cmd.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@ pub async fn create_widget(
215215
app: AppHandle,
216216
db: tauri::State<'_, DbState>,
217217
) -> Result<WidgetConfig, String> {
218+
if widget_type == "pet" {
219+
return Err("Desktop pet widget is under development. Stay tuned.".to_string());
220+
}
218221
let id = format!("{}-{}", widget_type, short_id());
219222
let (width, height) = default_size(&app, &widget_type)?;
220223

src-tauri/src/lib.rs

Lines changed: 66 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ fn format_seconds(secs: i64) -> String {
9494

9595
const LEGACY_APP_DIR_NAME: &str = "ShanWenxiao.TimeLens-TimeManagementAppwithWidgets";
9696
const DEFAULT_PROFILE_ID: &str = "default";
97+
pub(crate) const PENDING_LEGACY_IMPORT_KEY: &str = "pending_legacy_import";
9798

9899
fn copy_if_exists(src: &Path, dst: &Path) -> std::io::Result<()> {
99100
if src.exists() {
@@ -156,7 +157,7 @@ fn copy_db_with_sidecars(src: &Path, dst: &Path) -> std::io::Result<()> {
156157
/// Best-effort detection of a legacy 1.x database path on all supported
157158
/// platforms. Tauri v1 placed app data differently than v2; this function
158159
/// probes the most likely locations without requiring Tauri APIs.
159-
fn legacy_db_path() -> Option<PathBuf> {
160+
pub(crate) fn legacy_db_path() -> Option<PathBuf> {
160161
#[cfg(target_os = "windows")]
161162
{
162163
let appdata = std::env::var_os("APPDATA")?;
@@ -208,43 +209,62 @@ fn migrate_legacy_db(legacy_db: &Path, target_db: &Path) -> std::io::Result<()>
208209

209210
fn resolve_database_path(data_dir: &Path, profile_id: Option<&str>) -> PathBuf {
210211
let profile_id = profile_id.unwrap_or(DEFAULT_PROFILE_ID);
211-
let target_db = db_path_for_profile(data_dir, profile_id);
212-
213-
// Only the default profile attempts to auto-migrate from legacy 1.x paths.
214-
if profile_id == DEFAULT_PROFILE_ID {
215-
if let Some(legacy_path) = legacy_db_path() {
216-
let current_size = std::fs::metadata(&target_db).map(|m| m.len()).unwrap_or(0);
217-
let legacy_size = std::fs::metadata(&legacy_path)
218-
.map(|m| m.len())
219-
.unwrap_or(0);
220-
221-
let should_migrate =
222-
!target_db.exists() || (current_size <= 4096 && legacy_size > current_size);
223-
224-
if should_migrate {
225-
match migrate_legacy_db(&legacy_path, &target_db) {
226-
Ok(()) => {
227-
log::info!(
228-
"TimeLens DB migrated from legacy path to profile path: {} -> {}",
229-
legacy_path.display(),
230-
target_db.display()
231-
);
232-
}
233-
Err(err) => {
234-
log::warn!(
235-
"TimeLens DB migration failed ({} -> {}): {}. Falling back to legacy DB path.",
236-
legacy_path.display(),
237-
target_db.display(),
238-
err
239-
);
240-
return legacy_path;
241-
}
242-
}
243-
}
244-
}
212+
db_path_for_profile(data_dir, profile_id)
213+
}
214+
215+
/// Apply a pending user-approved legacy import before any profile database is
216+
/// opened. This is invoked once per restart after the frontend confirms the
217+
/// import; it copies the legacy 1.x database into the default profile and
218+
/// leaves the flag cleared so the prompt does not repeat.
219+
fn maybe_apply_pending_legacy_import(
220+
app_state_conn: &rusqlite::Connection,
221+
data_dir: &Path,
222+
) -> Result<(), String> {
223+
let pending = db::get_setting(app_state_conn, PENDING_LEGACY_IMPORT_KEY)
224+
.ok()
225+
.flatten()
226+
.unwrap_or_default()
227+
== "1";
228+
if !pending {
229+
return Ok(());
245230
}
246231

247-
target_db
232+
// Clear the flag immediately so a failed import does not loop on restart.
233+
let _ = db::set_setting(app_state_conn, PENDING_LEGACY_IMPORT_KEY, "0");
234+
235+
let current_profile_id = db::migrations::current_profile_id_from_app_state(app_state_conn);
236+
if current_profile_id != db::migrations::DEFAULT_PROFILE_ID {
237+
log::info!("Pending legacy import ignored: current profile is not default");
238+
return Ok(());
239+
}
240+
241+
let Some(legacy_path) = legacy_db_path() else {
242+
log::info!("Pending legacy import ignored: no legacy database found");
243+
return Ok(());
244+
};
245+
246+
let target_db = db::migrations::db_path_for_profile(data_dir, db::migrations::DEFAULT_PROFILE_ID);
247+
let target_size = std::fs::metadata(&target_db).map(|m| m.len()).unwrap_or(0);
248+
if target_size > 4096 {
249+
log::info!("Pending legacy import ignored: default profile already contains data");
250+
return Ok(());
251+
}
252+
253+
// Remove any existing empty/small default profile database so the copy is clean.
254+
let _ = std::fs::remove_file(&target_db);
255+
let _ = std::fs::remove_file(format!("{}-wal", target_db.display()));
256+
let _ = std::fs::remove_file(format!("{}-shm", target_db.display()));
257+
258+
migrate_legacy_db(&legacy_path, &target_db).map_err(|e| {
259+
format!("Failed to import legacy data into default profile: {}", e)
260+
})?;
261+
262+
log::info!(
263+
"TimeLens legacy database imported into default profile: {} -> {}",
264+
legacy_path.display(),
265+
target_db.display()
266+
);
267+
Ok(())
248268
}
249269

250270
fn init_file_logger(log_dir: &Path) -> Result<(), String> {
@@ -491,15 +511,19 @@ pub fn run() {
491511
let data_dir = app.path().app_data_dir()?;
492512
std::fs::create_dir_all(&data_dir)?;
493513

494-
// Compute the default profile path first so legacy migration can run
495-
// before we read global profile state.
496-
let default_db_path = resolve_database_path(&data_dir, None);
497-
498514
// Open the unencrypted app state database first (before any profile DB)
499515
// so profile metadata is available even when profile DBs are encrypted.
500516
let app_state_conn = db::migrations::open_app_state_db(&data_dir)
501517
.map_err(|e| format!("Failed to open app state database: {}", e))?;
502518

519+
// Apply any user-approved legacy 1.x import before we resolve the default
520+
// profile path so the imported database is used on this startup.
521+
if let Err(e) = maybe_apply_pending_legacy_import(&app_state_conn, &data_dir) {
522+
log::warn!("Failed to apply pending legacy import: {}", e);
523+
}
524+
525+
let default_db_path = resolve_database_path(&data_dir, None);
526+
503527
// One-time migration of profile metadata from the legacy default
504528
// profile DB into the separate app state DB.
505529
let _ = db::migrations::migrate_profile_state_from_default_db(
@@ -946,6 +970,8 @@ pub fn run() {
946970
commands::create_profile,
947971
commands::switch_profile,
948972
commands::get_current_profile,
973+
commands::detect_legacy_data,
974+
commands::import_legacy_data,
949975
commands::repair_data_issues,
950976
commands::export_backup_v2,
951977
commands::import_backup_v2_validate,
-1 Bytes
Loading
71 Bytes
Loading
60 Bytes
Loading
-53.2 KB
Loading
-23 Bytes
Loading
-27 Bytes
Loading

0 commit comments

Comments
 (0)