app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit c709d03f0ede14aacd67b6856d146daebe5b035e
parent aeb8d14330fb08553e08590c7d964b8c0c3598e5
Author: triesap <tyson@radroots.org>
Date:   Sun,  9 Aug 2026 17:41:14 +0000

preferences: own the Studio preferences crate

- copy the exact locked Studio preferences source into HarvestCircle core
- register the crate as a local workspace member and dependency
- add inherited package authorship and homepage metadata
- refresh the lockfile after the source-boundary change

Diffstat:
Mcore/Cargo.lock | 1-
Mcore/Cargo.toml | 6++++--
Acore/crates/studio_preferences/Cargo.toml | 18++++++++++++++++++
Acore/crates/studio_preferences/src/lib.rs | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 350 insertions(+), 3 deletions(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -1998,7 +1998,6 @@ dependencies = [ [[package]] name = "radroots_studio_preferences" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" dependencies = [ "url", ] diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/source_lock"] +members = ["crates/source_lock", "crates/studio_preferences"] resolver = "3" [workspace.package] @@ -8,6 +8,8 @@ edition = "2024" rust-version = "1.97.1" license = "GPL-3.0-only" repository = "https://github.com/radrootslabs/studio_app" +homepage = "https://radroots.org" +authors = ["Tyson Lupul <tyson@radroots.org>"] [workspace.lints.rust] unsafe_code = "forbid" @@ -21,6 +23,6 @@ radroots_studio_application = { git = "https://github.com/radrootslabs/lib", rev radroots_studio_domain = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } radroots_studio_ffi = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } radroots_studio_nostr = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } -radroots_studio_preferences = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } +radroots_studio_preferences = { path = "crates/studio_preferences", version = "=0.1.0-alpha" } radroots_studio_runtime = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } radroots_studio_storage = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } diff --git a/core/crates/studio_preferences/Cargo.toml b/core/crates/studio_preferences/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "radroots_studio_preferences" +description = "Private preference model for Radroots Studio" +version = "0.1.0-alpha" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license = "MPL-2.0" +repository.workspace = true +homepage.workspace = true +publish = false +include = ["src/**", "Cargo.toml"] + +[dependencies] +url = "=2.5.8" + +[lints] +workspace = true diff --git a/core/crates/studio_preferences/src/lib.rs b/core/crates/studio_preferences/src/lib.rs @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: MPL-2.0 +//! UI-neutral Studio preference state. +//! +//! This module carries forward the uniquely required preference behavior from +//! source commit `6074a4745be361f21bb47d4778c74a14b2d57954`. It intentionally +//! excludes that source's process-global state, sample account, and FFI layer. + +use url::Url; + +pub const PREFERENCES_SCHEMA_VERSION: u32 = 1; +const MAX_SUMMARY_BYTES: usize = 256; +const MAX_SERVER_URL_BYTES: usize = 2_048; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum UpdateChannel { + #[default] + Stable, + Preview, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StudioPreferences { + pub allow_incoming_connections: bool, + pub use_radroots_dns: bool, + pub use_radroots_subnets: bool, + pub launch_at_login: bool, + pub hide_dock_icon: bool, + pub vpn_on_demand_enabled: bool, + pub run_as_exit_node: bool, + pub allow_local_network_access: bool, + pub automatically_check_for_updates: bool, + pub update_channel: UpdateChannel, + pub last_update_check_summary: String, + pub alternate_server_url: String, +} + +impl Default for StudioPreferences { + fn default() -> Self { + Self { + allow_incoming_connections: true, + use_radroots_dns: true, + use_radroots_subnets: true, + launch_at_login: true, + hide_dock_icon: false, + vpn_on_demand_enabled: false, + run_as_exit_node: false, + allow_local_network_access: false, + automatically_check_for_updates: true, + update_channel: UpdateChannel::Stable, + last_update_check_summary: String::new(), + alternate_server_url: String::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PreferencesState { + schema_version: u32, + revision: u64, + preferences: StudioPreferences, +} + +impl Default for PreferencesState { + fn default() -> Self { + Self { + schema_version: PREFERENCES_SCHEMA_VERSION, + revision: 1, + preferences: StudioPreferences::default(), + } + } +} + +impl PreferencesState { + #[must_use] + pub const fn schema_version(&self) -> u32 { + self.schema_version + } + + #[must_use] + pub const fn revision(&self) -> u64 { + self.revision + } + + #[must_use] + pub const fn preferences(&self) -> &StudioPreferences { + &self.preferences + } + + pub fn apply(&mut self, change: PreferenceChange) -> Result<bool, PreferencesError> { + let mut candidate = self.preferences.clone(); + change.apply_to(&mut candidate)?; + if candidate == self.preferences { + return Ok(false); + } + self.revision = self + .revision + .checked_add(1) + .ok_or(PreferencesError::RevisionExhausted)?; + self.preferences = candidate; + Ok(true) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PreferenceChange { + AllowIncomingConnections(bool), + UseRadrootsDns(bool), + UseRadrootsSubnets(bool), + LaunchAtLogin(bool), + HideDockIcon(bool), + VpnOnDemandEnabled(bool), + RunAsExitNode(bool), + AllowLocalNetworkAccess(bool), + AutomaticallyCheckForUpdates(bool), + UpdateChannel(UpdateChannel), + LastUpdateCheckSummary(String), + AlternateServerUrl(String), +} + +impl PreferenceChange { + fn apply_to(self, preferences: &mut StudioPreferences) -> Result<(), PreferencesError> { + match self { + Self::AllowIncomingConnections(value) => { + preferences.allow_incoming_connections = value; + } + Self::UseRadrootsDns(value) => preferences.use_radroots_dns = value, + Self::UseRadrootsSubnets(value) => preferences.use_radroots_subnets = value, + Self::LaunchAtLogin(value) => preferences.launch_at_login = value, + Self::HideDockIcon(value) => preferences.hide_dock_icon = value, + Self::VpnOnDemandEnabled(value) => preferences.vpn_on_demand_enabled = value, + Self::RunAsExitNode(value) => preferences.run_as_exit_node = value, + Self::AllowLocalNetworkAccess(value) => { + preferences.allow_local_network_access = value; + } + Self::AutomaticallyCheckForUpdates(value) => { + preferences.automatically_check_for_updates = value; + } + Self::UpdateChannel(value) => preferences.update_channel = value, + Self::LastUpdateCheckSummary(value) => { + preferences.last_update_check_summary = validated_summary(value)?; + } + Self::AlternateServerUrl(value) => { + preferences.alternate_server_url = validated_server_url(value)?; + } + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PreferencesError { + InvalidSummary, + InvalidAlternateServerUrl, + RevisionExhausted, +} + +fn validated_summary(value: String) -> Result<String, PreferencesError> { + let value = value.trim(); + if value.len() > MAX_SUMMARY_BYTES || value.chars().any(char::is_control) { + return Err(PreferencesError::InvalidSummary); + } + Ok(value.to_owned()) +} + +fn validated_server_url(value: String) -> Result<String, PreferencesError> { + let value = value.trim(); + if value.is_empty() { + return Ok(String::new()); + } + if value.len() > MAX_SERVER_URL_BYTES || value.chars().any(char::is_control) { + return Err(PreferencesError::InvalidAlternateServerUrl); + } + let url = Url::parse(value).map_err(|_| PreferencesError::InvalidAlternateServerUrl)?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(PreferencesError::InvalidAlternateServerUrl); + } + Ok(url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_preserve_the_reviewed_boolean_policy_without_sample_identity() { + let state = PreferencesState::default(); + assert_eq!(state.schema_version(), PREFERENCES_SCHEMA_VERSION); + assert_eq!(state.revision(), 1); + assert!(state.preferences().allow_incoming_connections); + assert!(state.preferences().use_radroots_dns); + assert!(state.preferences().use_radroots_subnets); + assert!(state.preferences().launch_at_login); + assert!(state.preferences().automatically_check_for_updates); + assert_eq!(state.preferences().update_channel, UpdateChannel::Stable); + assert!(state.preferences().last_update_check_summary.is_empty()); + assert!(state.preferences().alternate_server_url.is_empty()); + } + + #[test] + fn revisions_advance_only_when_a_valid_canonical_value_changes() { + let mut state = PreferencesState::default(); + assert!( + !state + .apply(PreferenceChange::HideDockIcon(false)) + .expect("unchanged value") + ); + assert_eq!(state.revision(), 1); + assert!( + state + .apply(PreferenceChange::HideDockIcon(true)) + .expect("changed value") + ); + assert_eq!(state.revision(), 2); + assert!(state.preferences().hide_dock_icon); + } + + #[test] + fn every_boolean_and_channel_change_updates_exactly_one_revision() { + let mut state = PreferencesState::default(); + let changes = [ + PreferenceChange::AllowIncomingConnections(false), + PreferenceChange::UseRadrootsDns(false), + PreferenceChange::UseRadrootsSubnets(false), + PreferenceChange::LaunchAtLogin(false), + PreferenceChange::VpnOnDemandEnabled(true), + PreferenceChange::RunAsExitNode(true), + PreferenceChange::AllowLocalNetworkAccess(true), + PreferenceChange::AutomaticallyCheckForUpdates(false), + PreferenceChange::UpdateChannel(UpdateChannel::Preview), + ]; + for (index, change) in changes.into_iter().enumerate() { + assert!(state.apply(change).expect("valid preference change")); + assert_eq!(state.revision(), index as u64 + 2); + } + let preferences = state.preferences(); + assert!(!preferences.allow_incoming_connections); + assert!(!preferences.use_radroots_dns); + assert!(!preferences.use_radroots_subnets); + assert!(!preferences.launch_at_login); + assert!(preferences.vpn_on_demand_enabled); + assert!(preferences.run_as_exit_node); + assert!(preferences.allow_local_network_access); + assert!(!preferences.automatically_check_for_updates); + assert_eq!(preferences.update_channel, UpdateChannel::Preview); + } + + #[test] + fn revision_overflow_is_rejected_without_mutation() { + let mut state = PreferencesState { + revision: u64::MAX, + ..PreferencesState::default() + }; + assert_eq!( + state.apply(PreferenceChange::HideDockIcon(true)), + Err(PreferencesError::RevisionExhausted) + ); + assert!(!state.preferences().hide_dock_icon); + } + + #[test] + fn alternate_server_is_trimmed_canonical_and_credential_free() { + let mut state = PreferencesState::default(); + state + .apply(PreferenceChange::AlternateServerUrl( + " https://example.com/api ".to_owned(), + )) + .expect("valid URL"); + assert_eq!( + state.preferences().alternate_server_url, + "https://example.com/api" + ); + for invalid in [ + "http://example.com", + "https://user@example.com", + "https://user:password@example.com", + "https://example.com/#fragment", + "not a URL", + "https://example.com/a\nb", + ] { + assert_eq!( + state.apply(PreferenceChange::AlternateServerUrl(invalid.to_owned())), + Err(PreferencesError::InvalidAlternateServerUrl) + ); + } + state + .apply(PreferenceChange::AlternateServerUrl(" ".to_owned())) + .expect("empty URL resets the override"); + assert!(state.preferences().alternate_server_url.is_empty()); + assert_eq!( + state.apply(PreferenceChange::AlternateServerUrl(format!( + "https://example.com/{}", + "x".repeat(MAX_SERVER_URL_BYTES) + ))), + Err(PreferencesError::InvalidAlternateServerUrl) + ); + } + + #[test] + fn summary_is_bounded_trimmed_and_control_free() { + let mut state = PreferencesState::default(); + state + .apply(PreferenceChange::LastUpdateCheckSummary( + " Checked today ".to_owned(), + )) + .expect("valid summary"); + assert_eq!( + state.preferences().last_update_check_summary, + "Checked today" + ); + assert_eq!( + state.apply(PreferenceChange::LastUpdateCheckSummary( + "bad\nvalue".to_owned() + )), + Err(PreferencesError::InvalidSummary) + ); + assert_eq!( + state.apply(PreferenceChange::LastUpdateCheckSummary( + "x".repeat(MAX_SUMMARY_BYTES + 1) + )), + Err(PreferencesError::InvalidSummary) + ); + } +}