mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 07:39:23 +00:00
craft-update: apps respect the launcher's per-app version choice
The launcher lets someone keep an app on an older release or on nightlies, but the app's own update check never knew, so a pinned app prompted to update on every start and "Update and restart" undid the downgrade. `pins` is one small file the launcher writes (versions.json in its per-user data folder, or the absolute path in CRAFT_VERSIONS_FILE) and apps read: app id to Stable, Nightly or Pinned(tag), the launcher's own serde shape. A missing or damaged file means everyone follows stable; an entry a newer launcher wrote is left to the launcher and kept on save. `Updater` reads it before each check and before an offer. An app kept off stable gets no prompt and no network call; a requested check reports UpToDate, and `Updater::managed()` says why, so the public Status enum is unchanged and no app breaks on a rev bump. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
04aa1703b3
commit
7d303b4b50
4 files changed
+361
-6
No files matched your search
@@ -15,6 +15,7 @@ mod msi_copy;
|
||||
mod msi_migrate;
|
||||
pub mod packages;
|
||||
pub mod paths;
|
||||
pub mod pins;
|
||||
pub mod platform;
|
||||
#[cfg(windows)]
|
||||
pub mod portable;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! The version each app should follow, shared by the launcher and the apps.
|
||||
//!
|
||||
//! The launcher writes one small file when someone chooses a version in "Choose version"; an app's
|
||||
//! own update check reads it, so an app kept on an older release, or on nightlies, is not offered
|
||||
//! the latest stable release every time it starts. Only the launcher writes the file; apps read.
|
||||
//!
|
||||
//! Invariants:
|
||||
//! - A missing, unreadable or damaged file means every app follows stable releases, as before.
|
||||
//! - An entry this version cannot read (written by a newer launcher) counts as managed by the
|
||||
//! launcher, never as stable, and [`Pins::save`] writes it back unchanged.
|
||||
//! - Choosing stable removes the app's entry, so the file only lists apps kept on something else.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Names a file to use in place of the default (absolute paths only). The launcher sets it for an
|
||||
/// app whose home it redirects (tarball installs on Linux), so the app still finds the real file.
|
||||
pub const PATH_ENV: &str = "CRAFT_VERSIONS_FILE";
|
||||
|
||||
/// Which published build an app follows. A pinned version never changes on its own.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VersionTrack {
|
||||
#[default]
|
||||
Stable,
|
||||
Nightly,
|
||||
Pinned(String),
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct File {
|
||||
/// App id to its track, kept as raw values so entries from a newer launcher survive a save.
|
||||
#[serde(default)]
|
||||
apps: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// The saved version choices.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Pins {
|
||||
apps: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// What an app's own update check should do.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Choice {
|
||||
/// Follow stable releases: check and offer as usual.
|
||||
Stable,
|
||||
/// The launcher keeps this app on another track; the app offers nothing itself.
|
||||
Managed(VersionTrack),
|
||||
/// An entry this version cannot read; also left to the launcher.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Pins {
|
||||
/// The file in the launcher's per-user data folder, or the one [`PATH_ENV`] names.
|
||||
pub fn default_path() -> Option<PathBuf> {
|
||||
if let Some(path) = std::env::var_os(PATH_ENV).map(PathBuf::from).filter(|p| p.is_absolute()) {
|
||||
return Some(path);
|
||||
}
|
||||
let launcher = &crate::suite::LAUNCHER;
|
||||
let dir = if cfg!(any(target_os = "macos", target_os = "windows")) { launcher.name } else { launcher.id };
|
||||
crate::folders::data_local_dir().map(|d| d.join(dir).join("versions.json"))
|
||||
}
|
||||
|
||||
/// Reads `path`. A missing or damaged file reads as empty: every app follows stable releases.
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
|
||||
Err(e) => {
|
||||
log::warn!("couldn't read {} ({e}); every app follows stable releases", path.display());
|
||||
return Self::default();
|
||||
}
|
||||
};
|
||||
match serde_json::from_str::<File>(&text) {
|
||||
Ok(file) => Self { apps: file.apps },
|
||||
Err(e) => {
|
||||
log::warn!("{} is damaged ({e}); every app follows stable releases", path.display());
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the default file (see [`Self::default_path`]).
|
||||
pub fn load_default() -> Self {
|
||||
Self::default_path().map(|p| Self::load(&p)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Writes `path` in one step, so a reader never sees half a file.
|
||||
pub fn save(&self, path: &Path) -> Result<(), String> {
|
||||
let file = File { apps: self.apps.clone() };
|
||||
let text = serde_json::to_string_pretty(&file).map_err(|e| format!("couldn't encode the version choices ({e})"))?;
|
||||
crate::paths::write_atomic(path, text.as_bytes())
|
||||
}
|
||||
|
||||
/// What `app`'s own update check should do.
|
||||
pub fn choice(&self, app: &str) -> Choice {
|
||||
match self.apps.get(app) {
|
||||
None => Choice::Stable,
|
||||
Some(raw) => match serde_json::from_value::<VersionTrack>(raw.clone()) {
|
||||
Ok(VersionTrack::Stable) => Choice::Stable,
|
||||
Ok(track) => Choice::Managed(track),
|
||||
Err(_) => Choice::Unknown,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The track `app` follows, when this version can read it.
|
||||
pub fn track(&self, app: &str) -> Option<VersionTrack> {
|
||||
match self.choice(app) {
|
||||
Choice::Stable => Some(VersionTrack::Stable),
|
||||
Choice::Managed(track) => Some(track),
|
||||
Choice::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `app`'s track. Stable removes the entry.
|
||||
pub fn set(&mut self, app: &str, track: &VersionTrack) {
|
||||
match track {
|
||||
VersionTrack::Stable => {
|
||||
self.apps.remove(app);
|
||||
}
|
||||
other => {
|
||||
if let Ok(raw) = serde_json::to_value(other) {
|
||||
self.apps.insert(app.to_string(), raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every app with a track this version can read, stable ones excluded.
|
||||
pub fn tracks(&self) -> impl Iterator<Item = (&str, VersionTrack)> {
|
||||
self.apps.iter().filter_map(|(id, raw)| serde_json::from_value(raw.clone()).ok().map(|t| (id.as_str(), t)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pinned(tag: &str) -> VersionTrack {
|
||||
VersionTrack::Pinned(tag.into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_or_damaged_file_means_every_app_follows_stable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("versions.json");
|
||||
assert_eq!(Pins::load(&path).choice("pdfcraft"), Choice::Stable);
|
||||
std::fs::write(&path, "{ not json").unwrap();
|
||||
assert_eq!(Pins::load(&path).choice("pdfcraft"), Choice::Stable);
|
||||
std::fs::write(&path, r#"{"apps":"wrong shape"}"#).unwrap();
|
||||
assert_eq!(Pins::load(&path).choice("pdfcraft"), Choice::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choices_survive_a_save_and_only_the_named_app_is_held() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nested").join("versions.json");
|
||||
let mut pins = Pins::default();
|
||||
pins.set("pdfcraft", &pinned("v0.13.0"));
|
||||
pins.set("photocraft", &VersionTrack::Nightly);
|
||||
pins.save(&path).unwrap();
|
||||
let read = Pins::load(&path);
|
||||
assert_eq!(read, pins);
|
||||
assert_eq!(read.choice("pdfcraft"), Choice::Managed(pinned("v0.13.0")));
|
||||
assert_eq!(read.choice("photocraft"), Choice::Managed(VersionTrack::Nightly));
|
||||
assert_eq!(read.choice("wordcraft"), Choice::Stable);
|
||||
assert_eq!(read.tracks().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_stable_removes_the_entry() {
|
||||
let mut pins = Pins::default();
|
||||
pins.set("pdfcraft", &pinned("v0.13.0"));
|
||||
pins.set("pdfcraft", &VersionTrack::Stable);
|
||||
assert_eq!(pins, Pins::default());
|
||||
assert_eq!(pins.track("pdfcraft"), Some(VersionTrack::Stable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_uses_the_launchers_own_shape() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("versions.json");
|
||||
std::fs::write(&path, r#"{"apps":{"pdfcraft":{"Pinned":"v0.13.0"},"filmcraft":"Nightly","wordcraft":"Stable"}}"#).unwrap();
|
||||
let pins = Pins::load(&path);
|
||||
assert_eq!(pins.track("pdfcraft"), Some(pinned("v0.13.0")));
|
||||
assert_eq!(pins.track("filmcraft"), Some(VersionTrack::Nightly));
|
||||
assert_eq!(pins.choice("wordcraft"), Choice::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_from_a_newer_launcher_is_left_to_the_launcher_and_kept_on_save() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("versions.json");
|
||||
std::fs::write(&path, r#"{"apps":{"pdfcraft":{"Beta":{"since":"v0.20.0"}}}}"#).unwrap();
|
||||
let mut pins = Pins::load(&path);
|
||||
assert_eq!(pins.choice("pdfcraft"), Choice::Unknown);
|
||||
assert_eq!(pins.track("pdfcraft"), None);
|
||||
pins.set("wordcraft", &pinned("v1.0.0"));
|
||||
pins.save(&path).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("Beta") && text.contains("v0.20.0"), "kept: {text}");
|
||||
assert_eq!(Pins::load(&path).choice("pdfcraft"), Choice::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_file_sits_in_the_launchers_data_folder() {
|
||||
// The environment override is read by the same function; it is not set in tests.
|
||||
if std::env::var_os(PATH_ENV).is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(path) = Pins::default_path() {
|
||||
assert!(path.is_absolute());
|
||||
assert_eq!(path.file_name().and_then(|n| n.to_str()), Some("versions.json"));
|
||||
let folder = path.parent().and_then(|p| p.file_name()).and_then(|n| n.to_str()).unwrap_or_default().to_string();
|
||||
assert!(folder == "ArtCraft Launcher" || folder == "artcraft-launcher", "{folder}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,18 @@
|
||||
//! and the self-update worker. An app creates one [`Updater`], calls [`Updater::tick`] and
|
||||
//! [`Updater::show`] each frame, and acts on the [`Action`] it returns, so every app gets the same
|
||||
//! handling of failures, dropped workers, repaints and quitting.
|
||||
//!
|
||||
//! An app the launcher keeps on another version (see [`crate::pins`]) offers nothing itself: the
|
||||
//! check reads the shared choice first, and a requested check reports [`Status::UpToDate`] while
|
||||
//! [`Updater::managed`] says why.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::dialog::{StartupPrompt, StartupPromptResponse};
|
||||
use crate::pins::{Choice, Pins};
|
||||
use crate::release::Release;
|
||||
use crate::{App, Http, Paths, Platform, Stop, check_for_update, self_update};
|
||||
|
||||
@@ -55,6 +61,10 @@ pub struct Updater {
|
||||
restart: bool,
|
||||
/// An update is installed and waits for the app to exit; nothing starts a second one.
|
||||
installed: bool,
|
||||
/// The version-choice file to read in place of [`Pins::default_path`] (tests).
|
||||
pins_path: Option<PathBuf>,
|
||||
/// The launcher's choice for this app when it is not stable, as of the last check or offer.
|
||||
managed: Option<Choice>,
|
||||
}
|
||||
|
||||
impl Updater {
|
||||
@@ -73,6 +83,8 @@ impl Updater {
|
||||
prompt: None,
|
||||
restart: false,
|
||||
installed: false,
|
||||
pins_path: None,
|
||||
managed: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +94,32 @@ impl Updater {
|
||||
self
|
||||
}
|
||||
|
||||
/// Reads the version choices from `path` instead of the launcher's file (tests).
|
||||
pub fn with_pins_path(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.pins_path = Some(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The launcher's choice for this app when it keeps it off stable releases (a pinned version,
|
||||
/// nightlies, or a choice this version cannot read), as of the last check. A window answering
|
||||
/// Help ▸ Check for Updates can say so instead of "newest version".
|
||||
pub fn managed(&self) -> Option<&Choice> {
|
||||
self.managed.as_ref()
|
||||
}
|
||||
|
||||
/// Reads the launcher's choice for this app; remembers it when it is not stable.
|
||||
fn read_choice(&mut self) -> bool {
|
||||
let pins = match &self.pins_path {
|
||||
Some(path) => Pins::load(path),
|
||||
None => Pins::load_default(),
|
||||
};
|
||||
self.managed = Some(pins.choice(self.app.id)).filter(|c| *c != Choice::Stable);
|
||||
if let Some(choice) = &self.managed {
|
||||
log::info!("{} follows the launcher's choice ({choice:?}); no update offered here", self.app.name);
|
||||
}
|
||||
self.managed.is_some()
|
||||
}
|
||||
|
||||
/// The check at start, once. Later calls do nothing.
|
||||
pub fn check_at_start(&mut self, ctx: &egui::Context) {
|
||||
if !std::mem::replace(&mut self.started, true) {
|
||||
@@ -98,7 +136,7 @@ impl Updater {
|
||||
|
||||
/// Opens the startup prompt for a release the app found itself.
|
||||
pub fn offer(&mut self, release: Release) {
|
||||
if self.install.is_none() && !self.installed {
|
||||
if self.install.is_none() && !self.installed && !self.read_choice() {
|
||||
self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone()));
|
||||
}
|
||||
}
|
||||
@@ -130,6 +168,11 @@ impl Updater {
|
||||
if self.check.is_some() {
|
||||
return;
|
||||
}
|
||||
// The file is a few bytes, read once per check; the launcher may have changed it since.
|
||||
if self.read_choice() {
|
||||
self.finish_check(Ok(None));
|
||||
return;
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
let (source, app, version, ctx) = (self.source.clone(), self.app, self.version.clone(), ctx.clone());
|
||||
let spawned = std::thread::Builder::new().name("update-check".into()).spawn(move || {
|
||||
@@ -153,7 +196,7 @@ impl Updater {
|
||||
match answer {
|
||||
Ok(Some(release)) => {
|
||||
self.status = Status::Idle;
|
||||
if self.prompt.is_none() && self.install.is_none() && !self.installed {
|
||||
if self.prompt.is_none() && self.install.is_none() && !self.installed && self.managed.is_none() {
|
||||
self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone()));
|
||||
}
|
||||
}
|
||||
@@ -313,12 +356,17 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::suite::PHOTOCRAFT;
|
||||
|
||||
/// An updater that never reads the real version-choice file.
|
||||
fn updater(answer: Checked) -> Updater {
|
||||
Updater::new(&PHOTOCRAFT, "0.1.0").with_source(Arc::new(move || answer.clone()))
|
||||
Updater::new(&PHOTOCRAFT, "0.1.0").with_source(Arc::new(move || answer.clone())).with_pins_path(no_pins())
|
||||
}
|
||||
|
||||
pub(super) fn no_pins() -> PathBuf {
|
||||
std::env::temp_dir().join("craft-update-tests-no-such-dir").join("versions.json")
|
||||
}
|
||||
|
||||
/// Ticks until the running check has reported.
|
||||
fn settle(updater: &mut Updater, ctx: &egui::Context) {
|
||||
pub(super) fn settle(updater: &mut Updater, ctx: &egui::Context) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while updater.check.is_some() && std::time::Instant::now() < deadline {
|
||||
updater.tick(ctx);
|
||||
@@ -326,7 +374,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn newer() -> Release {
|
||||
pub(super) fn newer() -> Release {
|
||||
Release { tag: "v9.0.0".into(), version: crate::version::parse("9.0.0"), ..Release::default() }
|
||||
}
|
||||
|
||||
@@ -444,7 +492,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_panicking_check_is_an_error_not_a_hang() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut u = Updater::new(&PHOTOCRAFT, "0.1.0").with_source(Arc::new(|| std::panic::panic_any("boom")));
|
||||
let mut u = Updater::new(&PHOTOCRAFT, "0.1.0").with_source(Arc::new(|| std::panic::panic_any("boom"))).with_pins_path(no_pins());
|
||||
u.check_now(&ctx);
|
||||
settle(&mut u, &ctx);
|
||||
assert!(matches!(u.status(), Status::Failed(_)));
|
||||
@@ -534,3 +582,7 @@ mod tests {
|
||||
assert_eq!(u.show(&ctx), Action::None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "updater_pin_tests.rs"]
|
||||
mod pin_tests;
|
||||
@@ -0,0 +1,81 @@
|
||||
//! The startup prompt and requested checks follow the launcher's shared version choice.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::tests::{newer, settle};
|
||||
use super::*;
|
||||
use crate::suite::PHOTOCRAFT;
|
||||
|
||||
/// A version-choice file holding `track` for PhotoCraft.
|
||||
fn pins_with(track: crate::pins::VersionTrack) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut pins = Pins::default();
|
||||
pins.set(PHOTOCRAFT.id, &track);
|
||||
pins.save(&dir.path().join("versions.json")).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pinned_app_is_not_prompted_and_the_network_is_not_asked() {
|
||||
use crate::pins::VersionTrack;
|
||||
let ctx = egui::Context::default();
|
||||
for track in [VersionTrack::Pinned("v0.13.0".into()), VersionTrack::Nightly] {
|
||||
let dir = pins_with(track.clone());
|
||||
let mut u = Updater::new(&PHOTOCRAFT, "0.13.0")
|
||||
.with_source(Arc::new(|| std::panic::panic_any("the network must not be asked")))
|
||||
.with_pins_path(dir.path().join("versions.json"));
|
||||
u.check_at_start(&ctx);
|
||||
settle(&mut u, &ctx);
|
||||
assert!(!u.prompt_open(), "{track:?}");
|
||||
assert_eq!(u.status(), &Status::Idle);
|
||||
u.check_now(&ctx);
|
||||
settle(&mut u, &ctx);
|
||||
assert_eq!(u.status(), &Status::UpToDate, "{track:?}");
|
||||
assert_eq!(u.managed(), Some(&Choice::Managed(track.clone())));
|
||||
u.offer(newer());
|
||||
assert!(!u.prompt_open(), "an offer is held too: {track:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpinning_in_the_launcher_brings_updates_back_on_the_next_check() {
|
||||
let ctx = egui::Context::default();
|
||||
let dir = pins_with(crate::pins::VersionTrack::Pinned("v0.13.0".into()));
|
||||
let path = dir.path().join("versions.json");
|
||||
let mut u = Updater::new(&PHOTOCRAFT, "0.13.0").with_source(Arc::new(|| Ok(Some(newer())))).with_pins_path(&path);
|
||||
u.check_now(&ctx);
|
||||
settle(&mut u, &ctx);
|
||||
assert!(!u.prompt_open());
|
||||
Pins::default().save(&path).unwrap();
|
||||
u.check_now(&ctx);
|
||||
settle(&mut u, &ctx);
|
||||
assert!(u.prompt_open());
|
||||
assert_eq!(u.managed(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_choice_from_a_newer_launcher_also_holds_updates() {
|
||||
let ctx = egui::Context::default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("versions.json");
|
||||
std::fs::write(&path, r#"{"apps":{"photocraft":{"Beta":1}}}"#).unwrap();
|
||||
let mut u = Updater::new(&PHOTOCRAFT, "0.13.0").with_source(Arc::new(|| Ok(Some(newer())))).with_pins_path(&path);
|
||||
u.check_at_start(&ctx);
|
||||
settle(&mut u, &ctx);
|
||||
assert!(!u.prompt_open());
|
||||
assert_eq!(u.managed(), Some(&Choice::Unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_apps_pin_does_not_hold_this_one() {
|
||||
let ctx = egui::Context::default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("versions.json");
|
||||
let mut pins = Pins::default();
|
||||
pins.set("pdfcraft", &crate::pins::VersionTrack::Pinned("v0.2.0".into()));
|
||||
pins.save(&path).unwrap();
|
||||
let mut u = Updater::new(&PHOTOCRAFT, "0.13.0").with_source(Arc::new(|| Ok(Some(newer())))).with_pins_path(&path);
|
||||
u.check_at_start(&ctx);
|
||||
settle(&mut u, &ctx);
|
||||
assert!(u.prompt_open());
|
||||
}
|
||||
Reference in new issue
Block a user