diff --git a/Cargo.lock b/Cargo.lock index 1a7486a9b5..42a5c0c30b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2728,6 +2728,16 @@ dependencies = [ [[package]] name = "gitbutler-settings" version = "0.0.0" +dependencies = [ + "anyhow", + "gitbutler-fs", + "notify", + "serde", + "serde_json", + "serde_json_lenient", + "tokio", + "tracing", +] [[package]] name = "gitbutler-stack" @@ -7343,6 +7353,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_json_lenient" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" +dependencies = [ + "itoa 1.0.11", + "memchr", + "ryu", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.19" diff --git a/apps/desktop/src/lib/config/appSettingsV2.ts b/apps/desktop/src/lib/config/appSettingsV2.ts new file mode 100644 index 0000000000..02e5e9f1df --- /dev/null +++ b/apps/desktop/src/lib/config/appSettingsV2.ts @@ -0,0 +1,65 @@ +import { listen, invoke } from '$lib/backend/ipc'; +import { plainToInstance } from 'class-transformer'; +import { writable } from 'svelte/store'; + +export class SettingsService { + readonly settings = writable(undefined, () => { + this.refresh(); + const unsubscribe = this.subscribe(async (settings) => await this.handlePayload(settings)); + return () => { + unsubscribe(); + }; + }); + + private async handlePayload(settings: AppSettings) { + // TODO: Remove this log + console.log(settings); + this.settings.set(settings); + } + + private async refresh() { + const response = await invoke('get_app_settings'); + const settings = plainToInstance(AppSettings, response); + this.handlePayload(settings); + } + + private subscribe(callback: (settings: AppSettings) => void) { + return listen(`settings://update`, (event) => + callback(plainToInstance(AppSettings, event.payload)) + ); + } + + public async updateOnboardingComplete(update: boolean) { + await invoke('update_onboarding_complete', { update }); + } + + public async updateTelemetry(update: TelemetryUpdate) { + await invoke('update_telemetry', { update }); + } +} + +export class AppSettings { + /** Whether the user has passed the onboarding flow. */ + onboardingComplete!: boolean; + /** Telemetry settings */ + telemetry!: TelemetrySettings; +} + +export class TelemetrySettings { + /** Whether the anonymous metrics are enabled. */ + appMetricsEnabled!: boolean; + /** Whether anonymous error reporting is enabled. */ + appErrorReportingEnabled!: boolean; + /** Whether non-anonymous metrics are enabled. */ + appNonAnonMetricsEnabled!: boolean; +} + +/** Request updating the TelemetrySettings. Only the fields that are set are updated */ +export class TelemetryUpdate { + /** Whether the anonymous metrics are enabled. */ + appMetricsEnabled?: boolean | undefined; + /** Whether anonymous error reporting is enabled. */ + appErrorReportingEnabled?: boolean | undefined; + /** Whether non-anonymous metrics are enabled. */ + appNonAnonMetricsEnabled?: boolean | undefined; +} diff --git a/crates/gitbutler-settings/Cargo.toml b/crates/gitbutler-settings/Cargo.toml index 92f7f97f92..d842fe3006 100644 --- a/crates/gitbutler-settings/Cargo.toml +++ b/crates/gitbutler-settings/Cargo.toml @@ -6,3 +6,17 @@ authors = ["GitButler "] publish = false [dependencies] +anyhow = "1.0.93" +serde = { workspace = true, features = ["std"] } +serde_json = { version = "1.0", features = ["std", "arbitrary_precision"] } +serde_json_lenient = "0.2.3" +gitbutler-fs.workspace = true +notify = { version = "6.0.1" } +tracing.workspace = true +tokio = { workspace = true, features = ["macros", "rt"] } + +[[test]] +name = "settings" +path = "tests/mod.rs" + +[dev-dependencies] diff --git a/crates/gitbutler-settings/assets/defaults.jsonc b/crates/gitbutler-settings/assets/defaults.jsonc new file mode 100644 index 0000000000..3c68456770 --- /dev/null +++ b/crates/gitbutler-settings/assets/defaults.jsonc @@ -0,0 +1,16 @@ +{ + /// Whether the user has passed the onboarding flow. + "onboardingComplete": false, + "telemetry": { + /// Whether the anonymous metrics are enabled. + "appMetricsEnabled": true, + /// Whether anonymous error reporting is enabled. + "appErrorReportingEnabled": true, + /// Whether non-anonymous metrics are enabled. + "appNonAnonMetricsEnabled": false + }, + "githubOauthApp": { + /// Client ID for the GitHub OAuth application. Set this to use custom (non-GitButler) OAuth application. + "oauthClientId": "cd51880daa675d9e6452" + } +} diff --git a/crates/gitbutler-settings/src/api.rs b/crates/gitbutler-settings/src/api.rs new file mode 100644 index 0000000000..48fb046103 --- /dev/null +++ b/crates/gitbutler-settings/src/api.rs @@ -0,0 +1,35 @@ +use crate::AppSettingsWithDiskSync; +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +/// Update request for [`crate::app_settings::TelemetrySettings`]. +pub struct TelemetryUpdate { + pub app_metrics_enabled: Option, + pub app_error_reporting_enabled: Option, + pub app_non_anon_metrics_enabled: Option, +} + +/// Mutation, immediately followed by writing everything to disk. +impl AppSettingsWithDiskSync { + pub fn update_onboarding_complete(&self, update: bool) -> Result<()> { + let mut settings = self.get_mut_enforce_save()?; + settings.onboarding_complete = update; + settings.save() + } + + pub fn update_telemetry(&self, update: TelemetryUpdate) -> Result<()> { + let mut settings = self.get_mut_enforce_save()?; + if let Some(app_metrics_enabled) = update.app_metrics_enabled { + settings.telemetry.app_metrics_enabled = app_metrics_enabled; + } + if let Some(app_error_reporting_enabled) = update.app_error_reporting_enabled { + settings.telemetry.app_error_reporting_enabled = app_error_reporting_enabled; + } + if let Some(app_non_anon_metrics_enabled) = update.app_non_anon_metrics_enabled { + settings.telemetry.app_non_anon_metrics_enabled = app_non_anon_metrics_enabled; + } + settings.save() + } +} diff --git a/crates/gitbutler-settings/src/app_settings.rs b/crates/gitbutler-settings/src/app_settings.rs new file mode 100644 index 0000000000..1aa474ab30 --- /dev/null +++ b/crates/gitbutler-settings/src/app_settings.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TelemetrySettings { + /// Whether the anonymous metrics are enabled. + pub app_metrics_enabled: bool, + /// Whether anonymous error reporting is enabled. + pub app_error_reporting_enabled: bool, + /// Whether non-anonymous metrics are enabled. + pub app_non_anon_metrics_enabled: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GitHubOAuthAppSettings { + /// Client ID for the GitHub OAuth application. Set this to use custom (non-GitButler) OAuth application. + pub oauth_client_id: String, +} diff --git a/crates/gitbutler-settings/src/json.rs b/crates/gitbutler-settings/src/json.rs new file mode 100644 index 0000000000..c331924e49 --- /dev/null +++ b/crates/gitbutler-settings/src/json.rs @@ -0,0 +1,263 @@ +// Given a current json value and an update json value, return a json value that represents the difference between the two. +pub fn json_difference( + current: serde_json::Value, + update: &serde_json::Value, +) -> serde_json::Value { + use serde_json::Value; + if let Value::Object(update_object) = &update { + if let Value::Object(current_object) = current { + let mut result = serde_json::Map::new(); + for (key, update_value) in update_object { + if let Some(current_value) = current_object.get(key) { + if current_value != update_value { + result.insert( + key.clone(), + json_difference(current_value.clone(), update_value), + ); + } + } else { + result.insert(key.clone(), update_value.clone()); + } + } + Value::Object(result) + } else { + update.clone() + } + } else { + update.clone() + } +} + +/// Based on Zed `merge_non_null_json_value_into` +/// Note: This doesn't merge arrays. +pub fn merge_non_null_json_value(source: serde_json::Value, target: &mut serde_json::Value) { + use serde_json::Value; + if let Value::Object(source_object) = source { + let target_object = if let Value::Object(target) = target { + target + } else { + *target = serde_json::json!({}); + target.as_object_mut().expect("object was just set") + }; + for (key, value) in source_object { + if let Some(target) = target_object.get_mut(&key) { + merge_non_null_json_value(value, target); + } else if !value.is_null() { + target_object.insert(key, value); + } + } + } else if !source.is_null() { + *target = source + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_does_not_merge_null_values() { + let source = serde_json::json!({"a": null, "b": true }); + let mut target = serde_json::json!({}); + merge_non_null_json_value(source, &mut target); + assert_eq!(target, serde_json::json!({"b": true })); + } + + #[test] + fn it_does_not_merge_arrays() { + let source = serde_json::json!({"a": null, "b": [1,2,3]}); + let mut target = serde_json::json!({"a": {"b": 1}, "b": [42]}); + merge_non_null_json_value(source, &mut target); + assert_eq!(target, serde_json::json!({"a": {"b": 1}, "b": [1,2,3] })); + } + + #[test] + fn it_merges_nested_objects_correctly() { + let source = serde_json::json!({"a": {"b": {"c": 42}}}); + let mut target = serde_json::json!({}); + merge_non_null_json_value(source.clone(), &mut target); + assert_eq!(target, source); + } + + #[test] + pub fn test_difference_existing_key() { + use serde_json::json; + let current = json!({ + "a": 1, + "b": { + "c": 2, + "d": 3 + }, + "e": { + "f": 4 + } + }); + let update = json!({ + "a": 1, + "b": { + "c": 2, + "d": 3 + }, + "e": { + "f": 5 + } + }); + assert_eq!( + json_difference(current, &update), + json!({ + "e": { + "f": 5 + } + }) + ); + } + + #[test] + pub fn test_difference_new_key() { + use serde_json::json; + let current = json!({ + "a": 1, + "b": { + "c": 2, + "d": 3 + }, + "e": { + "f": 4 + } + }); + let update = json!({ + "a": 1, + "b": { + "c": 2, + "d": 3 + }, + "e": { + "f": 4 + }, + "g": 5 + }); + assert_eq!( + json_difference(current, &update), + json!({ + "g": 5 + }) + ); + } + + #[test] + pub fn test_no_overlap_at_all() { + use serde_json::json; + let current = json!({ + "a": 1, + "b": { + "c": 2, + "d": 3 + }, + "e": { + "f": 4 + } + }); + let update = json!({ + "g": 5, + "h": { + "i": 6, + "j": 7 + }, + "k": { + "l": 8 + } + }); + assert_eq!(json_difference(current, &update), update); + } + + #[test] + pub fn test_everything_is_same_noop() { + use serde_json::json; + let current = json!({ + "a": 1, + "b": { + "c": 2, + "d": 3 + }, + "e": { + "f": 4 + } + }); + let update = json!({ + "a": 1, + "b": { + "c": 2, + "d": 3 + }, + "e": { + "f": 4 + } + }); + assert_eq!(json_difference(current, &update), json!({})); + } + + #[test] + pub fn test_difference_new_key_with_null() { + use serde_json::json; + let current = json!({ + "a": 1, + "b": { + "c": 2, + "d": 3 + }, + "e": { + "f": 4 + } + }); + let update = json!({ + "a": 1, + "b": { + "c": 2, + "d": 3 + }, + "e": { + "f": null + }, + "g": 5 + }); + assert_eq!( + json_difference(current, &update), + json!({ + "e": { + "f": null + }, + "g": 5 + }) + ); + } + + #[test] + pub fn test_both_null() { + use serde_json::json; + let current = json!({ + "a": null + }); + let update = json!({ + "a": null + }); + assert_eq!(json_difference(current, &update), json!({})); + } + + #[test] + pub fn test_empty_object() { + use serde_json::json; + let current = json!({}); + let update = json!({}); + assert_eq!(json_difference(current, &update), json!({})); + } + + #[test] + pub fn test_empty_object_with_new_key() { + use serde_json::json; + let current = json!({}); + let update = json!({ + "a": 1 + }); + assert_eq!(json_difference(current, &update), update); + } +} diff --git a/crates/gitbutler-settings/src/legacy.rs b/crates/gitbutler-settings/src/legacy.rs new file mode 100644 index 0000000000..8403085772 --- /dev/null +++ b/crates/gitbutler-settings/src/legacy.rs @@ -0,0 +1,12 @@ +/// Application settings +/// Constructed via the `tauri_plugin_store::Store` from `settings.json` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[deprecated(note = "Use AppSettings`")] +pub struct LegacySettings { + pub app_metrics_enabled: Option, + pub app_error_reporting_enabled: Option, + pub app_non_anon_metrics_enabled: Option, + pub app_analytics_confirmed: Option, + /// Client ID for the GitHub OAuth application + pub github_oauth_client_id: Option, +} diff --git a/crates/gitbutler-settings/src/lib.rs b/crates/gitbutler-settings/src/lib.rs index 1ab01e408d..263a56fc60 100644 --- a/crates/gitbutler-settings/src/lib.rs +++ b/crates/gitbutler-settings/src/lib.rs @@ -1,11 +1,24 @@ -/// Application settings -/// Constructed via the `tauri_plugin_store::Store` from `settings.json` -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#![allow(deprecated)] +use serde::{Deserialize, Serialize}; + +mod legacy; +pub use legacy::LegacySettings; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] pub struct AppSettings { - pub app_metrics_enabled: Option, - pub app_error_reporting_enabled: Option, - pub app_non_anon_metrics_enabled: Option, - pub app_analytics_confirmed: Option, - /// Client ID for the GitHub OAuth application - pub github_oauth_client_id: Option, + /// Whether the user has passed the onboarding flow. + pub onboarding_complete: bool, + /// Telemetry settings + pub telemetry: app_settings::TelemetrySettings, + /// Client ID for the GitHub OAuth application. + pub github_oauth_app: app_settings::GitHubOAuthAppSettings, } + +pub mod app_settings; +mod json; +mod persistence; +mod watch; +pub use watch::AppSettingsWithDiskSync; + +pub mod api; diff --git a/crates/gitbutler-settings/src/persistence.rs b/crates/gitbutler-settings/src/persistence.rs new file mode 100644 index 0000000000..a88d87d71a --- /dev/null +++ b/crates/gitbutler-settings/src/persistence.rs @@ -0,0 +1,68 @@ +use std::path::Path; + +use crate::json::{json_difference, merge_non_null_json_value}; +use crate::AppSettings; +use anyhow::Result; +use serde_json::json; + +static DEFAULTS: &str = include_str!("../assets/defaults.jsonc"); + +impl AppSettings { + /// Load the settings from the configuration directory, or initialize the file with an empty JSON object at `config_path`. + /// Finally, merge all customizations from `config_path` into the default settings. + pub fn load(config_path: &Path) -> Result { + // If the file on config_path does not exist, create it empty + if !config_path.exists() { + gitbutler_fs::write(config_path, "{}\n")?; + } + + // merge customizations from disk into the defaults to get a complete set of settings. + let customizations = serde_json_lenient::from_str(&std::fs::read_to_string(config_path)?)?; + let mut settings: serde_json::Value = serde_json_lenient::from_str(DEFAULTS)?; + + merge_non_null_json_value(customizations, &mut settings); + Ok(serde_json::from_value(settings)?) + } + + /// Save all value in this instance to the custom configuration file *if they differ* from the defaults. + pub fn save(&self, config_path: &Path) -> Result<()> { + // Load the current settings + let current = serde_json::to_value(AppSettings::load(config_path)?)?; + + // Derive changed values only compared to the current settings + let update = serde_json::to_value(self)?; + let diff = json_difference(current, &update); + + // If there are no changes, do nothing + if diff == json!({}) { + return Ok(()); + } + + // Load the existing customizations only + let mut customizations = + serde_json_lenient::from_str(&std::fs::read_to_string(config_path)?)?; + + // Merge the new customizations into the existing ones + // TODO: This will nuke any comments in the file + merge_non_null_json_value(diff, &mut customizations); + gitbutler_fs::write(config_path, customizations.to_string())?; + Ok(()) + } +} + +mod tests { + #[test] + fn ensure_default_settings_covers_all_fields() { + let settings: serde_json::Value = + serde_json_lenient::from_str(crate::persistence::DEFAULTS).unwrap(); + let app_settings: Result = + serde_json::from_value(settings.clone()); + if app_settings.is_err() { + println!("\n==========================================================================================="); + println!("Not all AppSettings have default values."); + println!("Make sure to update the defaults file in 'crates/gitbutler-settings/assets/defaults.jsonc'."); + println!("===========================================================================================\n"); + } + assert!(app_settings.is_ok()) + } +} diff --git a/crates/gitbutler-settings/src/watch.rs b/crates/gitbutler-settings/src/watch.rs new file mode 100644 index 0000000000..8262f3aa4c --- /dev/null +++ b/crates/gitbutler-settings/src/watch.rs @@ -0,0 +1,160 @@ +use crate::AppSettings; +use anyhow::Result; +use notify::{event::ModifyKind, Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; +use std::ops::{Deref, DerefMut}; +use std::path::Path; +use std::{ + path::PathBuf, + sync::{mpsc, Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, + time::Duration, +}; + +/// A monitor for [`AppSettings`] on disk which will keep its internal state in sync with +/// what's on disk. +/// +/// It will also distribute the latest version of the application settings. +pub struct AppSettingsWithDiskSync { + config_path: PathBuf, + /// The source of truth for the application settings, as previously read from disk. + snapshot: Arc>, + /// A function to receive the most recent app settings as read from disk. + #[allow(clippy::type_complexity)] + subscriber: Option Result<()> + Send + Sync + 'static>>, +} + +/// Allow changes to the most recent [`AppSettings`] and force them to be saved. +pub(crate) struct AppSettingsEnforceSaveToDisk<'a> { + config_path: &'a Path, + snapshot: RwLockWriteGuard<'a, AppSettings>, + saved: bool, +} + +impl AppSettingsEnforceSaveToDisk<'_> { + pub fn save(&mut self) -> Result<()> { + // Mark as completed first so failure to save will not make us complain about not saving. + self.saved = true; + self.snapshot.save(self.config_path)?; + Ok(()) + } +} + +impl Deref for AppSettingsEnforceSaveToDisk<'_> { + type Target = AppSettings; + + fn deref(&self) -> &Self::Target { + &self.snapshot + } +} + +impl DerefMut for AppSettingsEnforceSaveToDisk<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.snapshot + } +} + +impl Drop for AppSettingsEnforceSaveToDisk<'_> { + fn drop(&mut self) { + assert!( + self.saved, + "BUG: every change must immediately be saved to disk." + ); + } +} + +const SETTINGS_FILE: &str = "settings.json"; + +impl AppSettingsWithDiskSync { + /// Create a new instance without actually starting to [watch in the background](Self::watch_in_background()). + /// + /// * `config_dir` contains the application settings file. + /// * `subscriber` receives any change to it. + pub fn new( + config_dir: impl AsRef, + subscriber: impl Fn(AppSettings) -> Result<()> + Send + Sync + 'static, + ) -> Result { + let config_path = config_dir.as_ref().join(SETTINGS_FILE); + let app_settings = AppSettings::load(&config_path)?; + let app_settings = Arc::new(RwLock::new(app_settings)); + + Ok(Self { + config_path, + snapshot: app_settings, + subscriber: Some(Box::new(subscriber)), + }) + } + + /// Return a reference to the most recently loaded [`AppSettings`]. + pub fn get(&self) -> Result> { + self.snapshot + .read() + .map_err(|e| anyhow::anyhow!("Could not read settings: {:?}", e)) + } + + /// Allow changes only from within this crate to implement all possible settings updates [here](crate::api). + pub(crate) fn get_mut_enforce_save(&self) -> Result> { + self.snapshot + .write() + .map(|snapshot| AppSettingsEnforceSaveToDisk { + snapshot, + config_path: &self.config_path, + saved: false, + }) + .map_err(|e| anyhow::anyhow!("Could not write settings: {:?}", e)) + } + + /// The path from which application settings will be read from disk. + pub fn config_path(&self) -> &Path { + &self.config_path + } + + /// Start watching [`Self::config_path()`] for changes and inform + pub fn watch_in_background(&mut self) -> Result<()> { + let (tx, rx) = mpsc::channel(); + let snapshot = self.snapshot.clone(); + let config_path = self.config_path.to_owned(); + let send_event = self + .subscriber + .take() + .expect("BUG: must not call this more than once"); + let watcher_config = Config::default() + .with_compare_contents(true) + .with_poll_interval(Duration::from_secs(2)); + tokio::task::spawn_blocking(move || -> Result<()> { + let mut watcher: RecommendedWatcher = Watcher::new(tx, watcher_config)?; + watcher.watch(&config_path, RecursiveMode::NonRecursive)?; + loop { + match rx.recv() { + Ok(Ok(Event { + kind: notify::event::EventKind::Modify(ModifyKind::Data(_)), + .. + })) => { + let Ok(mut last_seen_settings) = snapshot.write() else { + continue; + }; + if let Ok(update) = AppSettings::load(&config_path) { + if *last_seen_settings != update { + tracing::info!("settings.json modified; refreshing settings"); + *last_seen_settings = update.clone(); + send_event(update)?; + } + } + } + + Err(_) => { + tracing::error!( + "Error watching config file {:?} - watcher terminated", + config_path + ); + break; + } + + _ => { + // Noop + } + } + } + Ok(()) + }); + Ok(()) + } +} diff --git a/crates/gitbutler-settings/tests/fixtures/modify_default_true_to_false.json b/crates/gitbutler-settings/tests/fixtures/modify_default_true_to_false.json new file mode 100644 index 0000000000..6df367defb --- /dev/null +++ b/crates/gitbutler-settings/tests/fixtures/modify_default_true_to_false.json @@ -0,0 +1,5 @@ +{ + "telemetry": { + "appMetricsEnabled": false + } +} diff --git a/crates/gitbutler-settings/tests/mod.rs b/crates/gitbutler-settings/tests/mod.rs new file mode 100644 index 0000000000..ac92f280a8 --- /dev/null +++ b/crates/gitbutler-settings/tests/mod.rs @@ -0,0 +1,16 @@ +use gitbutler_settings::AppSettings; + +#[test] +#[allow(clippy::bool_assert_comparison)] +fn test_load_settings() { + let settings = + AppSettings::load("tests/fixtures/modify_default_true_to_false.json".as_ref()).unwrap(); + assert_eq!(settings.telemetry.app_metrics_enabled, false); // modified + assert_eq!(settings.telemetry.app_error_reporting_enabled, true); // default + assert_eq!(settings.telemetry.app_non_anon_metrics_enabled, false); // default + assert_eq!(settings.onboarding_complete, false); // default + assert_eq!( + settings.github_oauth_app.oauth_client_id, + "cd51880daa675d9e6452" + ); // default +} diff --git a/crates/gitbutler-tauri/src/github.rs b/crates/gitbutler-tauri/src/github.rs index b77372ac95..ba79f60189 100644 --- a/crates/gitbutler-tauri/src/github.rs +++ b/crates/gitbutler-tauri/src/github.rs @@ -1,4 +1,5 @@ pub mod commands { + use gitbutler_settings::AppSettingsWithDiskSync; use std::collections::HashMap; use tauri::State; @@ -6,20 +7,7 @@ pub mod commands { use serde::{Deserialize, Serialize}; use tracing::instrument; - use crate::{ - error::Error, - settings::{self, SettingsStore}, - }; - - const GITHUB_CLIENT_ID: &str = "cd51880daa675d9e6452"; - fn client_id(store: &SettingsStore) -> String { - store - .app_settings() - .github_oauth_client_id - .as_deref() - .unwrap_or(GITHUB_CLIENT_ID) - .to_string() - } + use crate::error::Error; #[derive(Debug, Deserialize, Serialize, Clone, Default)] pub struct Verification { @@ -28,12 +16,12 @@ pub mod commands { } #[tauri::command(async)] - #[instrument(skip(store), err(Debug))] + #[instrument(skip(settings), err(Debug))] pub async fn init_device_oauth( - store: State<'_, settings::SettingsStore>, + settings: State<'_, AppSettingsWithDiskSync>, ) -> Result { let mut req_body = HashMap::new(); - let client_id = client_id(&store); + let client_id = settings.get()?.github_oauth_app.oauth_client_id.clone(); req_body.insert("client_id", client_id.as_str()); req_body.insert("scope", "repo"); @@ -60,9 +48,9 @@ pub mod commands { } #[tauri::command(async)] - #[instrument(skip(store), err(Debug))] + #[instrument(skip(settings), err(Debug))] pub async fn check_auth_status( - store: State<'_, settings::SettingsStore>, + settings: State<'_, AppSettingsWithDiskSync>, device_code: &str, ) -> Result { #[derive(Debug, Deserialize, Serialize, Clone, Default)] @@ -71,7 +59,7 @@ pub mod commands { } let mut req_body = HashMap::new(); - let client_id = client_id(&store); + let client_id = settings.get()?.github_oauth_app.oauth_client_id.clone(); req_body.insert("client_id", client_id.as_str()); req_body.insert("device_code", device_code); req_body.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); diff --git a/crates/gitbutler-tauri/src/lib.rs b/crates/gitbutler-tauri/src/lib.rs index 2213bdac47..4d3d69c095 100644 --- a/crates/gitbutler-tauri/src/lib.rs +++ b/crates/gitbutler-tauri/src/lib.rs @@ -19,6 +19,7 @@ pub mod commands; pub mod logs; pub mod menu; pub mod window; +pub use window::state::event::ChangeForFrontend; pub use window::state::WindowState; pub mod askpass; diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index 3042b38098..4c34e34982 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -11,10 +11,11 @@ clippy::too_many_lines )] +use gitbutler_settings::AppSettingsWithDiskSync; use gitbutler_tauri::settings::SettingsStore; use gitbutler_tauri::{ askpass, commands, config, forge, github, logs, menu, modes, open, projects, remotes, repo, - secret, stack, undo, users, virtual_branches, zip, App, WindowState, + secret, settings, stack, undo, users, virtual_branches, zip, App, WindowState, }; use tauri::Emitter; use tauri::{generate_context, Manager}; @@ -85,22 +86,33 @@ fn main() { }); } - let (app_data_dir, app_cache_dir, app_log_dir) = { + let (app_data_dir, app_cache_dir, app_log_dir, config_dir) = { let paths = app_handle.path(); ( paths.app_data_dir().expect("missing app data dir"), paths.app_cache_dir().expect("missing app cache dir"), paths.app_log_dir().expect("missing app log dir"), + paths.config_dir().expect("missing config dir"), ) }; std::fs::create_dir_all(&app_data_dir).expect("failed to create app data dir"); std::fs::create_dir_all(&app_cache_dir).expect("failed to create cache dir"); + let config_dir = config_dir.join("gitbutler"); + std::fs::create_dir_all(&config_dir).expect("failed to create config dir"); tracing::info!(version = %app_handle.package_info().version, name = %app_handle.package_info().name, "starting app"); app_handle.manage(WindowState::new(app_handle.clone())); + let mut app_settings = AppSettingsWithDiskSync::new(config_dir, { + let app_handle = app_handle.clone(); + move |app_settings| { + gitbutler_tauri::ChangeForFrontend::from(app_settings).send(&app_handle) + } + })?; + app_settings.watch_in_background()?; + app_handle.manage(app_settings); let app = App { app_data_dir: app_data_dir.clone(), }; @@ -227,6 +239,9 @@ fn main() { open::open_url, forge::commands::get_available_review_templates, forge::commands::get_review_template_contents, + settings::get_app_settings, + settings::update_onboarding_complete, + settings::update_telemetry, ]) .menu(menu::build) .on_window_event(|window, event| match event { diff --git a/crates/gitbutler-tauri/src/settings.rs b/crates/gitbutler-tauri/src/settings.rs index b99f5ba549..d468a3846b 100644 --- a/crates/gitbutler-tauri/src/settings.rs +++ b/crates/gitbutler-tauri/src/settings.rs @@ -1,7 +1,16 @@ +#![allow(deprecated)] +use anyhow::Result; +use gitbutler_settings::api::TelemetryUpdate; use gitbutler_settings::AppSettings; +use gitbutler_settings::AppSettingsWithDiskSync; +use gitbutler_settings::LegacySettings; use std::sync::Arc; +use tauri::State; use tauri::Wry; use tauri_plugin_store::Store; +use tracing::instrument; + +use crate::error::Error; pub struct SettingsStore { store: Arc>, @@ -14,8 +23,8 @@ impl From>> for SettingsStore { } impl SettingsStore { - pub fn app_settings(&self) -> AppSettings { - AppSettings { + pub fn app_settings(&self) -> LegacySettings { + LegacySettings { app_metrics_enabled: self.get_bool("appMetricsEnabled"), app_error_reporting_enabled: self.get_bool("appErrorReportingEnabled"), app_non_anon_metrics_enabled: self.get_bool("appNonAnonMetricsEnabled"), @@ -34,3 +43,29 @@ impl SettingsStore { .and_then(|v| v.as_str().map(|s| s.to_string())) } } + +#[tauri::command(async)] +#[instrument(skip(handle), err(Debug))] +pub fn get_app_settings(handle: State<'_, AppSettingsWithDiskSync>) -> Result { + Ok(handle.get()?.clone()) +} + +#[tauri::command(async)] +#[instrument(skip(handle), err(Debug))] +pub fn update_onboarding_complete( + handle: State<'_, AppSettingsWithDiskSync>, + update: bool, +) -> Result<(), Error> { + handle + .update_onboarding_complete(update) + .map_err(|e| e.into()) +} + +#[tauri::command(async)] +#[instrument(skip(handle), err(Debug))] +pub fn update_telemetry( + handle: State<'_, AppSettingsWithDiskSync>, + update: TelemetryUpdate, +) -> Result<(), Error> { + handle.update_telemetry(update).map_err(|e| e.into()) +} diff --git a/crates/gitbutler-tauri/src/window.rs b/crates/gitbutler-tauri/src/window.rs index 93d13d932d..d55f606fdb 100644 --- a/crates/gitbutler-tauri/src/window.rs +++ b/crates/gitbutler-tauri/src/window.rs @@ -1,4 +1,4 @@ -pub(super) mod state { +pub(crate) mod state { use std::{collections::BTreeMap, sync::Arc}; use anyhow::{Context, Result}; @@ -8,15 +8,16 @@ pub(super) mod state { use tauri::{AppHandle, Manager}; use tracing::instrument; - mod event { + pub(crate) mod event { use anyhow::{Context, Result}; use gitbutler_project::ProjectId; + use gitbutler_settings::AppSettings; use gitbutler_watcher::Change; use tauri::Emitter; /// A change we want to inform the frontend about. #[derive(Debug, Clone, PartialEq, Eq)] - pub(super) struct ChangeForFrontend { + pub struct ChangeForFrontend { name: String, payload: serde_json::Value, project_id: ProjectId, @@ -61,8 +62,19 @@ pub(super) mod state { } } + impl From for ChangeForFrontend { + fn from(settings: AppSettings) -> Self { + ChangeForFrontend { + name: "settings://update".to_string(), + payload: serde_json::json!(settings), + // TODO: remove dummy project id + project_id: ProjectId::default(), + } + } + } + impl ChangeForFrontend { - pub(super) fn send(&self, app_handle: &tauri::AppHandle) -> Result<()> { + pub fn send(&self, app_handle: &tauri::AppHandle) -> Result<()> { app_handle .emit(&self.name, Some(&self.payload)) .context("emit event")?;