Show donation request once per app version

This commit is contained in:
Hakan Seven committed 2026-09-06 11:44:38 +03:00
1 parent 1b0a42a35c
commit 2c83ae2736
28 files changed
+332 -24

No files matched your search

+10 -14
View File
@@ -24,6 +24,7 @@ mod presspull_ops;
mod recent;
pub(crate) mod settings;
mod shortcuts;
mod startup;
mod style_ops;
mod text_inline;
mod tolerance_dialog;
@@ -691,6 +692,7 @@ pub(super) struct OpenCADStudio {
/// The open in-canvas modal dialog, if any (Plan B: shared overlay instead
/// of OS windows).
active_modal: Option<ModalKind>,
pending_startup_modals: std::collections::VecDeque<ModalKind>,
/// Plot modal geometry preserved while the Plot Style editor is open as
/// a child dialog. None means Plotstyle was opened directly (e.g. command).
plotstyle_parent_plot_geometry: Option<(iced::Vector, iced::Vector)>,
@@ -795,13 +797,9 @@ pub(super) struct OpenCADStudio {
/// Tracked separately from the PDSIZE sign so a size of 0 (sign-less) still
/// remembers which radio is active.
point_size_relative: bool,
/// New-release notification window — opened on startup when the
/// GitHub releases API reports a newer version than this build.
/// First-launch "make Open CAD Studio the default for .dwg/.dxf?" prompt
/// window. Shown once, gated on `default_assoc_prompted`.
/// Whether the one-time default-association prompt has already been shown.
/// Persisted via [`settings::UserSettings`] so it survives restarts.
/// Whether the default-association prompt has been answered.
default_assoc_prompted: bool,
donation_prompt_version: String,
/// Read-only session (`--read-only`): editing is allowed but every save
/// path is refused. Set once at boot from the CLI config.
read_only: bool,
@@ -1663,6 +1661,7 @@ pub enum ModalKind {
Shortcuts,
PluginManager,
UpdateNotice,
DonationPrompt,
Layers,
LayerStateManager,
LayerTranslator,
@@ -2914,6 +2913,7 @@ pub enum Message {
UpdateCheckResult(Option<crate::io::update_check::UpdateInfo>),
/// User dismissed the update-notice window.
UpdateNoticeClose,
DonationPromptDonate,
/// First-launch default-association prompt: user accepted — register this
/// app as the default handler for .dwg / .dxf.
AssocPromptYes,
@@ -3421,6 +3421,7 @@ impl OpenCADStudio {
color_picker_tab: ColorPickerTab::Index,
recent_colors: Vec::new(),
active_modal: None,
pending_startup_modals: std::collections::VecDeque::new(),
plotstyle_parent_plot_geometry: None,
find_replace: FindReplaceState::default(),
aec_drop_acknowledged: false,
@@ -3461,6 +3462,7 @@ impl OpenCADStudio {
point_size_buf: String::new(),
point_size_relative: true,
default_assoc_prompted: false,
donation_prompt_version: String::new(),
read_only: false,
update_notice_version: None,
update_notice_body: None,
@@ -3831,13 +3833,7 @@ impl OpenCADStudio {
.map(|line| Task::done(Message::Command(line))),
)
};
// One-time prompt offering to make Open CAD Studio the default app for
// .dwg / .dxf. Shown only on the first launch that hasn't answered it
// yet; the flag is persisted so we never ask twice.
let assoc_prompt: Task<Message> = Task::none();
if !s.default_assoc_prompted {
s.active_modal = Some(ModalKind::AssocPrompt);
}
s.queue_startup_prompts();
// Fetch the Patreon supporters list once at boot for the Start page.
#[cfg(not(target_arch = "wasm32"))]
let patrons_fetch = Task::perform(
@@ -3900,7 +3896,6 @@ impl OpenCADStudio {
focus_cmd,
cli_open,
script,
assoc_prompt,
patrons_fetch,
videos_fetch,
discussions_fetch,
@@ -3916,6 +3911,7 @@ impl OpenCADStudio {
fn boot_web() -> (Self, Task<Message>) {
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut s = Self::new();
s.queue_startup_prompts();
let focus = s.focus_cmd_input();
let primary_font = crate::scene::text::web_font::preload_language(
&crate::i18n::active_language_tag(),
+4 -1
View File
@@ -90,7 +90,7 @@ const SNAP_ORDER: &[SnapType] = &[
// persisted set. (#121)
];
/// `$OSMODE` bit for each running object-snap mode (standard AutoCAD bitmask).
/// `$OSMODE` bit for each running object-snap mode.
/// `None` for OCS-only snaps (Grid, ObjectPick) that have no standard bit.
fn snap_bit(s: SnapType) -> Option<i32> {
Some(match s {
@@ -179,6 +179,8 @@ pub struct UserSettings {
/// prompt has already been shown. Set once the user answers (either way),
/// so we never nag again on subsequent launches.
pub default_assoc_prompted: bool,
/// App version whose donation prompt has been displayed.
pub donation_prompt_version: String,
/// Ids of plugins the user turned off in the Plugin Manager. Disabled
/// plugins keep their manifest listed but drop their ribbon tab and command
/// dispatch.
@@ -313,6 +315,7 @@ impl Default for UserSettings {
snap_angle_deg: 0.0,
otrack: false,
default_assoc_prompted: false,
donation_prompt_version: String::new(),
disabled_plugins: Vec::new(),
plugin_repos: Vec::new(),
literal_spaces: false,
+108
View File
@@ -0,0 +1,108 @@
use super::{ModalKind, OpenCADStudio};
impl OpenCADStudio {
pub(super) fn queue_startup_prompts(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
if !self.default_assoc_prompted {
self.pending_startup_modals
.push_back(ModalKind::AssocPrompt);
}
if self.donation_prompt_version != env!("OCS_APP_VERSION") {
self.pending_startup_modals
.push_back(ModalKind::DonationPrompt);
}
self.show_next_startup_modal();
}
pub(super) fn show_next_startup_modal(&mut self) {
if self.active_modal.is_none() && self.opening.is_none() && self.pending_opens.is_empty() {
if let Some(&kind) = self.pending_startup_modals.front() {
self.active_modal = Some(kind);
self.reset_modal_geometry();
}
}
}
pub(super) fn mark_startup_modal_shown(&mut self) {
if self.active_modal.is_some()
&& self.active_modal.as_ref() == self.pending_startup_modals.front()
{
self.pending_startup_modals.pop_front();
}
if self.active_modal == Some(ModalKind::DonationPrompt)
&& self.donation_prompt_version != env!("OCS_APP_VERSION")
{
self.donation_prompt_version = env!("OCS_APP_VERSION").to_string();
self.save_config();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{config::AppConfig, Message};
#[test]
fn donation_prompt_persists_once_per_version() {
let mut app = OpenCADStudio::new_for_test();
// Older settings have no donation version; headless construction never prompts.
app.apply_config(serde_json::from_str::<AppConfig>("{}").unwrap());
assert!(app.active_modal.is_none());
assert!(app.pending_startup_modals.is_empty());
app.default_assoc_prompted = true;
app.queue_startup_prompts();
assert_eq!(app.active_modal, Some(ModalKind::DonationPrompt));
assert!(app.donation_prompt_version.is_empty());
let _ = app.update(Message::ModalContentResized(iced::Size::new(540.0, 280.0)));
let saved = serde_json::to_string(&app.current_config()).unwrap();
let _ = app.update(Message::CloseModal);
assert!(app.active_modal.is_none());
let mut restarted = OpenCADStudio::new_for_test();
restarted.apply_config(serde_json::from_str(&saved).unwrap());
restarted.queue_startup_prompts();
assert_eq!(restarted.donation_prompt_version, env!("OCS_APP_VERSION"));
assert!(restarted.active_modal.is_none());
restarted.donation_prompt_version = "previous-release".to_string();
restarted.queue_startup_prompts();
assert_eq!(restarted.active_modal, Some(ModalKind::DonationPrompt));
let _ = restarted.update(Message::CommandEscape);
assert!(restarted.active_modal.is_none());
assert_eq!(restarted.donation_prompt_version, env!("OCS_APP_VERSION"));
let _ = restarted.update(Message::Noop);
assert!(restarted.active_modal.is_none());
}
#[test]
fn startup_dialogs_wait_their_turn_and_preserve_unseen_prompts() {
let mut app = OpenCADStudio::new_for_test();
app.apply_config(AppConfig::default());
app.queue_startup_prompts();
assert_eq!(app.active_modal, Some(ModalKind::AssocPrompt));
let _ = app.update(Message::UpdateCheckResult(Some(
crate::io::update_check::UpdateInfo {
version: "next-release".to_string(),
body: String::new(),
},
)));
assert_eq!(app.active_modal, Some(ModalKind::AssocPrompt));
let _ = app.update(Message::AssocPromptNo);
assert_eq!(app.active_modal, Some(ModalKind::DonationPrompt));
// A file-recovery dialog may interrupt startup before the first frame.
app.active_modal = Some(ModalKind::Recovery);
let _ = app.update(Message::CloseModal);
assert_eq!(app.active_modal, Some(ModalKind::DonationPrompt));
assert!(app.donation_prompt_version.is_empty());
let _ = app.update(Message::ModalContentResized(iced::Size::new(540.0, 280.0)));
assert_eq!(app.donation_prompt_version, env!("OCS_APP_VERSION"));
let _ = app.update(Message::CloseModal);
assert_eq!(app.active_modal, Some(ModalKind::UpdateNotice));
let _ = app.update(Message::UpdateNoticeClose);
assert!(app.active_modal.is_none());
assert!(app.pending_startup_modals.is_empty());
}
}
+2
View File
@@ -370,6 +370,7 @@ impl OpenCADStudio {
snap_angle_deg: self.snap_angle_deg,
otrack: self.snapper.otrack_enabled,
default_assoc_prompted: self.default_assoc_prompted,
donation_prompt_version: self.donation_prompt_version.clone(),
disabled_plugins: {
let mut v: Vec<String> = self.disabled_plugins.iter().cloned().collect();
v.sort();
@@ -432,6 +433,7 @@ impl OpenCADStudio {
// open / tab switch), not app-global, so they are not applied here.
self.snapper.otrack_enabled = s.otrack;
self.default_assoc_prompted = s.default_assoc_prompted;
self.donation_prompt_version = s.donation_prompt_version.clone();
self.disabled_plugins = s.disabled_plugins.iter().cloned().collect();
self.plugin_repos = s.plugin_repos.clone();
self.command_line.literal_spaces = s.literal_spaces;
+12 -5
View File
@@ -124,6 +124,7 @@ impl OpenCADStudio {
/// old OS window did: a style editor discards its staged (un-applied)
/// changes, and the ribbon tool that launched the dialog is de-highlighted.
fn close_active_modal(&mut self) {
self.mark_startup_modal_shown();
use super::ModalKind::*;
// Plot Style opened from PLOT behaves as a child modal.
// Closing it restores the parent Plot dialog instead of returning
@@ -282,6 +283,7 @@ impl OpenCADStudio {
}
}
let task = self.update_inner(msg);
self.show_next_startup_modal();
self.sync_open_command_history();
// Close the document-level first-touch transaction started by
// push_undo_snapshot at this message boundary.
@@ -6429,6 +6431,7 @@ impl OpenCADStudio {
return Task::none();
}
let first_measurement = self.modal_content_size.replace(size).is_none();
self.mark_startup_modal_shown();
if first_measurement {
let initial_width = self.mtext_editor.as_ref().and_then(|editor| {
editor.editing.is_none().then(|| {
@@ -7125,9 +7128,15 @@ impl OpenCADStudio {
};
self.update_notice_version = Some(info.version);
self.update_notice_body = Some(info.body);
self.active_modal = Some(super::ModalKind::UpdateNotice);
self.pending_startup_modals
.push_back(super::ModalKind::UpdateNotice);
Task::none()
}
Message::DonationPromptDonate => {
self.close_active_modal();
self.dispatch_view("DONATE", self.active_tab)
.unwrap_or_else(Task::none)
}
Message::UpdateNoticeClose => {
self.close_active_modal();
Task::none()
@@ -7143,8 +7152,7 @@ impl OpenCADStudio {
Message::AssocPromptYes => {
self.file_assoc_enabled = true;
self.mark_assoc_prompted();
self.active_modal = None;
self.reset_modal_geometry();
self.close_active_modal();
// set_default_app registers the handler first, then makes us the
// default — boot no longer does this automatically.
Task::perform(
@@ -7155,8 +7163,7 @@ impl OpenCADStudio {
Message::AssocPromptNo => {
self.file_assoc_enabled = false;
self.mark_assoc_prompted();
self.active_modal = None;
self.reset_modal_geometry();
self.close_active_modal();
Task::none()
}
Message::AssocResult(result) => {
+41 -4
View File
@@ -17,6 +17,7 @@ impl OpenCADStudio {
Some(K::FindReplace) => crate::tr!("modal", "find-replace"),
Some(K::PluginManager) => crate::tr!("modal", "plugin-manager"),
Some(K::UpdateNotice) => crate::tr!("modal", "update-available"),
Some(K::DonationPrompt) => crate::tr!("donation", "title"),
Some(K::Layers) => crate::tr!("modal", "layer-manager"),
Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"),
Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(),
@@ -1300,6 +1301,9 @@ impl OpenCADStudio {
super::super::ModalKind::AssocPrompt => {
automatic_flow(ex, default_assoc_dialog_window)
}
super::super::ModalKind::DonationPrompt => {
sized_flow(ex, 540, 360, donation_dialog_window)
}
super::super::ModalKind::AecDropWarning => {
let src_label = self
.tabs
@@ -1843,10 +1847,43 @@ fn layer_delete_warning_window(
.into()
}
/// First-launch prompt offering to register Open CAD Studio as the default
/// handler for .dwg / .dxf. "Yes" runs the platform association call; "Not now"
/// just dismisses. Either answer flips the persisted `default_assoc_prompted`
/// flag so the dialog never reappears.
fn donation_dialog_window(sizing: crate::ui::modal::ModalSizing) -> Element<'static, Message> {
container(
column![
text(crate::tr!("donation", "heading")).size(18),
row![
text(crate::tr!("donation", "body"))
.size(14)
.width(Fill),
crate::ui::icons::themed(crate::ui::icons::HEART, 52.0),
]
.spacing(20)
.align_y(iced::Center),
row![
Space::new().width(Fill),
dialog_button(
crate::tr!("start", "donate"),
Message::DonationPromptDonate,
button::primary,
),
dialog_button(
crate::tr!("donation", "decline"),
Message::CloseModal,
button::secondary,
),
]
.spacing(8)
.align_y(iced::Center),
]
.spacing(18)
.width(sizing.width),
)
.style(dialog_body_style)
.padding([24, 28])
.into()
}
/// One-time default application prompt.
fn default_assoc_dialog_window(
sizing: crate::ui::modal::ModalSizing,
) -> Element<'static, Message> {
+29
View File
@@ -408,6 +408,35 @@ mod tests {
use super::*;
use std::collections::BTreeSet;
#[test]
fn donation_text_is_translated_in_every_locale() {
for language in Language::ALL
.into_iter()
.filter(|language| *language != Language::System)
{
let loader = FluentLanguageLoader::new("opencadstudio", "en-US".parse().unwrap());
load_language(&loader, language).expect("Fluent resources must parse");
let attributes: BTreeSet<String> = loader.with_message_iter(
&language.requested()[0],
|messages| {
messages
.filter(|message| message.id.name == "donation")
.flat_map(|message| {
message.attributes.iter().map(|attr| attr.id.name.to_owned())
})
.collect()
},
);
assert_eq!(
attributes,
BTreeSet::from(["title", "heading", "body", "decline"].map(String::from))
);
for attribute in attributes {
assert!(!loader.get_attr("donation", &attribute).is_empty());
}
}
}
#[test]
fn greek_catalog_loads_and_covers_the_source_catalog() {
let loader = FluentLanguageLoader::new("opencadstudio", "en-US".parse().unwrap());