#![allow( clippy::print_stdout, reason = "interactive setup wizard whose purpose is to drive a stdout dialogue" )] use std::fmt::Write as FmtWrite; use std::path::{Path, PathBuf}; use anyhow::{Context, bail}; use console::Style; use dialoguer::{Confirm, Input, Password, Select}; use indicatif::ProgressBar; use secrecy::ExposeSecret; use crate::credential::{CredentialBackend, CredentialStore}; use crate::types::{ Domain, FileMatchPolicy, LivePhotoMode, LivePhotoMovFilenamePolicy, LogLevel, PhotoResolution, RawPolicy, }; /// Result of the setup wizard — either the user wants to sync now or just exit. #[derive(Debug)] pub(crate) enum SetupResult { /// User chose to sync now. Contains the config path and any one-shot /// password needed for explicit `.env` fallback. SyncNow { config_path: PathBuf, one_shot_password: Option, }, /// User chose not to sync now (or cancelled). Done, } /// Collected answers from the interactive setup wizard. #[derive(Debug)] struct SetupAnswers { // Account username: String, password: secrecy::SecretString, domain: Option, secret_source: SetupSecretSource, // Destination. `folder_structure` is the unfiled-pass template; // `folder_structure_albums` is the v0.13 per-album template. The wizard // sets both together when the user picks a date hierarchy so album passes // get the same layout as the unfiled pass (matches v0.12 behavior). directory: String, folder_structure: Option, folder_structure_albums: Option, // What to download albums: Vec, /// v0.13+ array form. Empty = use kei's default (`primary`). /// `["all"]` = every library (PrimarySync + every shared zone). libraries: Vec, /// v0.13+ smart-folder selector (Favorites, Hidden, etc.). Empty = default /// (`none`); non-empty = emit `[filters].smart_folders`. smart_folders: Vec, /// Optional smart-folder pass template for `[download].folder_structure_smart_folders`. /// `None` leaves the runtime default (`{smart-folder}`). folder_structure_smart_folders: Option, /// `Some(false)` emits `[filters].unfiled = false` (used when the user /// picks specific albums and doesn't also want every other photo). /// `None` keeps the v0.13 default (`true`). unfiled: Option, /// v0.13 `[filters].filename_exclude` patterns; empty = don't emit. filename_exclude: Vec, // Media types skip_videos: bool, /// `Some(_)` emits `[photos].live_photo_mode = "..."`; `None` keeps the /// `Both` default. Replaces the wizard's old binary skip prompt. live_photo_mode: Option, live_photo_mov_filename_policy: Option, // Quality resolution: Option, force_resolution: bool, raw_policy: Option, // Date range recent: Option, skip_created_before: Option, skip_created_after: Option, // Running mode watch_interval: Option, notify_systemd: bool, pid_file: Option, /// `[watch].reconcile_every_n_cycles`; only meaningful in watch mode. reconcile_every_n_cycles: Option, // Extras notification_script: Option, threads_num: Option, max_retries: Option, /// Raw user input string (e.g. `"10MB"`); validated by the config layer's /// `parse_bandwidth_limit` on next sync. Empty = no limit. bandwidth_limit: Option, keep_unicode_in_filenames: bool, set_exif_datetime: bool, #[cfg(feature = "xmp")] embed_xmp: bool, #[cfg(feature = "xmp")] xmp_sidecar: bool, file_match_policy: Option, /// Top-level `data_dir` in the emitted TOML. data_dir: Option, log_level: Option, /// `[ui].friendly`. `None` keeps the section out of the emitted TOML so /// the runtime default-on-for-TTY policy applies. `Some(false)` lets a /// user opt out at setup time without having to remember the flag later. ui_friendly: Option, } impl Default for SetupAnswers { fn default() -> Self { Self { username: String::new(), password: secrecy::SecretString::from(String::new()), domain: None, secret_source: SetupSecretSource::CredentialStore, directory: "~/Photos/iCloud".to_string(), folder_structure: None, folder_structure_albums: None, albums: Vec::new(), libraries: vec!["all".to_string()], smart_folders: Vec::new(), folder_structure_smart_folders: None, unfiled: None, filename_exclude: Vec::new(), skip_videos: false, live_photo_mode: None, live_photo_mov_filename_policy: None, resolution: None, force_resolution: false, raw_policy: None, recent: None, skip_created_before: None, skip_created_after: None, watch_interval: None, notify_systemd: false, pid_file: None, reconcile_every_n_cycles: None, notification_script: None, threads_num: None, max_retries: None, bandwidth_limit: None, keep_unicode_in_filenames: false, set_exif_datetime: false, #[cfg(feature = "xmp")] embed_xmp: false, #[cfg(feature = "xmp")] xmp_sidecar: false, file_match_policy: None, data_dir: None, log_level: None, ui_friendly: None, } } } #[derive(Debug, Clone, PartialEq, Eq)] enum SetupSecretSource { CredentialStore, PasswordFile(String), PasswordCommand(String), EnvFile, } impl SetupSecretSource { fn needs_password_prompt(&self) -> bool { matches!(self, Self::CredentialStore | Self::EnvFile) } } #[derive(Debug)] struct SetupWriteResult { credential_backend: Option, env_path: Option, } // ── Delight helpers ────────────────────────────────────────────── /// Print a dimmed section divider for visual structure between wizard steps. /// Run `f` with a ticking spinner showing `msg`. Clears the spinner when done. fn spinner_for(msg: &'static str, f: impl FnOnce() -> R) -> R { let spinner = ProgressBar::new_spinner(); spinner.set_message(msg); spinner.enable_steady_tick(std::time::Duration::from_millis(80)); let result = f(); spinner.finish_and_clear(); result } fn section_header(text: &str) { let dim = Style::new().dim(); println!(); println!("{}", dim.apply_to(format!(" ── {text} ──"))); } /// Print a line prefixed with a green bold checkmark. fn check(text: &str) { let mark = Style::new().green().bold(); println!("{} {text}", mark.apply_to("✓")); } /// Dim every comment line in a generated TOML string so the active /// keys stand out. Returns a new String with ANSI escapes injected. fn dim_comments(toml: &str) -> String { let dim = Style::new().dim(); toml.lines() .map(|line| { if line.trim_start().starts_with('#') { dim.apply_to(line).to_string() } else if line.trim_start().starts_with('[') { Style::new().cyan().bold().apply_to(line).to_string() } else { line.to_string() } }) .collect::>() .join("\n") } fn print_toml_preview(toml_content: &str) { println!(); println!("───────────────────────────────────────────────────────"); print!("{}", dim_comments(toml_content)); println!("───────────────────────────────────────────────────────"); println!(); } pub(crate) fn run_setup( config_path: &Path, input_mode: crate::InputMode, ) -> anyhow::Result { if !input_mode.can_prompt() { bail!("The setup wizard needs an interactive terminal."); } println!(); println!("Hey! Let's get kei set up."); println!(); println!("A few quick questions, then you'll be syncing."); println!("Press Enter to accept a default — it's always a safe bet."); println!(); // Check for existing config if config_path.exists() && !check_overwrite(config_path)? { println!("Setup cancelled."); return Ok(SetupResult::Done); } let mut answers = SetupAnswers::default(); // Step 1: Account section_header("Account"); ask_account_identity(&mut answers)?; check(&format!("Glad you're here, {}.", answers.username)); // Step 2: Secrets section_header("Secrets"); ask_secret_source(&mut answers)?; if answers.secret_source.needs_password_prompt() { ask_account_password(&mut answers)?; } // Step 3: Where to save section_header("Where to save"); ask_destination(&mut answers)?; // Step 4: What to download section_header("What to sync"); ask_what_to_download(&mut answers)?; if !answers.albums.is_empty() { check("Keeping it focused."); } // Step 5: Media types section_header("Media types"); ask_media_types(&mut answers)?; // Step 6: Photo quality & RAW section_header("Quality"); ask_quality(&mut answers)?; // Step 7: Date range section_header("Date range"); ask_date_range(&mut answers)?; // Step 8: Running mode section_header("Running mode"); ask_running_mode(&mut answers)?; if answers.watch_interval.is_some() { check("Set-and-forget. Kei will keep an eye on things."); } // Step 9: Extras section_header("Extras"); ask_extras(&mut answers)?; apply_library_scoped_templates_for_all_libraries(&mut answers); // Generate TOML with a brief spinner so the user sees something happening. let toml_content: String = spinner_for("Building your config...", || generate_toml(&answers)); check("All done."); println!(); // The Select offers "Show again" so users on small terminals can // re-read the config after it scrolled past. let line_count = toml_content.lines().count(); print_toml_preview(&toml_content); loop { let action_items = [ format!("Write to {}", config_path.display()), format!("Show full configuration again ({line_count} lines)"), "Cancel and exit without writing".to_string(), ]; let action = Select::new() .with_prompt("Look good?") .items(&action_items) .default(0) .interact()?; match action { 0 => break, 1 => print_toml_preview(&toml_content), _ => { println!("Setup cancelled."); return Ok(SetupResult::Done); } } } // Write files with a spinner for visual closure. let write_result = spinner_for("Writing your config...", || { write_setup_files(config_path, &toml_content, &answers) })?; check("Config saved."); println!(); println!(" Config → {}", config_path.display()); print_secret_summary(&answers, &write_result); println!(); let bold = Style::new().bold(); println!("{}", bold.apply_to("You're all set.")); if answers.watch_interval.is_some() { println!(); println!( " {} to run continuously in the background.", bold.apply_to(format!("kei install --config {}", config_path.display())) ); } println!(); // Offer to sync now let sync_now = Confirm::new() .with_prompt("Want to kick off your first sync right now?") .default(true) .interact()?; if sync_now { let one_shot_password = match answers.secret_source { SetupSecretSource::EnvFile => Some(answers.password), _ => None, }; Ok(SetupResult::SyncNow { config_path: config_path.to_path_buf(), one_shot_password, }) } else { println!(); println!("No rush. When you're ready:"); println!(); if let Some(env_path) = &write_result.env_path { print_load_env_snippet(env_path); } println!(" {}", sync_command_for_config(config_path)); println!(); Ok(SetupResult::Done) } } fn apply_library_scoped_templates_for_all_libraries(answers: &mut SetupAnswers) { if answers.libraries.as_slice() != ["all"] { return; } answers.folder_structure = Some(library_template_or_default( answers.folder_structure.as_deref(), "%Y/%m/%d", )); } fn ensure_library_token(template: &str) -> String { if template.contains("{library}") { return template.to_string(); } if template.is_empty() { return "{library}".to_string(); } format!("{{library}}/{template}") } fn library_template_or_default(template: Option<&str>, default: &str) -> String { ensure_library_token(template.unwrap_or(default)) } fn single_quoted_shell(value: &str) -> String { format!("'{}'", shell_single_quote_escape(value)) } fn sync_command_for_config(config_path: &Path) -> String { let path = config_path.to_string_lossy(); format!("kei --config {} sync", single_quoted_shell(&path)) } /// Print the right "load .env into this shell" command for the user's shell. /// Detects the shell from `$SHELL`. Defaults to the bash/zsh form because /// it's the most common on Linux and macOS; calls out fish explicitly because /// `set -a` doesn't exist there and the bash snippet would silently no-op. fn print_load_env_snippet(env_path: &Path) { let shell = std::env::var("SHELL").unwrap_or_default(); let shell_name = Path::new(&shell) .file_name() .and_then(|s| s.to_str()) .unwrap_or(""); let env_display = env_path.display(); match shell_name { "fish" => { // fish doesn't have `set -a`, and the .env file's single-quoted // values would be passed through verbatim by a naive // `(cat | string split)` loop. Cleanest reliable one-liner: have // bash do the parsing, then exec fish so the inherited env // carries through. println!(" # fish: load .env via a bash subshell, then continue in fish"); println!(" bash -c 'set -a; source {env_display}; set +a; exec fish'"); } _ => { // bash / zsh / sh / dash / ksh: POSIX `set -a` + source. println!(" set -a; source {env_display}; set +a"); } } } fn write_setup_files( config_path: &Path, toml_content: &str, answers: &SetupAnswers, ) -> anyhow::Result { write_setup_files_with_store( config_path, toml_content, answers, |username, config_dir, pw| { CredentialStore::new( username, config_dir, answers.domain.unwrap_or(Domain::Com).as_str(), ) .store(pw) }, ) } fn write_setup_files_with_store( config_path: &Path, toml_content: &str, answers: &SetupAnswers, store_password: impl FnOnce(&str, &Path, &str) -> anyhow::Result, ) -> anyhow::Result { if let Some(parent) = config_path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("Could not create directory {}", parent.display()))?; } let (credential_backend, write_env) = match &answers.secret_source { SetupSecretSource::CredentialStore => { let config_dir = credential_store_dir(answers); let backend = store_password( &answers.username, &config_dir, answers.password.expose_secret(), )?; (Some(backend), false) } SetupSecretSource::PasswordFile(_) | SetupSecretSource::PasswordCommand(_) => (None, false), SetupSecretSource::EnvFile => (None, true), }; write_config_file(config_path, toml_content)?; let env_path = if write_env { Some(write_env_file(config_path, answers)?) } else { None }; Ok(SetupWriteResult { credential_backend, env_path, }) } fn write_config_file(config_path: &Path, toml_content: &str) -> anyhow::Result<()> { std::fs::write(config_path, toml_content) .with_context(|| format!("Could not write {}", config_path.display()))?; #[cfg(unix)] set_restricted_permissions(config_path)?; Ok(()) } fn credential_store_dir(answers: &SetupAnswers) -> PathBuf { match &answers.data_dir { Some(dir) => crate::config::expand_tilde(dir), None => crate::config::default_cookie_dir(), } } fn write_env_file(config_path: &Path, answers: &SetupAnswers) -> anyhow::Result { let env_path = config_path .parent() .unwrap_or_else(|| Path::new(".")) .join(".env"); let escaped_user = shell_single_quote_escape(&answers.username); let escaped_pass = shell_single_quote_escape(answers.password.expose_secret()); let env_content = format!("ICLOUD_USERNAME='{escaped_user}'\nICLOUD_PASSWORD='{escaped_pass}'\n",); std::fs::write(&env_path, &env_content) .with_context(|| format!("Could not write {}", env_path.display()))?; #[cfg(unix)] set_restricted_permissions(&env_path)?; Ok(env_path) } fn shell_single_quote_escape(value: &str) -> String { value.replace('\'', "'\\''") } #[cfg(unix)] fn set_restricted_permissions(path: &Path) -> anyhow::Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) .with_context(|| format!("Could not set secure permissions on {}", path.display())) } fn print_secret_summary(answers: &SetupAnswers, write_result: &SetupWriteResult) { for line in secret_summary_lines(answers, write_result) { println!("{line}"); } } fn secret_summary_lines(answers: &SetupAnswers, write_result: &SetupWriteResult) -> Vec { match &answers.secret_source { SetupSecretSource::CredentialStore => { let backend = write_result .credential_backend .map(CredentialBackend::as_str) .unwrap_or("credential-store"); vec![ format!(" Secrets → {backend} backend"), " Use `kei password backend` to check it or `kei password set` to change it." .to_string(), ] } SetupSecretSource::PasswordFile(path) => { vec![format!(" Secrets → password file: {path}")] } SetupSecretSource::PasswordCommand(_) => { vec![" Secrets → password command from config".to_string()] } SetupSecretSource::EnvFile => { if let Some(env_path) = &write_result.env_path { vec![format!(" Secrets → {}", env_path.display())] } else { Vec::new() } } } } fn check_overwrite(path: &Path) -> anyhow::Result { Confirm::new() .with_prompt(format!("{} already exists. Overwrite?", path.display())) .default(false) .interact() .map_err(Into::into) } // ── Step 1: Account ──────────────────────────────────────────────── fn ask_account_identity(answers: &mut SetupAnswers) -> anyhow::Result<()> { answers.username = Input::new() .with_prompt("Apple ID email") .validate_with(|input: &String| { if input.contains('@') && input.contains('.') { Ok(()) } else { Err("Please enter a valid email address") } }) .interact_text()?; println!(); let region_items = ["iCloud.com", "iCloud.com.cn (China)"]; let region = Select::new() .with_prompt("iCloud region") .items(region_items) .default(0) .interact()?; answers.domain = match region { 1 => Some(Domain::Cn), _ => None, // com is the default, no need to write it }; Ok(()) } fn ask_account_password(answers: &mut SetupAnswers) -> anyhow::Result<()> { // `with_confirmation` re-prompts on mismatch in-place, so a typo costs // one extra round, not a broken config that fails the next sync. answers.password = secrecy::SecretString::from( Password::new() .with_prompt("iCloud password") .with_confirmation( "Re-enter password to confirm", "Passwords didn't match, try again", ) .interact()?, ); Ok(()) } // ── Step 2: Where to save ────────────────────────────────────────── fn ask_destination(answers: &mut SetupAnswers) -> anyhow::Result<()> { println!(); answers.directory = Input::new() .with_prompt("Where should photos be saved?") .default("~/Photos/iCloud".to_string()) .interact_text()?; // Data directory for sessions, state DB, credentials, health. The default // is right for almost everyone, so it's offered here as a single line with // an obvious skip, not gated behind the "additional options?" extras prompt. let data_dir: String = Input::new() .with_prompt( "App data directory (advanced — leave default unless you know you need to change this)", ) .default("~/.config/kei".to_string()) .interact_text()?; if data_dir != "~/.config/kei" { answers.data_dir = Some(data_dir); } println!(); let folder_items = [ "By date: 2024/03/15 (%Y/%m/%d)", "By month: 2024/03 (%Y/%m)", "By year: 2024 (%Y)", "All in one folder", "Custom pattern...", ]; let folder = Select::new() .with_prompt("How should photos be organized into folders?") .items(folder_items) .default(0) .interact()?; // The unfiled-pass template (`folder_structure`) and the per-album // template (`folder_structure_albums`) are independent in v0.13. // To match user intent ("organize photos by date") and v0.12 layout // behavior, when the user picks a date hierarchy we apply the same // template under each album folder by setting // `folder_structure_albums = "{album}/