forked from 1b-agent/opencadstudio-upstream
Show donation request once per app version
This commit is contained in:
1 parent
1b0a42a35c
commit
2c83ae2736
28 files changed
+332
-24
No files matched your search
+10
-14
@@ -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
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user